Initial project sync
This commit is contained in:
@@ -0,0 +1 @@
|
||||
uid://dx6s4rdqhe8i7
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://resources/action_data.gd" id="1_test_block_start"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_test_block_start")
|
||||
id = &"test_block_start"
|
||||
display_name = "Test Block Start"
|
||||
input_pattern = Array[StringName]([&"S"])
|
||||
damage_mult = 0.0
|
||||
move_mult_x = 0.0
|
||||
move_mult_y = 0.0
|
||||
hit_type = &"defense"
|
||||
clear_window = false
|
||||
startup_beats = 0.1
|
||||
active_beats = 0.5
|
||||
recovery_beats = 0.2
|
||||
cancel_from = 0.5
|
||||
allowed_ground_states = Array[StringName]([&"Grounded"])
|
||||
action_tags = Array[StringName]([&"basic", &"block", &"down"])
|
||||
defense_tags = Array[StringName]([&"parry"])
|
||||
animation = &"block_start"
|
||||
@@ -0,0 +1,21 @@
|
||||
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://resources/action_data.gd" id="1_test_s_followup"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_test_s_followup")
|
||||
id = &"test_s_followup_combo"
|
||||
display_name = "Test S Follow-up Combo"
|
||||
input_pattern = Array[StringName]([&"S", &"D"])
|
||||
damage_mult = 1.0
|
||||
move_mult_x = 0.0
|
||||
move_mult_y = 0.0
|
||||
hit_type = &"normal"
|
||||
clear_window = true
|
||||
startup_beats = 0.25
|
||||
active_beats = 0.25
|
||||
recovery_beats = 0.5
|
||||
cancel_from = 0.5
|
||||
allowed_ground_states = Array[StringName]([&"Grounded"])
|
||||
action_tags = Array[StringName]([&"combo", &"right"])
|
||||
animation = &"atk_ground_1"
|
||||
@@ -0,0 +1,162 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var started: Array[StringName] = []
|
||||
var rejected: Array[StringName] = []
|
||||
var judged: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var fixture := await _controller_fixture()
|
||||
if fixture.is_empty():
|
||||
_finish()
|
||||
return
|
||||
var controller: Node = fixture["controller"]
|
||||
var combo: Node = fixture["combo"]
|
||||
controller.connect("action_started", _on_action_started)
|
||||
controller.connect("action_rejected", _on_action_rejected)
|
||||
_event_bus().connect("judgement_made", _on_judgement_made)
|
||||
|
||||
controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 12))
|
||||
_expect_array(combo.call("get_slots"), [&"A"], "A should enter ComboWindow")
|
||||
_expect_string(str(started[started.size() - 1]), "ground_attack_left_1", "A should start left attack")
|
||||
_expect_int(int(judged[judged.size() - 1].get("beat")), 12, "judgement_made should include nearest beat")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 13))
|
||||
_expect_array(combo.call("get_slots"), [&"A", &"A"], "second A should stay in ComboWindow")
|
||||
_expect_string(str(started[started.size() - 1]), "ground_attack_left_2", "AA should start left second attack")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
controller.call("submit_intent", _intent(&"D", &"d", &"pressed", "miss", 14))
|
||||
_expect_array(combo.call("get_slots"), [&"Ø"], "miss should record explicit miss placeholder")
|
||||
_expect_bool(started.is_empty(), true, "miss should not start an action")
|
||||
_expect_bool(rejected.has(&"miss"), true, "miss should reject with miss reason")
|
||||
|
||||
var context: Dictionary = controller.call("_resolver_context")
|
||||
_expect(context.has("burst_action_id"), "resolver context should expose burst_action_id")
|
||||
_expect(context.has("counter_action_id"), "resolver context should expose counter_action_id")
|
||||
_expect(context.has("blade_chain_action_id"), "resolver context should expose blade_chain_action_id")
|
||||
fixture["root"].free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _controller_fixture() -> Dictionary:
|
||||
var combo_script: Script = load("res://scenes/components/combo_window.gd")
|
||||
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
|
||||
var state_script: Script = load("res://scenes/components/state_machine.gd")
|
||||
var energy_script: Script = load("res://scenes/components/energy_component.gd")
|
||||
var controller_script: Script = load("res://scenes/components/action_controller.gd")
|
||||
for item: Dictionary in [
|
||||
{"script": combo_script, "label": "ComboWindow"},
|
||||
{"script": resolver_script, "label": "ActionResolver"},
|
||||
{"script": state_script, "label": "StateMachine"},
|
||||
{"script": energy_script, "label": "EnergyComponent"},
|
||||
{"script": controller_script, "label": "ActionController"},
|
||||
]:
|
||||
_expect(item["script"] != null, "%s script should load" % item["label"])
|
||||
if combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null:
|
||||
return {}
|
||||
|
||||
var fixture_root := Node.new()
|
||||
root.add_child(fixture_root)
|
||||
var combo: Node = combo_script.new()
|
||||
combo.name = "ComboWindow"
|
||||
fixture_root.add_child(combo)
|
||||
var resolver: Node = resolver_script.new()
|
||||
resolver.name = "ActionResolver"
|
||||
fixture_root.add_child(resolver)
|
||||
var state: Node = state_script.new()
|
||||
state.name = "StateMachine"
|
||||
fixture_root.add_child(state)
|
||||
var energy: Node = energy_script.new()
|
||||
energy.name = "EnergyComponent"
|
||||
fixture_root.add_child(energy)
|
||||
var controller: Node = controller_script.new()
|
||||
controller.name = "ActionController"
|
||||
controller.set("combo_window_path", NodePath("../ComboWindow"))
|
||||
controller.set("action_resolver_path", NodePath("../ActionResolver"))
|
||||
controller.set("state_machine_path", NodePath("../StateMachine"))
|
||||
fixture_root.add_child(controller)
|
||||
await process_frame
|
||||
energy.call("set_values", 99, 99)
|
||||
return {
|
||||
"root": fixture_root,
|
||||
"combo": combo,
|
||||
"controller": controller,
|
||||
}
|
||||
|
||||
|
||||
func _intent(symbol: StringName, rhythm_action: StringName, event_type: StringName, label: String, beat: int) -> RefCounted:
|
||||
var intent_script: Script = load("res://scenes/components/input_intent.gd")
|
||||
var intent: RefCounted = intent_script.call("create", symbol, rhythm_action, event_type, float(Time.get_ticks_msec()))
|
||||
intent.set("judgement", {
|
||||
"label": label,
|
||||
"diff": 0.0,
|
||||
"abs_diff": 0.0,
|
||||
"nearest_beat": beat,
|
||||
})
|
||||
return intent
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
started.append(StringName(str(action.get("id"))))
|
||||
|
||||
|
||||
func _on_action_rejected(_intent, reason: StringName) -> void:
|
||||
rejected.append(reason)
|
||||
|
||||
|
||||
func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void:
|
||||
judged.append({"quality": quality, "offset_ms": offset_ms, "beat": beat_index})
|
||||
|
||||
|
||||
func _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 _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_array(actual: Array, expected: Array, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS action controller flow")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bl5r1ip6l2ymo
|
||||
@@ -0,0 +1,61 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var exporter: Script = load("res://tools/export_action_patterns.gd")
|
||||
_expect(exporter != null, "Action pattern export tool should load")
|
||||
if exporter != null:
|
||||
var patterns: Dictionary = exporter.call("export_patterns")
|
||||
var expected := {
|
||||
"A": &"ground_attack_left_1",
|
||||
"AA": &"ground_attack_left_2",
|
||||
"AAA": &"ground_attack_left_3",
|
||||
"ASP": &"dash_slash_left",
|
||||
"AASP": &"combo_finisher_left",
|
||||
"AAASP": &"ground_smash_left",
|
||||
"D": &"ground_attack_right_1",
|
||||
"DD": &"ground_attack_right_2",
|
||||
"DDD": &"ground_attack_right_3",
|
||||
"DSP": &"dash_slash_right",
|
||||
"DDSP": &"combo_finisher_right",
|
||||
"DDDSP": &"ground_smash_right",
|
||||
"W": &"launcher_up",
|
||||
"S": &"block_start",
|
||||
}
|
||||
_expect_int(patterns.size(), expected.size(), "Exported pattern count")
|
||||
for key: String in expected:
|
||||
_expect(patterns.get(key) == expected[key], "Pattern %s should map to %s" % [key, expected[key]])
|
||||
for forbidden_key: String in ["AD", "DA", "ASPSP"]:
|
||||
_expect(not patterns.has(forbidden_key), "Forbidden pattern %s should not be exported" % forbidden_key)
|
||||
_expect(not patterns.has("SS"), "SS should not be exported as a separate skill; runtime should reuse trailing S block fallback")
|
||||
var text := str(exporter.call("to_text"))
|
||||
_expect(text.contains("A -> ground_attack_left_1"), "Pattern export text should include A")
|
||||
_expect(text.contains("W -> launcher_up"), "Pattern export text should include W")
|
||||
_expect(text.contains("S -> block_start"), "Pattern export text should include S")
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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 action pattern export")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://di3k4o5megqdl
|
||||
@@ -0,0 +1,141 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_check_event_bus_autoload()
|
||||
_check_current_main_scene_loads()
|
||||
_check_player_components()
|
||||
_check_skill_resources()
|
||||
_check_project_layers_and_inputs()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_event_bus_autoload() -> void:
|
||||
_expect(ProjectSettings.has_setting("autoload/EventBus"), "EventBus should be registered as a game autoload")
|
||||
if ProjectSettings.has_setting("autoload/EventBus"):
|
||||
var path := str(ProjectSettings.get_setting("autoload/EventBus"))
|
||||
_expect(path.contains("res://autoload/event_bus.gd"), "EventBus autoload should point at autoload/event_bus.gd")
|
||||
var event_bus_script := load("res://autoload/event_bus.gd")
|
||||
_expect(event_bus_script != null, "autoload/event_bus.gd should exist")
|
||||
_expect(ProjectSettings.has_setting("autoload/RhythmManager"), "RhythmManager should be registered as a game autoload")
|
||||
_expect(ProjectSettings.has_setting("autoload/CombatManager"), "CombatManager should be registered as a game autoload")
|
||||
|
||||
|
||||
func _check_current_main_scene_loads() -> void:
|
||||
var main_scene_path := str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||||
_expect(main_scene_path == "res://scenes/main/main.tscn", "Phase-7 main scene should route through the migrated Stage/Main layer")
|
||||
var main_scene: PackedScene = load(main_scene_path)
|
||||
_expect(main_scene != null, "%s should load" % main_scene_path)
|
||||
if main_scene != null:
|
||||
var main := main_scene.instantiate()
|
||||
get_root().add_child(main)
|
||||
_expect(main.has_node("Stage"), "Phase-7 main scene should instance Stage")
|
||||
_expect(main.has_node("ChartRunner"), "Phase-7 main scene should own ChartRunner")
|
||||
_expect(not main.has_node("RhythmConductor"), "Main scene should use RhythmManager autoload instead of a RhythmConductor child")
|
||||
main.free()
|
||||
|
||||
|
||||
func _check_player_components() -> void:
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
_expect(player_scene != null, "player.tscn should load")
|
||||
if player_scene != null:
|
||||
var player := player_scene.instantiate()
|
||||
get_root().add_child(player)
|
||||
for node_name: String in [
|
||||
"StateMachine",
|
||||
"InputComponent",
|
||||
"ComboWindow",
|
||||
"ActionResolver",
|
||||
"ActionExecutor",
|
||||
"MotionExecutor",
|
||||
"MovementMotor",
|
||||
"BurstComponent",
|
||||
"ChargeComponent",
|
||||
"EnergyComponent",
|
||||
"EffectContainer",
|
||||
"HealthComponent",
|
||||
"DamageReceiver",
|
||||
"DamageEmitter",
|
||||
]:
|
||||
_expect(player.has_node(node_name), "Player should have %s child component" % node_name)
|
||||
player.free()
|
||||
var source := _read_text("res://scenes/characters/player.gd")
|
||||
_expect_not_contains(source, "KEY_", "Player should not match raw KEY_* values")
|
||||
_expect_not_contains(source, "get_first_node_in_group(\"rhythm_conductor\")", "Player should not look up RhythmConductor by group")
|
||||
_expect_not_contains(source, "PlayerProjectile.new()", "Player should not instantiate projectiles directly")
|
||||
_expect_not_contains(source, "get_parent()", "Player should not reach upward with get_parent()")
|
||||
|
||||
|
||||
func _check_skill_resources() -> void:
|
||||
var action_script := load("res://resources/action_data.gd")
|
||||
_expect(action_script != null, "resources/action_data.gd should exist")
|
||||
var action_dir := DirAccess.open("res://resources/actions")
|
||||
_expect(action_dir != null, "resources/actions should exist")
|
||||
if action_dir != null:
|
||||
var action_files: Array[String] = []
|
||||
for file_name: String in action_dir.get_files():
|
||||
if file_name.ends_with(".tres"):
|
||||
action_files.append(file_name)
|
||||
_expect(action_files.size() >= 12, "phase-4+ player actions should be saved as .tres resources")
|
||||
var tracker_script := load("res://scenes/components/combo_window.gd")
|
||||
var resolver_script := load("res://scenes/combat/action_resolver.gd")
|
||||
_expect(tracker_script != null, "ComboWindow script should load")
|
||||
_expect(resolver_script != null, "ActionResolver script should load")
|
||||
if tracker_script == null or resolver_script == null:
|
||||
return
|
||||
var window = tracker_script.new()
|
||||
window.record(&"A")
|
||||
var resolved = resolver_script.resolve(window)
|
||||
_expect(resolved is Resource, "ActionResolver.resolve should return an ActionData Resource, not a Dictionary")
|
||||
if resolved is Resource:
|
||||
_expect(resolved.get("input_pattern") is Array, "Resolved ActionData should expose input_pattern")
|
||||
_expect(str(resolved.get("id")) == "ground_attack_left_1", "A pattern should resolve to ground_attack_left_1")
|
||||
resolver_script.clear_cache()
|
||||
window.free()
|
||||
|
||||
|
||||
func _check_project_layers_and_inputs() -> void:
|
||||
var expected_layers := {
|
||||
"layer_names/2d_physics/layer_1": "world",
|
||||
"layer_names/2d_physics/layer_2": "player_hurtbox",
|
||||
"layer_names/2d_physics/layer_3": "enemy_hurtbox",
|
||||
"layer_names/2d_physics/layer_4": "player_hitbox",
|
||||
"layer_names/2d_physics/layer_5": "enemy_hitbox",
|
||||
}
|
||||
for key: String in expected_layers:
|
||||
_expect(ProjectSettings.has_setting(key), "%s should be configured" % key)
|
||||
if ProjectSettings.has_setting(key):
|
||||
_expect(str(ProjectSettings.get_setting(key)) == expected_layers[key], "%s should be named %s" % [key, expected_layers[key]])
|
||||
for action_name: String in ["move_left", "move_right", "combo_w", "combo_a", "combo_d", "combo_s", "combo_space"]:
|
||||
_expect(InputMap.has_action(action_name), "InputMap should define %s" % action_name)
|
||||
_expect(not InputMap.has_action("player_space"), "InputMap should remove duplicate player_space action")
|
||||
|
||||
|
||||
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_not_contains(source: String, needle: String, label: String) -> void:
|
||||
if source.contains(needle):
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS architecture refactor")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfs2gbjkn4png
|
||||
@@ -0,0 +1,136 @@
|
||||
extends SceneTree
|
||||
|
||||
const BUFF_ID := &"time_anchor_attack_buff"
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var bus: Node = load("res://autoload/event_bus.gd").new()
|
||||
bus.name = "EventBus"
|
||||
root.add_child(bus)
|
||||
|
||||
var actor := Node.new()
|
||||
actor.name = "TestActor"
|
||||
var container: Node = load("res://scenes/components/effect_container.gd").new()
|
||||
container.name = "EffectContainer"
|
||||
actor.add_child(container)
|
||||
var buff_component: Node = load("res://scenes/components/attack_buff_component.gd").new()
|
||||
buff_component.name = "AttackBuffComponent"
|
||||
actor.add_child(buff_component)
|
||||
root.add_child(actor)
|
||||
await process_frame
|
||||
|
||||
var broadcasts: Array = []
|
||||
bus.connect("attack_buff_changed", func(stacks: int, max_stacks: int) -> void:
|
||||
broadcasts.append([stacks, max_stacks])
|
||||
)
|
||||
|
||||
var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd")
|
||||
|
||||
var health: Node = load("res://scenes/components/health_component.gd").new()
|
||||
health.name = "HealthComponent"
|
||||
health.set("maximum", 1000)
|
||||
health.set("current", 400)
|
||||
actor.add_child(health)
|
||||
await process_frame
|
||||
|
||||
# --- Perfect/Good held anchors add stacks; damage formula is 1 + 0.50 x stacks. ---
|
||||
for _index: int in range(3):
|
||||
bus.emit_signal("time_anchor_resolved", {}, true, {"label": "perfect"})
|
||||
_expect_int(int(buff_component.call("attack_buff_stacks")), 3, "three held anchors should give three stacks")
|
||||
var damage := float(stat_resolver.call("resolve_damage", 100.0, null, {"label": "perfect"}, container, null))
|
||||
_expect_float(damage, 250.0, "damage should scale by 1 + 0.50 x stacks")
|
||||
if not broadcasts.is_empty():
|
||||
_expect_int(int(broadcasts[broadcasts.size() - 1][1]), 7, "broadcast should carry max stacks 7")
|
||||
|
||||
# --- Broken anchors do not add stacks. ---
|
||||
bus.emit_signal("time_anchor_resolved", {}, false, {})
|
||||
_expect_int(int(buff_component.call("attack_buff_stacks")), 3, "broken anchors must not add stacks")
|
||||
|
||||
# --- Bad anchors hold/heal but do not add attack-buff stacks. ---
|
||||
bus.emit_signal("time_anchor_resolved", {"beat": 15}, true, {"label": "bad"})
|
||||
_expect_int(int(buff_component.call("attack_buff_stacks")), 3, "bad held anchors must not add attack buff")
|
||||
|
||||
# --- Cap at 7; extra Perfect/Good holds keep the cap. ---
|
||||
for _index: int in range(30):
|
||||
bus.emit_signal("time_anchor_resolved", {}, true, {"label": "good"})
|
||||
_expect_int(int(buff_component.call("attack_buff_stacks")), 7, "stacks should cap at 7")
|
||||
damage = float(stat_resolver.call("resolve_damage", 100.0, null, {"label": "perfect"}, container, null))
|
||||
_expect_float(damage, 450.0, "capped stacks should give +350% damage")
|
||||
|
||||
# --- Each actual phase switch subtracts two stacks, bottoming at zero. ---
|
||||
var expected_reductions := [5, 3, 1, 0]
|
||||
for expected: int in expected_reductions:
|
||||
bus.emit_signal("time_phase_changed", &"past", &"future", &"time_anchor_broken")
|
||||
_expect_int(int(buff_component.call("attack_buff_stacks")), expected, "phase switch should subtract stacks to %d" % expected)
|
||||
var active_ids: Array = container.call("active_effect_ids")
|
||||
_expect_bool(active_ids.has(BUFF_ID), false, "zero stacks should remove the buff effect")
|
||||
|
||||
# --- Effect stacks and component report stay equal. ---
|
||||
bus.emit_signal("time_anchor_resolved", {}, true, {"label": "perfect"})
|
||||
_expect_int(int(container.call("effect_stacks", BUFF_ID)), int(buff_component.call("attack_buff_stacks")), "effect stacks and component report should match")
|
||||
|
||||
# --- First held anchor per 4-beat window heals; low health doubles the heal. ---
|
||||
health.call("set_values", 400, 1000)
|
||||
bus.emit_signal("time_anchor_resolved", {"beat": 8}, true, {"label": "perfect"})
|
||||
_expect_int(int(health.get("current")), 460, "perfect anchor should heal 60 while below half health")
|
||||
bus.emit_signal("time_anchor_resolved", {"beat": 9}, true, {"label": "good"})
|
||||
_expect_int(int(health.get("current")), 460, "second held anchor in the same 4-beat window should not heal")
|
||||
bus.emit_signal("time_anchor_resolved", {"beat": 12}, true, {"label": "bad"})
|
||||
_expect_int(int(health.get("current")), 480, "bad anchor in a new window should heal 20 while below half health")
|
||||
health.call("set_values", 980, 1000)
|
||||
bus.emit_signal("time_anchor_resolved", {"beat": 16}, true, {"label": "perfect"})
|
||||
_expect_int(int(health.get("current")), 1000, "time-anchor healing should not exceed max health")
|
||||
|
||||
# --- chart_reset zeroes the buff. ---
|
||||
bus.emit_signal("chart_reset", &"test")
|
||||
_expect_int(int(buff_component.call("attack_buff_stacks")), 0, "chart_reset should zero the buff")
|
||||
_expect_bool(container.call("active_effect_ids").has(BUFF_ID), false, "chart_reset should remove the buff effect")
|
||||
|
||||
# --- Regression: stack-aware aggregation keeps max_stacks = 1 behaviour. ---
|
||||
var definition: Resource = load("res://resources/effects/effect_definition.gd").new()
|
||||
definition.set("id", &"test_single_stack_add")
|
||||
definition.set("duration_type", &"infinite")
|
||||
definition.set("max_stacks", 1)
|
||||
var modifier: Resource = load("res://resources/effects/stat_modifier.gd").new()
|
||||
modifier.set("stat", &"damage_mult")
|
||||
modifier.set("operation", "add")
|
||||
modifier.set("value", 0.5)
|
||||
var stat_modifiers: Array[Resource] = [modifier]
|
||||
definition.set("stat_modifiers", stat_modifiers)
|
||||
container.call("add_effect", definition)
|
||||
container.call("add_effect", definition)
|
||||
_expect_float(float(container.call("stat_multiplier", &"damage_mult")), 1.5, "max_stacks 1 add modifiers keep the migration baseline")
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [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_bool(actual: bool, expected: bool, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS attack buff")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvlr82gqcqewi
|
||||
@@ -0,0 +1,80 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await _check_blade_rain_lands_on_player_ground("res://resources/actions/jian_yu_left_lv3.tres", Vector2.LEFT, "A/left")
|
||||
await _check_blade_rain_lands_on_player_ground("res://resources/actions/jian_yu_right_lv3.tres", Vector2.RIGHT, "D/right")
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_blade_rain_lands_on_player_ground(action_path: String, heading: Vector2, label: String) -> void:
|
||||
var parent := Node2D.new()
|
||||
root.add_child(parent)
|
||||
var player_scene := load("res://scenes/characters/player.tscn") as PackedScene
|
||||
var action := load(action_path) as Resource
|
||||
_expect(player_scene != null, "%s player scene should load" % label)
|
||||
_expect(action != null, "%s action should load" % label)
|
||||
if player_scene == null or action == null:
|
||||
parent.free()
|
||||
return
|
||||
var player := player_scene.instantiate() as Node2D
|
||||
parent.add_child(player)
|
||||
player.global_position = Vector2(1000.0, 560.0)
|
||||
player.set("heading", heading)
|
||||
await process_frame
|
||||
|
||||
player.call("_spawn_world_fx", action)
|
||||
await process_frame
|
||||
|
||||
var tile_count := 0
|
||||
for child: Node in parent.get_children():
|
||||
if child == player:
|
||||
continue
|
||||
var fx := child as Node2D
|
||||
if fx == null:
|
||||
continue
|
||||
var texture := child.get("texture") as Texture2D
|
||||
if texture == null or texture.resource_path.find("rain_lading_tile") == -1:
|
||||
continue
|
||||
tile_count += 1
|
||||
var vframes := maxi(1, int(child.get("vframes")))
|
||||
var sprite_scale := child.get("sprite_scale") as Vector2
|
||||
var frame_height := float(texture.get_height()) / float(vframes)
|
||||
var visual_bottom_y := fx.global_position.y + frame_height * sprite_scale.y * 0.5
|
||||
# The player art keeps its feet above the frame bottom, so the visible
|
||||
# ground line sits VISIBLE_GROUND_LINE_OFFSET above the actor origin.
|
||||
var ground_line_y: float = player.global_position.y + float(player.get_script().VISIBLE_GROUND_LINE_OFFSET)
|
||||
_expect_float(visual_bottom_y, ground_line_y, "%s blade-rain landing tile bottom should share the player's visible ground line" % label)
|
||||
_expect_int(tile_count, 8, "%s level-3 blade rain should spawn eight landing tiles" % label)
|
||||
parent.free()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if absf(actual - expected) > 0.02:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [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 blade rain alignment")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bguqa0k4shg87
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6hawactspree
|
||||
@@ -0,0 +1,76 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var main_scene := load("res://scenes/main/main.tscn") as PackedScene
|
||||
_expect(main_scene != null, "main.tscn should load")
|
||||
if main_scene == null:
|
||||
_finish()
|
||||
return
|
||||
var main := main_scene.instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var flow := root.get_node_or_null("GameFlowManager")
|
||||
if flow != null:
|
||||
flow.set("state", &"Gameplay_FrontArea")
|
||||
flow.set("_boss_room_entered", false)
|
||||
|
||||
var stage := main.get_node("Stage")
|
||||
var gate: Node = stage.get_node("BossRoomGate")
|
||||
var player := stage.get_node("ActorsContainer/Player") as Node2D
|
||||
var boss := stage.get_node("ActorsContainer/Boss") as Node2D
|
||||
var ui := main.get_node("UILayer/UI")
|
||||
var boss_status := ui.get_node_or_null("BossStatus") as Control
|
||||
var boss_health_bar := ui.get_node_or_null("BossStatus/BossHealthBar") as ProgressBar
|
||||
var wall_shape := gate.get_node("GateWall").get_child(0) as CollisionShape2D
|
||||
|
||||
_expect(boss_status != null, "Main UI should expose BossStatus")
|
||||
if boss_status == null or boss_health_bar == null:
|
||||
main.free()
|
||||
_finish()
|
||||
return
|
||||
|
||||
_expect(not boss_status.visible, "BossStatus should be hidden before entering the boss room")
|
||||
gate.call("unseal")
|
||||
await process_frame
|
||||
player.global_position = Vector2(boss.global_position.x, float(gate.get("floor_y")))
|
||||
gate.call("_physics_process", 0.016)
|
||||
await process_frame
|
||||
|
||||
_expect(bool(gate.get("locked")), "BossRoomGate should lock once the player crosses into the boss room")
|
||||
_expect(wall_shape.disabled, "BossRoomGate should keep the air wall collision disabled after entry")
|
||||
_expect(boss_status.visible, "BossStatus should show immediately after boss-room entry")
|
||||
_expect_float(float(boss_health_bar.max_value), 28000.0, "BossStatus should bind the authored Boss health immediately")
|
||||
_expect_float(float(boss_health_bar.value), 28000.0, "BossStatus should show full Boss health before the first hit")
|
||||
|
||||
main.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void:
|
||||
if absf(actual - expected) > tolerance:
|
||||
failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS boss room entry hud and wall")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://brsjdcla5hntq
|
||||
@@ -0,0 +1,115 @@
|
||||
extends SceneTree
|
||||
|
||||
## Boss-room gate (AnchorV1.0 §4.5 + 关卡设计指南 §8): starts SEALED until the
|
||||
## front area is cleared, opens via unseal(), locks behind the player, then
|
||||
## fires the entry hook, and keeps the air wall fully gone after entry.
|
||||
|
||||
var failures: Array[String] = []
|
||||
var entered_signals := 0
|
||||
var unsealed_signals := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
|
||||
var stage: Node = stage_scene.instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
|
||||
var gate: Node = stage.get_node_or_null("BossRoomGate")
|
||||
_expect_bool(gate != null, true, "stage should carry a BossRoomGate")
|
||||
if gate == null:
|
||||
_finish()
|
||||
return
|
||||
gate.connect("boss_room_entered", func() -> void: entered_signals += 1)
|
||||
gate.connect("unsealed", func() -> void: unsealed_signals += 1)
|
||||
|
||||
var wall_shape := gate.get_node("GateWall").get_child(0) as CollisionShape2D
|
||||
var door_visual := gate.get_node_or_null("DoorVisual") as CanvasItem
|
||||
_expect_bool(bool(gate.get("sealed")), true, "gate starts sealed until the six minions fall (指南 §8)")
|
||||
_expect_bool(wall_shape.disabled, false, "sealed gate blocks the boss room physically")
|
||||
_expect_bool(door_visual != null, true, "gate should keep an inspectable DoorVisual node")
|
||||
if door_visual != null:
|
||||
_expect_bool(door_visual.visible, false, "sealed boss-room air wall should stay invisible")
|
||||
_expect_bool(bool(gate.get("locked")), false, "gate starts unlocked")
|
||||
var flow := root.get_node_or_null("GameFlowManager")
|
||||
if flow != null:
|
||||
flow.set("state", &"Gameplay_FrontArea")
|
||||
flow.set("_boss_room_entered", false)
|
||||
|
||||
var player := stage.get_node("ActorsContainer/Player") as Node2D
|
||||
var boss: Node = stage.get_node("ActorsContainer/Boss")
|
||||
var floor_y := float(gate.get("floor_y"))
|
||||
_expect_bool(
|
||||
(boss as Node2D).global_position.x >= float(gate.get("gate_x")) + float(gate.get("wall_thickness")),
|
||||
true,
|
||||
"Boss authored anchor should sit inside the boss-room trigger line so reaching the Boss starts the fight"
|
||||
)
|
||||
_expect_bool(bool(boss.get("combat_enabled")), false, "boss combat stays locked until the room is entered")
|
||||
|
||||
# While sealed, even a player beyond the threshold must not trigger the lock.
|
||||
player.global_position = Vector2(float(gate.get("gate_x")) + 120.0, floor_y)
|
||||
gate.call("_physics_process", 0.016)
|
||||
_expect_bool(bool(gate.get("locked")), false, "sealed gate never locks")
|
||||
player.global_position = Vector2(float(gate.get("gate_x")) - 120.0, floor_y)
|
||||
|
||||
# Front area cleared → unseal opens the way.
|
||||
gate.call("unseal")
|
||||
await process_frame
|
||||
_expect_bool(bool(gate.get("sealed")), false, "unseal() clears the seal")
|
||||
_expect_bool(wall_shape.disabled, true, "unsealed gate opens the passage")
|
||||
if door_visual != null:
|
||||
_expect_bool(door_visual.visible, false, "unsealed boss-room passage should not draw an air wall")
|
||||
_expect_int(unsealed_signals, 1, "unseal() announces itself once")
|
||||
|
||||
# Player left of the gate: nothing happens.
|
||||
gate.call("_physics_process", 0.016)
|
||||
_expect_bool(bool(gate.get("locked")), false, "gate must not lock while the player is in the front area")
|
||||
|
||||
# The instant the player steps across the gate line the room starts — the
|
||||
# boss must NOT wait until the player is point-blank at its anchor.
|
||||
player.global_position = Vector2(float(gate.get("gate_x")) + 1.0, floor_y)
|
||||
gate.call("_physics_process", 0.016)
|
||||
_expect_bool(bool(gate.get("locked")), true, "crossing the gate line immediately locks the gate")
|
||||
_expect_int(entered_signals, 1, "entering emits boss_room_entered once")
|
||||
await process_frame
|
||||
_expect_bool(wall_shape.disabled, true, "locked gate should keep the air wall collision fully disabled")
|
||||
if door_visual != null:
|
||||
_expect_bool(door_visual.visible, false, "locked boss-room air wall should remain invisible")
|
||||
_expect_bool(bool(boss.get("combat_enabled")), true, "locking the gate should wake the Boss even when the flow manager is not driving the scene")
|
||||
_expect_bool(bool(boss.get("stationary")), false, "locking the gate should release the Boss from training-dummy mode")
|
||||
if flow != null:
|
||||
_expect_string(str(flow.get("state")), "Gameplay_BossRoom", "gate entry should switch GameFlow into boss-room gameplay")
|
||||
gate.call("_physics_process", 0.016)
|
||||
_expect_int(entered_signals, 1, "the gate never re-fires after locking")
|
||||
|
||||
stage.free()
|
||||
_finish()
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS boss room gate")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bv3k1f02hr4j6
|
||||
@@ -0,0 +1,207 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var started: Array[StringName] = []
|
||||
var judged: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var fixture := await _controller_fixture()
|
||||
if fixture.is_empty():
|
||||
_finish()
|
||||
return
|
||||
var controller: Node = fixture["controller"]
|
||||
var combo: Node = fixture["combo"]
|
||||
var state: Node = fixture["state"]
|
||||
controller.connect("action_started", _on_action_started)
|
||||
_event_bus().connect("judgement_made", _on_judgement_made)
|
||||
|
||||
controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 20))
|
||||
_expect(started.has(&"ground_attack_left_1"), "A after_tap should execute the normal A action first")
|
||||
_expect_array(combo.call("get_slots"), [&"A"], "A after_tap should enter ComboWindow")
|
||||
controller.call("_tick_charge_hold", 0.2)
|
||||
controller.call("_reset_to_idle", false)
|
||||
controller.call("_tick_charge_hold", 0.0)
|
||||
_expect_equal(state.call("build_context").get("action_phase"), &"Charging", "A after_tap should enter Charging after hold threshold")
|
||||
controller.call("_tick_charge_hold", 0.5)
|
||||
controller.call("submit_intent", _intent(&"A", &"a", &"released", "perfect", 21))
|
||||
_expect(started.has(&"jian_yu_left_lv2"), "A release should select a left blade rain action by time spent charging")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
judged.clear()
|
||||
controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 30))
|
||||
controller.call("_tick_charge_hold", 0.2)
|
||||
controller.call("_reset_to_idle", false)
|
||||
controller.call("_tick_charge_hold", 0.0)
|
||||
_expect_equal(state.call("build_context").get("action_phase"), &"Charging", "Held A should be charging before SP arrives")
|
||||
controller.call("submit_intent", _intent(&"SP", &"space", &"pressed", "perfect", 31))
|
||||
_expect(started.has(&"dash_slash_left"), "SP during a held-A charge should break the charge into the [A][sp] dash")
|
||||
_expect(not started.has(&"jian_yu_left_lv1"), "SP during a held-A charge must not cast blade rain")
|
||||
controller.call("submit_intent", _intent(&"A", &"a", &"released", "perfect", 32))
|
||||
_expect(not started.has(&"jian_yu_left_lv1"), "Releasing A after the dash derive must not cast blade rain")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
judged.clear()
|
||||
controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 22))
|
||||
_expect_array(combo.call("get_slots"), [&"S"], "S should enter ComboWindow as a normal down action")
|
||||
_expect(started.has(&"block_start"), "S should start block_start")
|
||||
_expect_int(int(judged[judged.size() - 1].get("beat")), 22, "S should emit judgement feedback using its timestamp")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
judged.clear()
|
||||
controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 24))
|
||||
controller.call("submit_intent", _intent(&"S", &"s", &"released", "perfect", 24))
|
||||
_expect(started.has(&"block_start"), "S release should not add a second action")
|
||||
_expect_array(combo.call("get_slots"), [&"S"], "S release should not add another ComboWindow entry")
|
||||
_expect_int(judged.size(), 1, "S release should not be judged as a second press")
|
||||
controller.call("_tick_charge_hold", 1.0)
|
||||
controller.call("_reset_to_idle", false)
|
||||
controller.call("_tick_charge_hold", 1.0)
|
||||
_expect_equal(state.call("build_context").get("action_phase"), &"Neutral", "Released S should cancel charge tracking instead of entering Charging later")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
started.clear()
|
||||
judged.clear()
|
||||
controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 25))
|
||||
_expect(started.has(&"block_start"), "Second timed S should trigger block_start again")
|
||||
_expect_array(combo.call("get_slots"), [&"S", &"S"], "Repeated S should remain visible as two timed block inputs")
|
||||
_expect_int(int(judged[judged.size() - 1].get("beat")), 25, "Repeated S should judge the second press")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
combo.call("record", &"S")
|
||||
combo.call("record", &"SP")
|
||||
started.clear()
|
||||
judged.clear()
|
||||
controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 26))
|
||||
_expect(started.has(&"block_start"), "S should remain a plain block input after earlier S/SP slots")
|
||||
_expect_array(combo.call("get_slots"), [&"S", &"SP", &"S"], "S should append normally without blade-chain clearing")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
judged.clear()
|
||||
controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 27))
|
||||
controller.call("_tick_charge_hold", 1.0)
|
||||
controller.call("_reset_to_idle", false)
|
||||
controller.call("_tick_charge_hold", 0.0)
|
||||
_expect_equal(state.call("build_context").get("action_phase"), &"Charging", "Held S should enter Charging only while the key remains held")
|
||||
controller.call("_tick_charge_hold", 1.0)
|
||||
controller.call("submit_intent", _intent(&"SP", &"space", &"pressed", "perfect", 28))
|
||||
_expect(started.has(&"zhan_bo_lv3"), "Held S then SP should release blade wave by time spent charging")
|
||||
|
||||
fixture["root"].free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _controller_fixture() -> Dictionary:
|
||||
var combo_script: Script = load("res://scenes/components/combo_window.gd")
|
||||
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
|
||||
var state_script: Script = load("res://scenes/components/state_machine.gd")
|
||||
var energy_script: Script = load("res://scenes/components/energy_component.gd")
|
||||
var controller_script: Script = load("res://scenes/components/action_controller.gd")
|
||||
if combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null:
|
||||
failures.append("Phase 3 component scripts should load")
|
||||
return {}
|
||||
var fixture_root := Node.new()
|
||||
root.add_child(fixture_root)
|
||||
var combo: Node = combo_script.new()
|
||||
combo.name = "ComboWindow"
|
||||
fixture_root.add_child(combo)
|
||||
var resolver: Node = resolver_script.new()
|
||||
resolver.name = "ActionResolver"
|
||||
fixture_root.add_child(resolver)
|
||||
var state: Node = state_script.new()
|
||||
state.name = "StateMachine"
|
||||
fixture_root.add_child(state)
|
||||
var energy: Node = energy_script.new()
|
||||
energy.name = "EnergyComponent"
|
||||
fixture_root.add_child(energy)
|
||||
var controller: Node = controller_script.new()
|
||||
controller.name = "ActionController"
|
||||
controller.set("combo_window_path", NodePath("../ComboWindow"))
|
||||
controller.set("action_resolver_path", NodePath("../ActionResolver"))
|
||||
controller.set("state_machine_path", NodePath("../StateMachine"))
|
||||
fixture_root.add_child(controller)
|
||||
await process_frame
|
||||
energy.call("set_values", 99, 99)
|
||||
return {
|
||||
"root": fixture_root,
|
||||
"combo": combo,
|
||||
"controller": controller,
|
||||
"state": state,
|
||||
}
|
||||
|
||||
|
||||
func _intent(symbol: StringName, rhythm_action: StringName, event_type: StringName, label: String, beat: int) -> RefCounted:
|
||||
var intent_script: Script = load("res://scenes/components/input_intent.gd")
|
||||
var intent: RefCounted = intent_script.call("create", symbol, rhythm_action, event_type, float(Time.get_ticks_msec()))
|
||||
intent.set("judgement", {
|
||||
"label": label,
|
||||
"diff": 0.0,
|
||||
"abs_diff": 0.0,
|
||||
"nearest_beat": beat,
|
||||
})
|
||||
return intent
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
started.append(StringName(str(action.get("id"))))
|
||||
|
||||
|
||||
func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void:
|
||||
judged.append({"quality": quality, "offset_ms": offset_ms, "beat": beat_index})
|
||||
|
||||
|
||||
func _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 _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_array(actual: Array, expected: Array, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_equal(actual: Variant, expected: Variant, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, str(expected), str(actual)])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS charge hold gate")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctiymt4bs4qtm
|
||||
@@ -0,0 +1,204 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
_check_resources_load()
|
||||
await _check_runner_upcoming_and_triggered_once()
|
||||
await _check_runner_broadcasts_empty_action_and_unknown_events()
|
||||
_check_stage7_chart_resource_shape()
|
||||
_check_stage9_chart_resource_shape()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_resources_load() -> void:
|
||||
for path: String in [
|
||||
"res://resources/chart_event.gd",
|
||||
"res://resources/chart_track.gd",
|
||||
"res://resources/beat_chart.gd",
|
||||
"res://scenes/chart/chart_runner.gd",
|
||||
]:
|
||||
_expect(load(path) != null, "%s should load" % path)
|
||||
|
||||
|
||||
func _check_runner_upcoming_and_triggered_once() -> void:
|
||||
var runner := _make_runner(_make_chart())
|
||||
var upcoming: Array[StringName] = []
|
||||
var triggered: Array[StringName] = []
|
||||
runner.connect("chart_event_upcoming", func(event: Resource, _time_to_event: float) -> void:
|
||||
upcoming.append(_event_identity(event))
|
||||
)
|
||||
runner.connect("chart_event_triggered", func(event: Resource) -> void:
|
||||
triggered.append(_event_identity(event))
|
||||
)
|
||||
|
||||
runner.call("update_for_song_time", 0.49)
|
||||
_expect_array(upcoming, [&"boss_combo_1"], "Boss action upcoming should expose action_id at lead window")
|
||||
_expect(triggered.is_empty(), "No event should trigger before event time")
|
||||
|
||||
runner.call("update_for_song_time", 1.0)
|
||||
runner.call("update_for_song_time", 1.1)
|
||||
_expect_array(triggered, [&"boss_combo_1"], "Boss action should trigger once")
|
||||
|
||||
runner.call("update_for_song_time", 2.99)
|
||||
runner.call("update_for_song_time", 3.0)
|
||||
runner.call("update_for_song_time", 3.2)
|
||||
_expect(upcoming.count(&"boss_lunging_stab") == 1, "Special boss action upcoming should fire once")
|
||||
_expect(triggered.count(&"boss_lunging_stab") == 1, "Special boss action should trigger once")
|
||||
runner.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _check_runner_broadcasts_empty_action_and_unknown_events() -> void:
|
||||
var bus := _ensure_event_bus()
|
||||
var mirrored: Array[StringName] = []
|
||||
bus.connect("chart_event_triggered", func(event: Resource) -> void:
|
||||
mirrored.append(_event_identity(event))
|
||||
)
|
||||
var chart_script: Script = load("res://resources/beat_chart.gd")
|
||||
var track_script: Script = load("res://resources/chart_track.gd")
|
||||
var chart: Resource = chart_script.new()
|
||||
var track: Resource = track_script.new()
|
||||
chart.set("chart_id", &"wide_channel_chart")
|
||||
track.set("track_id", &"boss_special")
|
||||
track.set("track_type", &"boss")
|
||||
track.set("events", [
|
||||
_make_event(1, &"time_anchor", &"Boss", 0.0, &""),
|
||||
_make_event(2, &"phase_warning", &"", 0.0, &""),
|
||||
])
|
||||
chart.set("tracks", [track])
|
||||
var runner := _make_runner(chart)
|
||||
runner.call("update_for_song_time", 0.5)
|
||||
_expect_array(mirrored, [&"time_anchor"], "ChartRunner should broadcast unknown empty-action events")
|
||||
runner.call("update_for_song_time", 1.0)
|
||||
_expect_array(mirrored, [&"time_anchor", &"phase_warning"], "ChartRunner should keep the future event wide channel")
|
||||
runner.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _check_stage7_chart_resource_shape() -> void:
|
||||
var chart: Resource = load("res://resources/charts/stage7_boss_opening.tres")
|
||||
_expect(chart != null, "Stage-7 boss opening chart should load")
|
||||
if chart == null:
|
||||
return
|
||||
var track_ids: Array[StringName] = []
|
||||
for track: Resource in chart.get("tracks"):
|
||||
track_ids.append(StringName(str(track.get("track_id"))))
|
||||
for event: Resource in track.get("events"):
|
||||
if StringName(str(event.get("event_type"))) == &"enemy_action":
|
||||
_expect_equal(event.get("target_id"), &"Boss", "Boss chart action target_id should match ActorsContainer/Boss")
|
||||
_expect(track_ids.has(&"track_boss_melee"), "Boss chart should split melee events into their own track")
|
||||
_expect(track_ids.has(&"track_boss_ranged"), "Boss chart should split ranged events into their own track")
|
||||
_expect(track_ids.has(&"track_boss_special"), "Boss chart should reserve a special track")
|
||||
|
||||
|
||||
func _check_stage9_chart_resource_shape() -> void:
|
||||
var chart: Resource = load("res://resources/charts/stage9_boss_duel.tres")
|
||||
_expect(chart != null, "Stage-9 boss duel chart should load")
|
||||
if chart == null:
|
||||
return
|
||||
_expect_equal(chart.get("chart_id"), &"stage9_boss_duel", "Stage-9 chart id")
|
||||
_expect(int(chart.get("total_beats")) >= 64, "Stage-9 chart should cover a full 16-bar duel at 4/4")
|
||||
var track_ids: Array[StringName] = []
|
||||
var action_ids: Array[StringName] = []
|
||||
for track: Resource in chart.get("tracks"):
|
||||
track_ids.append(StringName(str(track.get("track_id"))))
|
||||
for event: Resource in track.get("events"):
|
||||
if StringName(str(event.get("event_type"))) != &"enemy_action":
|
||||
continue
|
||||
_expect_equal(event.get("target_id"), &"Boss", "Stage-9 Boss event target_id should match ActorsContainer/Boss")
|
||||
action_ids.append(StringName(str(event.get("action_id"))))
|
||||
_expect(track_ids.has(&"track_boss_melee"), "Stage-9 chart should keep melee track")
|
||||
_expect(track_ids.has(&"track_boss_ranged"), "Stage-9 chart should keep ranged track")
|
||||
_expect(track_ids.has(&"track_boss_special"), "Stage-9 chart should keep special track")
|
||||
for required_action: StringName in [&"boss_combo_1", &"boss_combo_2", &"boss_combo_3", &"boss_lunging_stab", &"boss_shoot_1", &"boss_shoot_2", &"boss_2way_shoot", &"boss_retreat_dash"]:
|
||||
_expect(action_ids.has(required_action), "Stage-9 chart should include %s" % required_action)
|
||||
|
||||
|
||||
func _make_event(beat: int, event_type: StringName, target_id := &"", lead_beats := 1.0, action_id := &"") -> Resource:
|
||||
var event_script: Script = load("res://resources/chart_event.gd")
|
||||
var event: Resource = event_script.new()
|
||||
event.set("beat_index", beat)
|
||||
event.set("event_type", event_type)
|
||||
event.set("target_id", target_id)
|
||||
event.set("lead_beats", lead_beats)
|
||||
event.set("action_id", action_id)
|
||||
return event
|
||||
|
||||
|
||||
func _make_chart() -> Resource:
|
||||
var chart_script: Script = load("res://resources/beat_chart.gd")
|
||||
var track_script: Script = load("res://resources/chart_track.gd")
|
||||
var chart: Resource = chart_script.new()
|
||||
var melee: Resource = track_script.new()
|
||||
var special: Resource = track_script.new()
|
||||
chart.set("chart_id", &"test_boss_chart")
|
||||
melee.set("track_id", &"track_boss_melee")
|
||||
melee.set("track_type", &"boss")
|
||||
melee.set("events", [
|
||||
_make_event(2, &"enemy_action", &"Boss", 1.0, &"boss_combo_1"),
|
||||
])
|
||||
special.set("track_id", &"track_boss_special")
|
||||
special.set("track_type", &"boss")
|
||||
special.set("events", [
|
||||
_make_event(6, &"enemy_action", &"Boss", 2.0, &"boss_lunging_stab"),
|
||||
])
|
||||
chart.set("tracks", [melee, special])
|
||||
return chart
|
||||
|
||||
|
||||
func _make_runner(chart: Resource) -> Node:
|
||||
var runner_script: Script = load("res://scenes/chart/chart_runner.gd")
|
||||
var runner: Node = runner_script.new()
|
||||
runner.set("beat_time_override", 0.5)
|
||||
runner.call("set_chart", chart)
|
||||
root.add_child(runner)
|
||||
return runner
|
||||
|
||||
|
||||
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 _event_identity(event: Resource) -> StringName:
|
||||
var action_id := StringName(str(event.get("action_id")))
|
||||
if not action_id.is_empty():
|
||||
return action_id
|
||||
return StringName(str(event.get("event_type")))
|
||||
|
||||
|
||||
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_array(actual: Array, expected: Array, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS chart layer")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4xmeny2oi6t2
|
||||
@@ -0,0 +1,187 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
_check_schema_defaults_and_track_propagation()
|
||||
await _check_runner_filtering_and_initial_phase()
|
||||
_check_validation_tool()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_schema_defaults_and_track_propagation() -> void:
|
||||
var event: Resource = load("res://resources/chart_event.gd").new()
|
||||
_expect_equal(event.get("time_phase_mask"), &"both", "ChartEvent default mask should be both")
|
||||
_expect_bool(bool(event.call("matches_time_phase", &"past")), true, "mask both should match past")
|
||||
_expect_bool(bool(event.call("matches_time_phase", &"future")), true, "mask both should match future")
|
||||
event.set("time_phase_mask", &"past")
|
||||
_expect_bool(bool(event.call("matches_time_phase", &"future")), false, "mask past should reject future")
|
||||
|
||||
var track: Resource = load("res://resources/chart_track.gd").new()
|
||||
track.set("time_phase_mask", &"future")
|
||||
var default_event: Resource = load("res://resources/chart_event.gd").new()
|
||||
var explicit_event: Resource = load("res://resources/chart_event.gd").new()
|
||||
explicit_event.set("time_phase_mask", &"past")
|
||||
track.set("events", [default_event, explicit_event])
|
||||
track.call("sorted_events")
|
||||
_expect_equal(default_event.get("time_phase_mask"), &"future", "track mask should flow into default events")
|
||||
_expect_equal(explicit_event.get("time_phase_mask"), &"past", "explicitly masked events keep their own mask")
|
||||
|
||||
var chart: Resource = load("res://resources/beat_chart.gd").new()
|
||||
_expect_equal(chart.get("initial_time_phase"), &"past", "BeatChart initial_time_phase should default to past")
|
||||
|
||||
|
||||
func _check_runner_filtering_and_initial_phase() -> void:
|
||||
var bus: Node = load("res://autoload/event_bus.gd").new()
|
||||
bus.name = "EventBus"
|
||||
root.add_child(bus)
|
||||
var manager: Node = load("res://autoload/time_phase_manager.gd").new()
|
||||
manager.name = "TimePhaseManager"
|
||||
root.add_child(manager)
|
||||
var system: Node = load("res://autoload/time_anchor_system.gd").new()
|
||||
system.name = "TimeAnchorSystem"
|
||||
system.set("periodic_scheduling_enabled", false)
|
||||
root.add_child(system)
|
||||
await process_frame
|
||||
|
||||
var past_event: Resource = load("res://resources/chart_event.gd").new()
|
||||
past_event.set("event_id", &"mask_past_event")
|
||||
past_event.set("beat_index", 1)
|
||||
past_event.set("event_type", &"phase_warning")
|
||||
past_event.set("time_phase_mask", &"past")
|
||||
var future_event: Resource = load("res://resources/chart_event.gd").new()
|
||||
future_event.set("event_id", &"mask_future_event")
|
||||
future_event.set("beat_index", 1)
|
||||
future_event.set("event_type", &"phase_warning")
|
||||
future_event.set("time_phase_mask", &"future")
|
||||
var track: Resource = load("res://resources/chart_track.gd").new()
|
||||
track.set("track_id", &"mask_track")
|
||||
track.set("events", [past_event, future_event])
|
||||
var chart: Resource = load("res://resources/beat_chart.gd").new()
|
||||
chart.set("chart_id", &"mask_chart")
|
||||
chart.set("tracks", [track])
|
||||
chart.set("initial_time_phase", &"future")
|
||||
|
||||
var runner: Node = load("res://scenes/chart/chart_runner.gd").new()
|
||||
runner.name = "ChartRunner"
|
||||
runner.set("chart", chart)
|
||||
runner.set("beat_time_override", 0.5)
|
||||
root.add_child(runner)
|
||||
await process_frame
|
||||
|
||||
_expect_equal(manager.get("current_time_phase"), &"future", "runner should apply the chart's initial_time_phase")
|
||||
|
||||
var triggered: Array = []
|
||||
bus.connect("chart_event_triggered", func(event: Resource) -> void:
|
||||
triggered.append(StringName(str(event.get("event_id"))))
|
||||
)
|
||||
|
||||
# Trigger-time final ruling: only the mask that matches the live phase dispatches.
|
||||
runner.call("update_for_song_time", 1.0)
|
||||
_expect_bool(triggered.has(&"mask_future_event"), true, "matching mask should dispatch")
|
||||
_expect_bool(triggered.has(&"mask_past_event"), false, "mismatching mask should be skipped")
|
||||
|
||||
# Skipped events are never re-dispatched after the phase flips back.
|
||||
manager.call("set_time_phase", &"past", &"debug")
|
||||
runner.call("update_for_song_time", 2.0)
|
||||
_expect_bool(triggered.has(&"mask_past_event"), false, "skipped events must not be re-dispatched")
|
||||
|
||||
# force_time_phase chart events route through TimeAnchorSystem to the manager.
|
||||
var reasons: Array = []
|
||||
bus.connect("time_phase_changed", func(_previous: StringName, _current: StringName, reason: StringName) -> void:
|
||||
reasons.append(reason)
|
||||
)
|
||||
var force_event: Resource = load("res://resources/chart_event.gd").new()
|
||||
force_event.set("event_id", &"force_future")
|
||||
force_event.set("event_type", &"force_time_phase")
|
||||
force_event.set("payload", {"target_time_phase": &"future"})
|
||||
bus.emit_signal("chart_event_triggered", force_event)
|
||||
_expect_equal(manager.get("current_time_phase"), &"future", "force_time_phase should switch the phase")
|
||||
_expect_bool(reasons.has(&"chart_forced"), true, "forced switch should carry the chart_forced reason")
|
||||
|
||||
# Same-phase force does not broadcast a fact.
|
||||
var reason_count := reasons.size()
|
||||
bus.emit_signal("chart_event_triggered", force_event)
|
||||
_expect_int(reasons.size(), reason_count, "forcing the current phase must not broadcast")
|
||||
|
||||
# runner.reset discards armed anchors and reapplies the initial phase.
|
||||
system.call("schedule_time_anchor", 30, &"chart")
|
||||
runner.call("reset")
|
||||
_expect_int(int(system.call("anchored_beat_count")), 0, "chart reset should discard armed anchors")
|
||||
_expect_equal(manager.get("current_time_phase"), &"future", "chart reset should restore the chart initial phase")
|
||||
|
||||
|
||||
func _check_validation_tool() -> void:
|
||||
var tool_script: Script = load("res://tools/time_anchor_chart_tool.gd")
|
||||
var chart: Resource = load("res://resources/beat_chart.gd").new()
|
||||
var track: Resource = load("res://resources/chart_track.gd").new()
|
||||
var half_beat_anchor: Resource = load("res://resources/chart_event.gd").new()
|
||||
half_beat_anchor.set("event_id", &"bad_half_beat")
|
||||
half_beat_anchor.set("event_type", &"time_anchor")
|
||||
half_beat_anchor.set("beat_index", 3)
|
||||
half_beat_anchor.set("subdivision", 1)
|
||||
half_beat_anchor.set("subdivisions_per_beat", 2)
|
||||
var duplicate_a: Resource = load("res://resources/chart_event.gd").new()
|
||||
duplicate_a.set("event_id", &"dup_a")
|
||||
duplicate_a.set("event_type", &"time_anchor")
|
||||
duplicate_a.set("beat_index", 8)
|
||||
var duplicate_b: Resource = load("res://resources/chart_event.gd").new()
|
||||
duplicate_b.set("event_id", &"dup_b")
|
||||
duplicate_b.set("event_type", &"time_anchor")
|
||||
duplicate_b.set("beat_index", 8)
|
||||
var bad_mask: Resource = load("res://resources/chart_event.gd").new()
|
||||
bad_mask.set("event_id", &"bad_mask")
|
||||
bad_mask.set("event_type", &"phase_warning")
|
||||
bad_mask.set("beat_index", 10)
|
||||
bad_mask.set("time_phase_mask", &"yesterday")
|
||||
var bad_force: Resource = load("res://resources/chart_event.gd").new()
|
||||
bad_force.set("event_id", &"bad_force")
|
||||
bad_force.set("event_type", &"force_time_phase")
|
||||
bad_force.set("beat_index", 12)
|
||||
track.set("events", [half_beat_anchor, duplicate_a, duplicate_b, bad_mask, bad_force])
|
||||
chart.set("tracks", [track])
|
||||
var errors: Array = tool_script.call("validate_chart", chart)
|
||||
_expect_int(errors.size(), 4, "validator should flag half-beat anchor, duplicate anchors, bad mask and bad force payload")
|
||||
|
||||
var clean_errors: Array = tool_script.call("validate_chart", load("res://resources/charts/stage9_boss_duel.tres"))
|
||||
_expect_int(clean_errors.size(), 0, "shipping chart should validate cleanly")
|
||||
|
||||
var profile_errors: Array = tool_script.call("validate_time_phase_profile", load("res://resources/time_phase/profile_past_strong_enemy.tres"))
|
||||
_expect_int(profile_errors.size(), 0, "shipping time phase profile should validate cleanly")
|
||||
|
||||
var generated: Array = tool_script.call("generate_periodic_time_anchor_events", 16, 64)
|
||||
_expect_int(generated.size(), 3, "generator should emit anchors at 16/32/48 for 64 beats")
|
||||
|
||||
var simulated: Array = tool_script.call("simulate_periodic_schedule", load("res://resources/time_anchor_frequency_default.tres"), 0, 28, [18] as Array[int])
|
||||
_expect_equal(str(simulated), str([4, 8, 12, 16, 22, 26]), "simulation should restart the 4-beat window at the explicit beat")
|
||||
|
||||
|
||||
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 _expect_bool(actual: bool, expected: bool, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS chart time phase mask")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2mktpfn4tj00
|
||||
@@ -0,0 +1,681 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var received_hits: Array[Dictionary] = []
|
||||
var confirmed_hits: Array[Dictionary] = []
|
||||
var hit_flow_events: Array[StringName] = []
|
||||
var target_cancel_reasons: Array[StringName] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
_check_resolver_scripts()
|
||||
_check_player_scene_has_phase5_combat_nodes()
|
||||
_check_judgement_policy_scales_stats()
|
||||
_check_health_component_life_state()
|
||||
await _check_combat_manager_resolve_hit()
|
||||
await _check_combat_knockback_resolution()
|
||||
await _check_combat_stump_acceptance()
|
||||
await _check_knockback_lifts_target()
|
||||
await _check_combat_manager_drives_target_hit_flow()
|
||||
await _check_lethal_hit_cancels_with_death_reason()
|
||||
await _check_effect_defense_modifiers_affect_combat_hit_flow()
|
||||
await _check_dead_targets_are_skipped()
|
||||
await _check_attack_tags_filter_defense_modifiers()
|
||||
await _check_combat_hit_dispatches_effect_events()
|
||||
await _check_combat_hit_event_filters_use_action_context()
|
||||
await _check_combat_hit_dispatches_kill_and_parry_events()
|
||||
await _check_action_on_hit_effects_apply_to_receiver()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_resolver_scripts() -> void:
|
||||
for path: String in [
|
||||
"res://scripts/resolvers/stat_resolver.gd",
|
||||
"res://scripts/resolvers/defense_resolver.gd",
|
||||
"res://scripts/resolvers/action_rule_resolver.gd",
|
||||
"res://scripts/resolvers/combat_resolver.gd",
|
||||
"res://resources/judgement_policy.gd",
|
||||
"res://resources/judgement_policy.tres",
|
||||
]:
|
||||
_expect(load(path) != null, "%s should load" % path)
|
||||
_expect(ProjectSettings.get_setting("autoload/CombatManager", "") == "*res://autoload/combat_manager.gd", "CombatManager should be registered as an autoload")
|
||||
_expect(int(ProjectSettings.call("get_order", "autoload/EventBus")) < int(ProjectSettings.call("get_order", "autoload/RhythmManager")), "EventBus autoload should initialize before RhythmManager")
|
||||
_expect(int(ProjectSettings.call("get_order", "autoload/RhythmManager")) < int(ProjectSettings.call("get_order", "autoload/CombatManager")), "CombatManager autoload should initialize after RhythmManager")
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
_expect(combat.has_method("resolve_hit"), "CombatManager should expose resolve_hit(emitter, receiver)")
|
||||
combat.free()
|
||||
var emitter_source := _read_text("res://scenes/components/damage_emitter.gd")
|
||||
_expect(emitter_source.contains("resolve_hit"), "DamageEmitter should route hits through CombatManager.resolve_hit")
|
||||
_expect(not emitter_source.contains("receiver.emit_signal(\"damage_received\""), "DamageEmitter should not emit receiver damage directly")
|
||||
|
||||
|
||||
func _check_player_scene_has_phase5_combat_nodes() -> void:
|
||||
var scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
_expect(scene != null, "player.tscn should load for phase-5 combat node check")
|
||||
if scene == null:
|
||||
return
|
||||
var player := scene.instantiate()
|
||||
root.add_child(player)
|
||||
var action_executor := player.get_node_or_null("ActionExecutor")
|
||||
var health := player.get_node_or_null("HealthComponent")
|
||||
_expect(player.get_node_or_null("DamageEmitter") is Area2D, "Player should have DamageEmitter in phase 5")
|
||||
_expect(player.get_node_or_null("DamageReceiver") is Area2D, "Player should have DamageReceiver in phase 5")
|
||||
_expect(health != null, "Player should have HealthComponent in phase 5")
|
||||
if health != null:
|
||||
_expect_float(float(health.get("hitstun_seconds")), 0.4, "HealthComponent.hitstun_seconds should match hit stun action 14")
|
||||
_expect(action_executor != null and action_executor.get("damage_emitter_path") == NodePath("../DamageEmitter"), "ActionExecutor should point at Player DamageEmitter")
|
||||
_expect(action_executor != null and action_executor.get("energy_component_path") == NodePath("../EnergyComponent"), "ActionExecutor should point at Player EnergyComponent")
|
||||
player.free()
|
||||
|
||||
|
||||
func _check_judgement_policy_scales_stats() -> void:
|
||||
var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd")
|
||||
if stat_resolver == null:
|
||||
return
|
||||
var action := _make_action(20.0, 10.0, 1.0)
|
||||
_expect_int(int(round(stat_resolver.call("resolve_damage", 100.0, action, {"label": "perfect"}))), 100, "Perfect damage should use policy damage x1.0")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_damage", 100.0, action, {"label": "good"}))), 85, "Good damage should use policy damage x0.85")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_damage", 100.0, action, {"label": "bad"}))), 70, "Bad damage should use policy damage x0.7")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_cost", action, {"label": "perfect"}))), 16, "Perfect cost should use policy cost x0.8")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_cost", action, {"label": "good"}))), 20, "Good cost should use policy cost x1.0")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_cost", action, {"label": "bad"}))), 24, "Bad cost should use policy cost x1.2")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_reward", action, {"label": "perfect"}))), 10, "Perfect reward should use policy reward x1.0")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_reward", action, {"label": "good"}))), 8, "Good reward should use policy reward x0.8")
|
||||
_expect_int(int(round(stat_resolver.call("resolve_reward", action, {"label": "bad"}))), 6, "Bad reward should use policy reward x0.6")
|
||||
|
||||
|
||||
func _check_health_component_life_state() -> void:
|
||||
var host := Node.new()
|
||||
root.add_child(host)
|
||||
var state_machine: Node = load("res://scenes/components/state_machine.gd").new()
|
||||
state_machine.name = "StateMachine"
|
||||
host.add_child(state_machine)
|
||||
var health: Node = load("res://scenes/components/health_component.gd").new()
|
||||
health.name = "HealthComponent"
|
||||
host.add_child(health)
|
||||
await process_frame
|
||||
_expect_float(float(health.get("hitstun_seconds")), 0.4, "HealthComponent default hitstun should be 0.4 seconds")
|
||||
health.call("set_values", 10, 10)
|
||||
health.call("receive_hit", {"damage": 3})
|
||||
_expect_int(int(health.get("current")), 7, "HealthComponent.receive_hit should apply damage")
|
||||
_expect_equal(state_machine.call("build_context").get("life_state"), &"Hitstun", "Non-lethal hit should write LifeState Hitstun")
|
||||
health.call("receive_hit", {"damage": 20})
|
||||
_expect_equal(state_machine.call("build_context").get("life_state"), &"Dead", "Lethal hit should write LifeState Dead")
|
||||
host.free()
|
||||
|
||||
|
||||
func _check_combat_manager_resolve_hit() -> void:
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new()
|
||||
root.add_child(emitter)
|
||||
root.add_child(receiver)
|
||||
emitter.set("damage", 10)
|
||||
receiver.connect("damage_received", _on_damage_received)
|
||||
await process_frame
|
||||
var result: Dictionary = combat.call("resolve_hit", emitter, receiver)
|
||||
_expect_int(int(result.get("damage", 0)), 10, "CombatManager.resolve_hit should return resolved damage")
|
||||
_expect_int(received_hits.size(), 1, "CombatManager.resolve_hit should notify the receiver once")
|
||||
emitter.free()
|
||||
receiver.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_combat_knockback_resolution() -> void:
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new()
|
||||
root.add_child(emitter)
|
||||
root.add_child(receiver)
|
||||
var action := _make_action(0.0, 0.0, 0.0, 0.5, 2.0)
|
||||
emitter.set("damage", 0)
|
||||
emitter.set("base_knockback", Vector2(40, 100))
|
||||
emitter.set("action_context", action)
|
||||
emitter.set("judgement_context", {"label": "good"})
|
||||
await process_frame
|
||||
var result: Dictionary = combat.call("resolve_hit", emitter, receiver)
|
||||
_expect_vector(result.get("knockback", Vector2.ZERO), Vector2(17, 170), "Combat knockback should come from ActionData fields and judgement policy")
|
||||
emitter.free()
|
||||
receiver.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_combat_stump_acceptance() -> void:
|
||||
confirmed_hits.clear()
|
||||
var bus := root.get_node_or_null("EventBus")
|
||||
_expect(bus != null and bus.has_signal("hit_confirmed"), "EventBus should expose hit_confirmed for stump acceptance")
|
||||
if bus != null and bus.has_signal("hit_confirmed") and not bus.is_connected("hit_confirmed", _on_hit_confirmed):
|
||||
bus.connect("hit_confirmed", _on_hit_confirmed)
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var target := _make_target("Phase5CombatStump", false)
|
||||
var health: Node = target.get_node("HealthComponent")
|
||||
var receiver: Area2D = target.get_node("DamageReceiver")
|
||||
var action := _make_action(0.0, 0.0, 1.0)
|
||||
var expected_damage := {
|
||||
"perfect": 100,
|
||||
"good": 85,
|
||||
"bad": 70,
|
||||
}
|
||||
for label: String in expected_damage.keys():
|
||||
health.call("set_values", 999, 999)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
root.add_child(emitter)
|
||||
emitter.set("damage", 100)
|
||||
emitter.set("action_context", action)
|
||||
emitter.set("judgement_context", {"label": label})
|
||||
await process_frame
|
||||
var result: Dictionary = combat.call("resolve_hit", emitter, receiver)
|
||||
var expected := int(expected_damage[label])
|
||||
_expect_int(int(result.get("damage", -1)), expected, "Stump hit_confirmed damage should scale for %s" % label)
|
||||
_expect_int(int(health.get("current")), 999 - expected, "Stump health should reflect %s damage" % label)
|
||||
emitter.free()
|
||||
_expect_int(confirmed_hits.size(), 3, "Combat stump should emit hit_confirmed once for each judgement")
|
||||
if confirmed_hits.size() == 3:
|
||||
_expect_int(int(confirmed_hits[0].get("damage", -1)), 100, "Perfect stump hit_confirmed damage")
|
||||
_expect_int(int(confirmed_hits[1].get("damage", -1)), 85, "Good stump hit_confirmed damage")
|
||||
_expect_int(int(confirmed_hits[2].get("damage", -1)), 70, "Bad stump hit_confirmed damage")
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_knockback_lifts_target() -> void:
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
if scene == null:
|
||||
combat.free()
|
||||
return
|
||||
var target := scene.instantiate()
|
||||
target.name = "KnockupTarget"
|
||||
root.add_child(target)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
root.add_child(emitter)
|
||||
var action := _make_action(0.0, 0.0, 0.0, 0.0, 1.0)
|
||||
emitter.set("damage", 0)
|
||||
emitter.set("base_knockback", Vector2(0, 120))
|
||||
emitter.set("action_context", action)
|
||||
emitter.set("judgement_context", {"label": "perfect"})
|
||||
await process_frame
|
||||
combat.call("resolve_hit", emitter, target.get_node("DamageReceiver"))
|
||||
_expect_equal(target.get_node("StateMachine").call("get_ground_state"), &"Airborne", "Vertical knockback should set target GroundState Airborne")
|
||||
_expect(float(target.get("height_speed")) > 0.0, "Vertical knockback should set positive height speed")
|
||||
emitter.free()
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_combat_manager_drives_target_hit_flow() -> void:
|
||||
received_hits.clear()
|
||||
hit_flow_events.clear()
|
||||
target_cancel_reasons.clear()
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var target := Node.new()
|
||||
target.name = "Target"
|
||||
root.add_child(target)
|
||||
var state_machine: Node = load("res://scenes/components/state_machine.gd").new()
|
||||
state_machine.name = "StateMachine"
|
||||
target.add_child(state_machine)
|
||||
var action_controller: Node = load("res://scenes/components/action_controller.gd").new()
|
||||
action_controller.name = "ActionController"
|
||||
target.add_child(action_controller)
|
||||
action_controller.connect("action_cancelled", _on_target_action_cancelled)
|
||||
var health: Node = load("res://scenes/components/health_component.gd").new()
|
||||
health.name = "HealthComponent"
|
||||
target.add_child(health)
|
||||
health.connect("health_changed", _on_target_health_changed)
|
||||
var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new()
|
||||
receiver.name = "DamageReceiver"
|
||||
target.add_child(receiver)
|
||||
receiver.connect("damage_received", _on_target_damage_received)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
root.add_child(emitter)
|
||||
emitter.set("damage", 3)
|
||||
await process_frame
|
||||
health.call("set_values", 10, 10)
|
||||
hit_flow_events.clear()
|
||||
combat.call("resolve_hit", emitter, receiver)
|
||||
_expect_int(int(health.get("current")), 7, "CombatManager should call target HealthComponent.receive_hit exactly once")
|
||||
_expect_equal(state_machine.call("build_context").get("life_state"), &"Hitstun", "CombatManager hit flow should let HealthComponent write LifeState")
|
||||
_expect(hit_flow_events.has(&"cancel"), "CombatManager should interrupt the target action before applying hit facts")
|
||||
_expect(hit_flow_events.has(&"health"), "CombatManager should apply health facts through HealthComponent")
|
||||
_expect(hit_flow_events.has(&"damage_signal"), "CombatManager should still notify DamageReceiver listeners once")
|
||||
if hit_flow_events.has(&"cancel") and hit_flow_events.has(&"health"):
|
||||
_expect(hit_flow_events.find(&"cancel") < hit_flow_events.find(&"health"), "CombatManager should cancel action before HealthComponent writes hit facts")
|
||||
if hit_flow_events.has(&"cancel") and hit_flow_events.has(&"damage_signal"):
|
||||
_expect(hit_flow_events.find(&"cancel") < hit_flow_events.find(&"damage_signal"), "CombatManager should cancel action before DamageReceiver emits damage_received")
|
||||
emitter.free()
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_lethal_hit_cancels_with_death_reason() -> void:
|
||||
received_hits.clear()
|
||||
hit_flow_events.clear()
|
||||
target_cancel_reasons.clear()
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var target := _make_target("LethalCancelTarget", true)
|
||||
var health: Node = target.get_node("HealthComponent")
|
||||
var receiver: Area2D = target.get_node("DamageReceiver")
|
||||
health.call("set_values", 3, 3)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
root.add_child(emitter)
|
||||
emitter.set("damage", 5)
|
||||
await process_frame
|
||||
combat.call("resolve_hit", emitter, receiver)
|
||||
_expect(target_cancel_reasons.has(&"death"), "Lethal CombatManager hit should cancel current action with death reason")
|
||||
_expect_equal(target.get_node("StateMachine").call("build_context").get("life_state"), &"Dead", "Lethal CombatManager hit should still let HealthComponent write Dead")
|
||||
emitter.free()
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_effect_defense_modifiers_affect_combat_hit_flow() -> void:
|
||||
received_hits.clear()
|
||||
hit_flow_events.clear()
|
||||
target_cancel_reasons.clear()
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var target := Node.new()
|
||||
target.name = "InvincibleTarget"
|
||||
root.add_child(target)
|
||||
var state_machine: Node = load("res://scenes/components/state_machine.gd").new()
|
||||
state_machine.name = "StateMachine"
|
||||
target.add_child(state_machine)
|
||||
var action_controller: Node = load("res://scenes/components/action_controller.gd").new()
|
||||
action_controller.name = "ActionController"
|
||||
target.add_child(action_controller)
|
||||
action_controller.connect("action_cancelled", _on_target_action_cancelled)
|
||||
var health: Node = load("res://scenes/components/health_component.gd").new()
|
||||
health.name = "HealthComponent"
|
||||
target.add_child(health)
|
||||
var effect_container: Node = load("res://scenes/components/effect_container.gd").new()
|
||||
effect_container.name = "EffectContainer"
|
||||
target.add_child(effect_container)
|
||||
var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new()
|
||||
receiver.name = "DamageReceiver"
|
||||
target.add_child(receiver)
|
||||
receiver.connect("damage_received", _on_target_damage_received)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
root.add_child(emitter)
|
||||
emitter.set("damage", 3)
|
||||
await process_frame
|
||||
health.call("set_values", 10, 10)
|
||||
effect_container.call("add_effect", _make_invincible_effect())
|
||||
var result: Dictionary = combat.call("resolve_hit", emitter, receiver)
|
||||
_expect_int(int(result.get("damage", -1)), 0, "Defense Effect should reduce combat damage to zero")
|
||||
_expect_int(int(health.get("current")), 10, "Invincible Defense Effect should prevent HealthComponent damage")
|
||||
_expect(not hit_flow_events.has(&"cancel"), "Invincible Defense Effect should prevent interrupt cancellation")
|
||||
_expect(hit_flow_events.has(&"damage_signal"), "CombatManager should still notify hit facts for an invincible hit")
|
||||
emitter.free()
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_dead_targets_are_skipped() -> void:
|
||||
received_hits.clear()
|
||||
hit_flow_events.clear()
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var target := _make_target("DeadTarget", true)
|
||||
var state_machine: Node = target.get_node("StateMachine")
|
||||
var health: Node = target.get_node("HealthComponent")
|
||||
var receiver: Area2D = target.get_node("DamageReceiver")
|
||||
receiver.connect("damage_received", _on_target_damage_received)
|
||||
state_machine.call("set_life_state", &"Dead")
|
||||
health.call("set_values", 0, 10)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
root.add_child(emitter)
|
||||
emitter.set("damage", 3)
|
||||
await process_frame
|
||||
var result: Dictionary = combat.call("resolve_hit", emitter, receiver)
|
||||
_expect(result.is_empty(), "CombatManager should return an empty result when receiver LifeState is Dead")
|
||||
_expect_int(int(health.get("current")), 0, "Dead target health should not be touched")
|
||||
_expect(not hit_flow_events.has(&"cancel"), "Dead target should not be interrupted")
|
||||
_expect(not hit_flow_events.has(&"damage_signal"), "Dead target should not emit damage_received")
|
||||
emitter.free()
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_attack_tags_filter_defense_modifiers() -> void:
|
||||
received_hits.clear()
|
||||
hit_flow_events.clear()
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var projectile_target := _make_target("ProjectileShieldTarget", true)
|
||||
var projectile_health: Node = projectile_target.get_node("HealthComponent")
|
||||
var projectile_effects: Node = projectile_target.get_node("EffectContainer")
|
||||
projectile_health.call("set_values", 10, 10)
|
||||
projectile_effects.call("add_effect", _make_tagged_invincible_effect(&"projectile"))
|
||||
var projectile_emitter: Area2D = _make_tagged_emitter(&"projectile")
|
||||
root.add_child(projectile_emitter)
|
||||
await process_frame
|
||||
var projectile_result: Dictionary = combat.call("resolve_hit", projectile_emitter, projectile_target.get_node("DamageReceiver"))
|
||||
_expect_int(int(projectile_result.get("damage", -1)), 0, "Tagged DefenseModifier should apply to matching attack tags")
|
||||
_expect_int(int(projectile_health.get("current")), 10, "Matching tagged defense should prevent damage")
|
||||
|
||||
var melee_target := _make_target("MeleeShieldTarget", true)
|
||||
var melee_health: Node = melee_target.get_node("HealthComponent")
|
||||
var melee_effects: Node = melee_target.get_node("EffectContainer")
|
||||
melee_health.call("set_values", 10, 10)
|
||||
melee_effects.call("add_effect", _make_tagged_invincible_effect(&"projectile"))
|
||||
var melee_emitter: Area2D = _make_tagged_emitter(&"melee")
|
||||
root.add_child(melee_emitter)
|
||||
await process_frame
|
||||
var melee_result: Dictionary = combat.call("resolve_hit", melee_emitter, melee_target.get_node("DamageReceiver"))
|
||||
_expect_int(int(melee_result.get("damage", -1)), 3, "Tagged DefenseModifier should not apply to non-matching attack tags")
|
||||
_expect_int(int(melee_health.get("current")), 7, "Non-matching tagged defense should allow damage")
|
||||
projectile_emitter.free()
|
||||
melee_emitter.free()
|
||||
projectile_target.free()
|
||||
melee_target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_combat_hit_dispatches_effect_events() -> void:
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var attacker := _make_actor("HitEventAttacker", true)
|
||||
var target := _make_target("HurtEventTarget", true)
|
||||
var attacker_effects: Node = attacker.get_node("EffectContainer")
|
||||
var target_effects: Node = target.get_node("EffectContainer")
|
||||
attacker_effects.call("add_effect", _make_event_listener_effect(&"on_hit", &"test_on_hit_reward"))
|
||||
target_effects.call("add_effect", _make_event_listener_effect(&"on_hurt", &"test_on_hurt_reward"))
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
emitter.name = "DamageEmitter"
|
||||
attacker.add_child(emitter)
|
||||
emitter.set("damage", 3)
|
||||
await process_frame
|
||||
combat.call("resolve_hit", emitter, target.get_node("DamageReceiver"))
|
||||
_expect_int(int(attacker_effects.call("active_count")), 2, "CombatManager should dispatch on_hit to attacker EffectContainer")
|
||||
_expect_int(int(target_effects.call("active_count")), 2, "CombatManager should dispatch on_hurt to receiver EffectContainer")
|
||||
attacker.free()
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_combat_hit_event_filters_use_action_context() -> void:
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var attacker := _make_actor("FilteredHitEventAttacker", true)
|
||||
var projectile_target := _make_target("FilteredProjectileTarget", true)
|
||||
var melee_target := _make_target("FilteredMeleeTarget", true)
|
||||
var attacker_effects: Node = attacker.get_node("EffectContainer")
|
||||
attacker_effects.call("add_effect", _make_tag_filtered_event_listener(&"on_hit", &"test_filtered_melee_hit_reward", &"melee"))
|
||||
var projectile_emitter: Area2D = _make_tagged_emitter(&"projectile")
|
||||
projectile_emitter.name = "ProjectileEmitter"
|
||||
attacker.add_child(projectile_emitter)
|
||||
await process_frame
|
||||
combat.call("resolve_hit", projectile_emitter, projectile_target.get_node("DamageReceiver"))
|
||||
_expect_int(int(attacker_effects.call("active_count")), 1, "Filtered on_hit should ignore non-matching action tags from CombatManager")
|
||||
var melee_emitter: Area2D = _make_tagged_emitter(&"melee")
|
||||
melee_emitter.name = "MeleeEmitter"
|
||||
attacker.add_child(melee_emitter)
|
||||
await process_frame
|
||||
combat.call("resolve_hit", melee_emitter, melee_target.get_node("DamageReceiver"))
|
||||
_expect_int(int(attacker_effects.call("active_count")), 2, "Filtered on_hit should receive matching action tags from CombatManager")
|
||||
attacker.free()
|
||||
projectile_target.free()
|
||||
melee_target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_combat_hit_dispatches_kill_and_parry_events() -> void:
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
|
||||
var attacker := _make_actor("KillEventAttacker", true)
|
||||
var lethal_target := _make_target("KillEventTarget", true)
|
||||
var attacker_effects: Node = attacker.get_node("EffectContainer")
|
||||
var target_health: Node = lethal_target.get_node("HealthComponent")
|
||||
attacker_effects.call("add_effect", _make_event_listener_effect(&"on_kill", &"test_on_kill_reward"))
|
||||
target_health.call("set_values", 3, 3)
|
||||
var lethal_emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
lethal_emitter.name = "DamageEmitter"
|
||||
attacker.add_child(lethal_emitter)
|
||||
lethal_emitter.set("damage", 5)
|
||||
await process_frame
|
||||
combat.call("resolve_hit", lethal_emitter, lethal_target.get_node("DamageReceiver"))
|
||||
_expect_int(int(attacker_effects.call("active_count")), 2, "CombatManager should dispatch on_kill to attacker EffectContainer after lethal damage")
|
||||
|
||||
var parry_attacker := _make_actor("ParryEventAttacker", true)
|
||||
var parry_target := _make_target("ParryEventTarget", true)
|
||||
var parry_target_effects: Node = parry_target.get_node("EffectContainer")
|
||||
var parry_state_machine: Node = parry_target.get_node("StateMachine")
|
||||
parry_target_effects.call("add_effect", _make_event_listener_effect(&"on_parry_success", &"test_on_parry_reward"))
|
||||
parry_state_machine.call("set_defense_state", &"Parrying")
|
||||
var parry_emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
parry_emitter.name = "DamageEmitter"
|
||||
parry_attacker.add_child(parry_emitter)
|
||||
parry_emitter.set("damage", 5)
|
||||
await process_frame
|
||||
combat.call("resolve_hit", parry_emitter, parry_target.get_node("DamageReceiver"))
|
||||
_expect_int(int(parry_target_effects.call("active_count")), 2, "CombatManager should dispatch on_parry_success to parrying receiver EffectContainer")
|
||||
|
||||
attacker.free()
|
||||
lethal_target.free()
|
||||
parry_attacker.free()
|
||||
parry_target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _check_action_on_hit_effects_apply_to_receiver() -> void:
|
||||
var combat: Node = load("res://autoload/combat_manager.gd").new()
|
||||
combat.name = "CombatManager"
|
||||
root.add_child(combat)
|
||||
var attacker := _make_actor("OnHitEffectAttacker", true)
|
||||
var target := _make_target("OnHitEffectTarget", true)
|
||||
var target_effects: Node = target.get_node("EffectContainer")
|
||||
var action: Resource = load("res://resources/action_data.gd").new()
|
||||
action.set("id", &"test_rooting_action")
|
||||
var effect_definition: Resource = load("res://resources/effects/effect_definition.gd").new()
|
||||
effect_definition.set("id", &"test_on_hit_effect")
|
||||
effect_definition.set("duration_type", &"infinite")
|
||||
var effects: Array[Resource] = [effect_definition]
|
||||
action.set("on_hit_effects", effects)
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
emitter.name = "DamageEmitter"
|
||||
attacker.add_child(emitter)
|
||||
emitter.set("damage", 1)
|
||||
emitter.set("action_context", action)
|
||||
await process_frame
|
||||
combat.call("resolve_hit", emitter, target.get_node("DamageReceiver"))
|
||||
_expect(target_effects.call("active_effect_ids").has(&"test_on_hit_effect"), "CombatManager should apply ActionData.on_hit_effects to the receiver")
|
||||
attacker.free()
|
||||
target.free()
|
||||
combat.free()
|
||||
|
||||
|
||||
func _on_damage_received(amount: int, hit_type: StringName, from: Vector2) -> void:
|
||||
received_hits.append({
|
||||
"amount": amount,
|
||||
"hit_type": hit_type,
|
||||
"from": from,
|
||||
})
|
||||
|
||||
|
||||
func _on_hit_confirmed(result: Dictionary) -> void:
|
||||
confirmed_hits.append(result)
|
||||
|
||||
|
||||
func _on_target_action_cancelled(_action: Resource, reason: StringName) -> void:
|
||||
target_cancel_reasons.append(reason)
|
||||
if reason == &"interrupt" or reason == &"death":
|
||||
hit_flow_events.append(&"cancel")
|
||||
|
||||
|
||||
func _on_target_health_changed(_current: int, _maximum: int) -> void:
|
||||
hit_flow_events.append(&"health")
|
||||
|
||||
|
||||
func _on_target_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||
hit_flow_events.append(&"damage_signal")
|
||||
|
||||
|
||||
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 _make_invincible_effect() -> Resource:
|
||||
var definition: Resource = load("res://resources/effects/effect_definition.gd").new()
|
||||
definition.set("id", &"test_invincible")
|
||||
definition.set("duration_type", &"infinite")
|
||||
var modifier: Resource = load("res://resources/effects/defense_modifier.gd").new()
|
||||
modifier.set("defense_state", &"Invincible")
|
||||
modifier.set("damage_mult", 0.0)
|
||||
modifier.set("interrupts", false)
|
||||
var modifiers: Array[Resource] = [modifier]
|
||||
definition.set("defense_modifiers", modifiers)
|
||||
return definition
|
||||
|
||||
|
||||
func _make_tagged_invincible_effect(required_attack_tag: StringName) -> Resource:
|
||||
var definition: Resource = load("res://resources/effects/effect_definition.gd").new()
|
||||
definition.set("id", &"test_tagged_invincible")
|
||||
definition.set("duration_type", &"infinite")
|
||||
var modifier: Resource = load("res://resources/effects/defense_modifier.gd").new()
|
||||
modifier.set("defense_state", &"Invincible")
|
||||
modifier.set("damage_mult", 0.0)
|
||||
modifier.set("interrupts", false)
|
||||
var required_tags: Array[StringName] = [required_attack_tag]
|
||||
modifier.set("required_attack_tags", required_tags)
|
||||
var modifiers: Array[Resource] = [modifier]
|
||||
definition.set("defense_modifiers", modifiers)
|
||||
return definition
|
||||
|
||||
|
||||
func _make_tagged_emitter(action_tag: StringName) -> Area2D:
|
||||
var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new()
|
||||
emitter.set("damage", 3)
|
||||
var action: Resource = load("res://resources/action_data.gd").new()
|
||||
var action_tags: Array[StringName] = [action_tag]
|
||||
action.set("action_tags", action_tags)
|
||||
emitter.set("action_context", action)
|
||||
return emitter
|
||||
|
||||
|
||||
func _make_action(base_cost: float, base_reward: float, damage_mult: float, knockback_mult_x := 0.0, knockback_mult_y := 0.0) -> Resource:
|
||||
var action: Resource = load("res://resources/action_data.gd").new()
|
||||
action.set("base_cost", base_cost)
|
||||
action.set("base_reward", base_reward)
|
||||
action.set("damage_mult", damage_mult)
|
||||
action.set("knockback_mult_x", knockback_mult_x)
|
||||
action.set("knockback_mult_y", knockback_mult_y)
|
||||
return action
|
||||
|
||||
|
||||
func _make_event_listener_effect(event_name: StringName, reward_id: StringName) -> Resource:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var reward: Resource = definition_script.new()
|
||||
reward.set("id", reward_id)
|
||||
reward.set("duration_type", &"infinite")
|
||||
var listener: Resource = definition_script.new()
|
||||
listener.set("id", StringName("listener_%s" % event_name))
|
||||
listener.set("duration_type", &"infinite")
|
||||
listener.set("trigger_event", event_name)
|
||||
var triggered_effects: Array[Resource] = [reward]
|
||||
listener.set("trigger_effects", triggered_effects)
|
||||
return listener
|
||||
|
||||
|
||||
func _make_tag_filtered_event_listener(event_name: StringName, reward_id: StringName, required_action_tag: StringName) -> Resource:
|
||||
var listener: Resource = _make_event_listener_effect(event_name, reward_id)
|
||||
var required_tags: Array[StringName] = [required_action_tag]
|
||||
listener.set("required_event_action_tags", required_tags)
|
||||
return listener
|
||||
|
||||
|
||||
func _make_actor(actor_name: String, include_effect_container: bool) -> Node:
|
||||
var actor := Node.new()
|
||||
actor.name = actor_name
|
||||
root.add_child(actor)
|
||||
if include_effect_container:
|
||||
var effect_container: Node = load("res://scenes/components/effect_container.gd").new()
|
||||
effect_container.name = "EffectContainer"
|
||||
actor.add_child(effect_container)
|
||||
return actor
|
||||
|
||||
|
||||
func _make_target(target_name: String, include_effect_container: bool) -> Node:
|
||||
var target := Node.new()
|
||||
target.name = target_name
|
||||
root.add_child(target)
|
||||
var state_machine: Node = load("res://scenes/components/state_machine.gd").new()
|
||||
state_machine.name = "StateMachine"
|
||||
target.add_child(state_machine)
|
||||
var action_controller: Node = load("res://scenes/components/action_controller.gd").new()
|
||||
action_controller.name = "ActionController"
|
||||
target.add_child(action_controller)
|
||||
action_controller.connect("action_cancelled", _on_target_action_cancelled)
|
||||
var health: Node = load("res://scenes/components/health_component.gd").new()
|
||||
health.name = "HealthComponent"
|
||||
target.add_child(health)
|
||||
if include_effect_container:
|
||||
var effect_container: Node = load("res://scenes/components/effect_container.gd").new()
|
||||
effect_container.name = "EffectContainer"
|
||||
target.add_child(effect_container)
|
||||
var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new()
|
||||
receiver.name = "DamageReceiver"
|
||||
target.add_child(receiver)
|
||||
return target
|
||||
|
||||
|
||||
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 _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: Variant, expected: Vector2, label: String) -> void:
|
||||
if not (actual is Vector2) or not (actual as Vector2).is_equal_approx(expected):
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS combat manager resolvers")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3hc7avarimds
|
||||
@@ -0,0 +1,114 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_check_energy_bar_scene_has_editor_segments()
|
||||
|
||||
var scene: PackedScene = load("res://scenes/ui/main_ui.tscn")
|
||||
if scene == null:
|
||||
push_error("Could not load main_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui := scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
_expect_node(ui, "RhythmTrack", "UI should include RhythmTrack")
|
||||
_expect_node(ui, "ComboWindow", "UI should include ComboWindow")
|
||||
_expect_node(ui, "StatusBars/HealthBar", "UI should include HealthBar")
|
||||
_expect_node(ui, "StatusBars/EnergyBar", "UI should include EnergyBar")
|
||||
_expect_node(ui, "StatusBars/ChargeBar", "UI should include ChargeBar")
|
||||
|
||||
var combo_window := ui.get_node("ComboWindow")
|
||||
_expect_bool(combo_window.get_child_count() >= 4, true, "ComboWindowHud should build four visual slots")
|
||||
|
||||
var bus := _event_bus()
|
||||
bus.emit_signal("player_health_changed", 42, 100)
|
||||
bus.emit_signal("player_energy_changed", 3.0, 10.0)
|
||||
bus.emit_signal("player_charge_changed", 0.8, 1.1, false, true)
|
||||
bus.emit_signal("combo_updated", [&"A", &"D", &"W", &"S"])
|
||||
await process_frame
|
||||
|
||||
var health_bar := ui.get_node("StatusBars/HealthBar") as ProgressBar
|
||||
var charge_bar := ui.get_node("StatusBars/ChargeBar") as ProgressBar
|
||||
_expect_float(float(health_bar.value), 42.0, "HealthBar should follow EventBus health")
|
||||
_expect_float(float(charge_bar.value), 0.8, "ChargeBar should follow EventBus charge")
|
||||
|
||||
var labels := _combo_slot_texts(combo_window)
|
||||
_expect_array(labels, ["A", "D", "W", "S"], "ComboWindowHud should render the four directional/action symbols")
|
||||
bus.emit_signal("combo_updated", [&"SP", &"Ø"])
|
||||
await process_frame
|
||||
labels = _combo_slot_texts(combo_window)
|
||||
_expect_array(labels, ["sp", "∅", "∅", "∅"], "ComboWindowHud should normalize space and miss placeholders without judgement grades")
|
||||
|
||||
ui.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_energy_bar_scene_has_editor_segments() -> void:
|
||||
var scene: PackedScene = load("res://scenes/ui/energy_bar.tscn")
|
||||
if scene == null:
|
||||
failures.append("Could not load energy_bar.tscn")
|
||||
return
|
||||
var energy_bar := scene.instantiate()
|
||||
_expect_bool(energy_bar is ProgressBar, true, "EnergyBar should match the reference ProgressBar implementation")
|
||||
_expect_bool(energy_bar.get_child_count() == 0, true, "Reference EnergyBar should not build editor-visible segment children")
|
||||
energy_bar.free()
|
||||
|
||||
|
||||
func _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 _expect_node(node: Node, path: String, label: String) -> void:
|
||||
if node.get_node_or_null(path) == null:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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 _combo_slot_texts(combo_window: Node) -> Array[String]:
|
||||
var texts: Array[String] = []
|
||||
for child: Node in combo_window.get_children():
|
||||
var panel := child as PanelContainer
|
||||
if panel == null or panel.get_child_count() == 0:
|
||||
continue
|
||||
var label := panel.get_child(0) as Label
|
||||
if label != null:
|
||||
texts.append(label.text)
|
||||
return texts
|
||||
|
||||
|
||||
func _expect_array(actual: Array, expected: Array, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS combo hud")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bplnlyowxbej0
|
||||
@@ -0,0 +1,75 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var combo_script: Script = load("res://scenes/components/combo_window.gd")
|
||||
_expect(combo_script != null, "ComboWindow script should load")
|
||||
if combo_script == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var combo: Node = combo_script.new()
|
||||
root.add_child(combo)
|
||||
await process_frame
|
||||
combo.set("size", 4)
|
||||
|
||||
combo.call("record", &"A")
|
||||
combo.call("record", &"Ø")
|
||||
combo.call("record", &"SP")
|
||||
_expect_array(combo.call("get_slots"), [&"A", &"Ø", &"SP"], "ComboWindow should preserve visible miss slots")
|
||||
_expect_string(str(combo.call("get_pattern")), "ASP", "get_pattern should ignore miss placeholders")
|
||||
_expect_string(str(combo.call("get_contiguous_pattern")), "SP", "contiguous pattern should not cross miss placeholders")
|
||||
_expect_bool(bool(combo.call("has_pending_clear")), false, "three slots should not request full clear")
|
||||
combo.call("clear", &"reset")
|
||||
|
||||
for symbol: StringName in [&"A", &"D", &"W", &"S"]:
|
||||
combo.call("record", symbol)
|
||||
_expect_array(combo.call("get_slots"), [&"A", &"D", &"W", &"S"], "fourth input should remain visible before clear")
|
||||
_expect_bool(bool(combo.call("has_pending_clear")), true, "fourth input should queue full clear")
|
||||
_expect_string(str(combo.call("consume_pending_clear_reason")), "full", "fourth input clear reason")
|
||||
combo.call("queue_clear", &"full", 0.001)
|
||||
combo.call("flush_pending_clear")
|
||||
_expect_array(combo.call("get_slots"), [], "flush_pending_clear should clear full window")
|
||||
|
||||
combo.call("record", &"A")
|
||||
combo.call("record", &"A")
|
||||
combo.call("clear", &"skill:ground_attack_left_2")
|
||||
_expect_array(combo.call("get_slots"), [], "successful skill clear should empty slots")
|
||||
combo.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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_array(actual: Array, expected: Array, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS combo window")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://x8hi3mo3s3ei
|
||||
@@ -0,0 +1,130 @@
|
||||
extends SceneTree
|
||||
|
||||
## 2026-07-06 策划契约:冲刺穿人机制保留,但其余时间玩家不得与敌人重叠。
|
||||
## 旧行为:dash_through 落点嵌在敌人体内时,恢复碰撞的逻辑"干等分离"——
|
||||
## 嵌着就无限期保持幽灵态,玩家可长时间站在小怪/Boss 身体里。
|
||||
## 新行为:嵌入时沿冲刺方向每物理帧温和推出(保留"从远侧穿出"手感),
|
||||
## 推清后恢复身体碰撞。
|
||||
|
||||
const ENEMY_BODY_BIT := 1 << 6
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1152, 648)
|
||||
var settings := root.get_node_or_null("GameSettings")
|
||||
if settings != null:
|
||||
settings.call("set_difficulty", &"easy")
|
||||
await _check_embedded_dash_gets_separated()
|
||||
await _check_clean_dash_restores_immediately()
|
||||
_finish()
|
||||
|
||||
|
||||
func _make_actors() -> Dictionary:
|
||||
var player := (load("res://scenes/characters/player.tscn") as PackedScene).instantiate() as CharacterBody2D
|
||||
root.add_child(player)
|
||||
var minion := (load("res://scenes/enemies/minion.tscn") as PackedScene).instantiate() as CharacterBody2D
|
||||
root.add_child(minion)
|
||||
var behavior := minion.get_node_or_null("MinionBehavior")
|
||||
if behavior != null:
|
||||
behavior.set("enabled", false)
|
||||
minion.set("approach_direction", 0.0)
|
||||
return {"player": player, "minion": minion}
|
||||
|
||||
|
||||
func _dash_action() -> Resource:
|
||||
var script: Script = load("res://resources/action_data.gd")
|
||||
var action: Resource = script.new()
|
||||
var action_tags: Array[StringName] = [&"dash_through"]
|
||||
action.set("id", &"test_dash_embed")
|
||||
action.set("action_tags", action_tags)
|
||||
action.set("move_mult_x", 1.0)
|
||||
action.set("startup_beats", 0.25)
|
||||
action.set("active_beats", 0.25)
|
||||
action.set("recovery_beats", 0.25)
|
||||
return action
|
||||
|
||||
|
||||
func _check_embedded_dash_gets_separated() -> void:
|
||||
var actors := _make_actors()
|
||||
var player := actors["player"] as CharacterBody2D
|
||||
var minion := actors["minion"] as CharacterBody2D
|
||||
await process_frame
|
||||
player.global_position = Vector2(500.0, 300.0)
|
||||
# 冲刺时长 0.75 拍 × 0.5s × 220px/s ≈ 82px:小怪摆在 80px 处,落点必然嵌入。
|
||||
minion.global_position = Vector2(580.0, 300.0)
|
||||
await physics_frame
|
||||
|
||||
var motion: Node = player.get_node("MotionExecutor")
|
||||
motion.call("execute", _dash_action(), Vector2.RIGHT, 0.5, 220.0)
|
||||
_expect((int(player.collision_mask) & ENEMY_BODY_BIT) == 0, "冲刺期间保持幽灵态(机制保留)")
|
||||
|
||||
# 裸场景无 handle_movement 驱动,手动按真实链路推进:tick → move_and_slide。
|
||||
var finished := false
|
||||
for _index: int in range(90):
|
||||
player.velocity = motion.call("tick", 1.0 / 60.0)
|
||||
player.move_and_slide()
|
||||
await physics_frame
|
||||
if not bool(motion.get("active")):
|
||||
finished = true
|
||||
break
|
||||
_expect(finished, "冲刺应在 90 帧内结束")
|
||||
|
||||
# 新契约:嵌入不再无限期保持幽灵态——有限帧内推出并恢复碰撞。
|
||||
var separated := false
|
||||
for _index: int in range(45):
|
||||
await physics_frame
|
||||
if not bool(motion.call("is_ghosting")):
|
||||
separated = true
|
||||
break
|
||||
_expect(separated, "落点嵌入后应在 45 帧内被推出并恢复碰撞(旧行为:无限期重叠)")
|
||||
if separated:
|
||||
_expect((int(player.collision_mask) & ENEMY_BODY_BIT) != 0, "分离后玩家身体碰撞恢复")
|
||||
_expect(player.global_position.x > minion.global_position.x,
|
||||
"应从冲刺方向的远侧穿出(不被弹回入口侧)")
|
||||
var gap := absf(player.global_position.x - minion.global_position.x)
|
||||
_expect(gap >= 18.0, "分离后身体不再重叠(间距 %.1f px ≥ 半宽和)" % gap)
|
||||
player.free()
|
||||
minion.free()
|
||||
|
||||
|
||||
func _check_clean_dash_restores_immediately() -> void:
|
||||
# 落点干净(前方无敌人)时行为与旧版一致:结束即恢复。
|
||||
var player := (load("res://scenes/characters/player.tscn") as PackedScene).instantiate() as CharacterBody2D
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
player.global_position = Vector2(500.0, 300.0)
|
||||
var motion: Node = player.get_node("MotionExecutor")
|
||||
motion.call("execute", _dash_action(), Vector2.RIGHT, 0.5, 220.0)
|
||||
for _index: int in range(90):
|
||||
player.velocity = motion.call("tick", 1.0 / 60.0)
|
||||
player.move_and_slide()
|
||||
await physics_frame
|
||||
if not bool(motion.get("active")):
|
||||
break
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
_expect(not bool(motion.call("is_ghosting")), "干净落点:冲刺结束立即恢复碰撞")
|
||||
_expect((int(player.collision_mask) & ENEMY_BODY_BIT) != 0, "干净落点:敌人身体位复位")
|
||||
player.free()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
paused = false
|
||||
if failures.is_empty():
|
||||
print("PASS dash overlap separation")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://4min0niv6jjo
|
||||
@@ -0,0 +1,146 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await _check_player_death_aligns_and_disappears()
|
||||
await _check_stage_camera_survives_player_disappearance()
|
||||
await _check_minion_death_aligns_and_disappears()
|
||||
await _check_boss_death_aligns_and_disappears()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_player_death_aligns_and_disappears() -> void:
|
||||
var scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
_expect(scene != null, "Player scene should load")
|
||||
if scene == null:
|
||||
return
|
||||
var player := scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
var sprite := player.get_node("Visual/CharacterSprite") as Sprite2D
|
||||
var baseline := _visible_sprite_bottom_global_y(sprite)
|
||||
player.get_node("StateMachine").call("set_life_state", &"Dead")
|
||||
player.call("handle_animations")
|
||||
_expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Player first death frame should keep the same visible ground line")
|
||||
player.call("_tick_manual_animation", 0.55)
|
||||
_expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Player fall-back death frame should keep the same visible ground line")
|
||||
player.call("_tick_manual_animation", 2.0)
|
||||
await process_frame
|
||||
_expect_bool(not is_instance_valid(player) or player.is_queued_for_deletion(), true, "Player corpse should disappear after death animation")
|
||||
|
||||
|
||||
func _check_stage_camera_survives_player_disappearance() -> void:
|
||||
var scene: PackedScene = load("res://scenes/stage/stage.tscn")
|
||||
_expect(scene != null, "Stage scene should load for player death cleanup")
|
||||
if scene == null:
|
||||
return
|
||||
var stage := scene.instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
var player := stage.get_node("ActorsContainer/Player")
|
||||
player.get_node("StateMachine").call("set_life_state", &"Dead")
|
||||
player.call("handle_animations")
|
||||
player.call("_tick_manual_animation", 3.0)
|
||||
await process_frame
|
||||
await process_frame
|
||||
stage.call("_update_camera_follow")
|
||||
stage.call("_update_wipe_center")
|
||||
_expect_bool(not is_instance_valid(player) or player.is_queued_for_deletion(), true, "Stage player corpse should be gone before camera follow updates")
|
||||
stage.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _check_minion_death_aligns_and_disappears() -> void:
|
||||
var scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
_expect(scene != null, "Minion scene should load")
|
||||
if scene == null:
|
||||
return
|
||||
var minion := scene.instantiate()
|
||||
root.add_child(minion)
|
||||
await process_frame
|
||||
var sprite := minion.get_node("Visual/CharacterSprite") as Sprite2D
|
||||
var baseline := _visible_sprite_bottom_global_y(sprite)
|
||||
minion.get_node("StateMachine").call("set_life_state", &"Dead")
|
||||
minion.call("handle_animations")
|
||||
_expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Minion first death frame should keep the same visible ground line")
|
||||
var animation_player := minion.get_node("AnimationPlayer") as AnimationPlayer
|
||||
var animation_name := str(animation_player.current_animation)
|
||||
animation_player.advance(animation_player.get_animation(animation_name).length + 0.2)
|
||||
minion.call("handle_animations")
|
||||
await process_frame
|
||||
_expect_bool(not is_instance_valid(minion) or minion.is_queued_for_deletion(), true, "Minion corpse should disappear after death animation")
|
||||
|
||||
|
||||
func _check_boss_death_aligns_and_disappears() -> 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
|
||||
var sprite := boss.get_node("Visual/CharacterSprite") as Sprite2D
|
||||
var baseline := _visible_sprite_bottom_global_y(sprite)
|
||||
boss.get_node("StateMachine").call("set_life_state", &"Dead")
|
||||
boss.call("handle_animations")
|
||||
_expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Boss first death frame should keep the same visible ground line")
|
||||
var animation_player := boss.get_node("AnimationPlayer") as AnimationPlayer
|
||||
animation_player.advance(animation_player.get_animation("boss_die").length + 0.2)
|
||||
boss.call("handle_animations")
|
||||
await process_frame
|
||||
_expect_bool(not is_instance_valid(boss) or boss.is_queued_for_deletion(), true, "Boss corpse should disappear after death animation")
|
||||
|
||||
|
||||
func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float:
|
||||
if sprite == null or sprite.texture == null:
|
||||
return INF
|
||||
var image := sprite.texture.get_image()
|
||||
if image == null or image.is_empty():
|
||||
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:
|
||||
return INF
|
||||
return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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 absf(actual - expected) > 0.03:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS test_death_presentation")
|
||||
else:
|
||||
printerr("FAIL test_death_presentation")
|
||||
for failure: String in failures:
|
||||
printerr(" - %s" % failure)
|
||||
quit(failures.size())
|
||||
@@ -0,0 +1 @@
|
||||
uid://cs5q71fcr3uha
|
||||
@@ -0,0 +1,184 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var ui_scene: PackedScene = load("res://scenes/ui/main_ui.tscn")
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
if ui_scene == null or player_scene == null:
|
||||
failures.append("Debug UI test scenes should load")
|
||||
_finish()
|
||||
return
|
||||
|
||||
var ui := ui_scene.instantiate()
|
||||
var player := player_scene.instantiate()
|
||||
root.add_child(player)
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
for node_path: String in [
|
||||
"DebugPanel",
|
||||
"DebugPanel/StateAxesLabel",
|
||||
"DebugPanel/ActionLabel",
|
||||
"DebugPanel/AnchorLabel",
|
||||
"DebugPanel/EffectsLabel",
|
||||
"DebugPanel/DefenseLabel",
|
||||
"DebugPanel/PatternsLabel",
|
||||
"DebugPanel/HitLogLabel",
|
||||
"DebugPanel/CalibrationLabel",
|
||||
]:
|
||||
_expect(ui.has_node(node_path), "MainUI should expose %s" % node_path)
|
||||
|
||||
if ui.has_method("bind_debug_actor"):
|
||||
ui.call("bind_debug_actor", player)
|
||||
else:
|
||||
failures.append("MainUI should expose bind_debug_actor(actor)")
|
||||
|
||||
var state_machine: Node = player.get_node("StateMachine")
|
||||
state_machine.call("set_ground_state", &"Airborne")
|
||||
state_machine.call("set_action_phase", &"Startup")
|
||||
state_machine.call("set_life_state", &"Hitstun")
|
||||
state_machine.call("set_defense_state", &"Parrying")
|
||||
|
||||
var action_controller: Node = player.get_node("ActionController")
|
||||
action_controller.set("current_action", load("res://resources/actions/ground_attack_left_1.tres"))
|
||||
action_controller.set("startup_stretch_seconds", 0.125)
|
||||
|
||||
var effect_container: Node = player.get_node("EffectContainer")
|
||||
if _method_arg_count(effect_container, "add_effect") >= 2:
|
||||
effect_container.call("add_effect", load("res://resources/effects/effect_root.tres"), &"debug_test")
|
||||
effect_container.call("add_effect", load("res://resources/effects/effect_temporary_invincible.tres"), &"debug_defense")
|
||||
else:
|
||||
failures.append("EffectContainer.add_effect should accept a source argument for debug UI")
|
||||
effect_container.call("add_effect", load("res://resources/effects/effect_root.tres"))
|
||||
|
||||
if ui.has_method("refresh_debug_panel"):
|
||||
ui.call("refresh_debug_panel")
|
||||
else:
|
||||
failures.append("MainUI should expose refresh_debug_panel()")
|
||||
|
||||
_expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Airborne", "Debug state axes should include GroundState")
|
||||
_expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Startup", "Debug state axes should include ActionPhaseState")
|
||||
_expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Hitstun", "Debug state axes should include LifeState")
|
||||
_expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Parrying", "Debug state axes should include DefenseState")
|
||||
_expect_label_contains(ui, "DebugPanel/ActionLabel", "ground_attack_left_1", "Debug action label should show current action id")
|
||||
_expect_label_contains(ui, "DebugPanel/AnchorLabel", "0.125", "Debug anchor label should show Startup stretch seconds")
|
||||
_expect_label_contains(ui, "DebugPanel/EffectsLabel", "effect_root", "Debug effects label should show active effect ids")
|
||||
_expect_label_contains(ui, "DebugPanel/EffectsLabel", "1.5s", "Debug effects label should show active effect remaining seconds")
|
||||
_expect_label_contains(ui, "DebugPanel/EffectsLabel", "x1", "Debug effects label should show active effect stacks")
|
||||
_expect_label_contains(ui, "DebugPanel/EffectsLabel", "debug_test", "Debug effects label should show active effect source")
|
||||
_expect_label_contains(ui, "DebugPanel/DefenseLabel", "Baseline", "Debug defense label should identify baseline defense")
|
||||
_expect_label_contains(ui, "DebugPanel/DefenseLabel", "Invincible", "Debug defense label should show synthesized defense state")
|
||||
_expect_label_contains(ui, "DebugPanel/DefenseLabel", "damage 0.00", "Debug defense label should show baseline damage multiplier")
|
||||
_expect_label_contains(ui, "DebugPanel/DefenseLabel", "interrupt false", "Debug defense label should show baseline interrupt result")
|
||||
_expect_label_contains(ui, "DebugPanel/PatternsLabel", "A->ground_attack_left_1", "Debug patterns label should show exported legal patterns")
|
||||
_expect_label_contains(ui, "DebugPanel/PatternsLabel", "S->block_start", "Debug patterns label should show standalone S block")
|
||||
_expect_label_not_contains(ui, "DebugPanel/PatternsLabel", "SS->", "Debug patterns label should not show removed S S combo")
|
||||
_expect_label_contains(ui, "DebugPanel/CalibrationLabel", "Diff", "Debug calibration label should show rhythm diff calibration data")
|
||||
|
||||
var bus := root.get_node_or_null("EventBus")
|
||||
if bus != null and bus.has_signal("hit_confirmed"):
|
||||
bus.emit_signal("hit_confirmed", {
|
||||
"damage": 7,
|
||||
"hit_type": &"melee",
|
||||
"action": load("res://resources/actions/ground_attack_left_1.tres"),
|
||||
"defense": {"defense_state": &"Parrying"},
|
||||
"interrupts": false,
|
||||
})
|
||||
else:
|
||||
failures.append("EventBus should expose hit_confirmed(result) for debug hit logs")
|
||||
_expect_label_contains(ui, "DebugPanel/HitLogLabel", "ground_attack_left_1", "Debug hit log should show hit action id")
|
||||
_expect_label_contains(ui, "DebugPanel/HitLogLabel", "dmg 7", "Debug hit log should show resolved damage")
|
||||
_expect_label_contains(ui, "DebugPanel/HitLogLabel", "Parrying", "Debug hit log should show effective defense")
|
||||
_expect_label_contains(ui, "DebugPanel/HitLogLabel", "interrupt false", "Debug hit log should show interrupt result")
|
||||
if bus != null and bus.has_signal("hit_confirmed"):
|
||||
for index: int in range(4):
|
||||
bus.emit_signal("hit_confirmed", {
|
||||
"damage": index + 1,
|
||||
"hit_type": &"melee",
|
||||
"action": load("res://resources/actions/ground_attack_left_1.tres"),
|
||||
"defense": {"defense_state": &"Vulnerable"},
|
||||
"interrupts": true,
|
||||
})
|
||||
if ui.has_method("get_hit_log_entries"):
|
||||
var entries: Array = ui.call("get_hit_log_entries")
|
||||
_expect_int(entries.size(), 5, "Debug UI should retain full hit history beyond the compact label")
|
||||
else:
|
||||
failures.append("MainUI should expose get_hit_log_entries()")
|
||||
if ui.has_method("export_hit_log"):
|
||||
_expect(ui.call("export_hit_log").contains("ground_attack_left_1"), "Debug UI should export hit history text")
|
||||
else:
|
||||
failures.append("MainUI should expose export_hit_log()")
|
||||
|
||||
ui.free()
|
||||
player.free()
|
||||
await _check_main_scene_binds_debug_actor()
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect_label_contains(root_node: Node, path: String, text: String, label: String) -> void:
|
||||
var node := root_node.get_node_or_null(path) as Label
|
||||
if node == null:
|
||||
failures.append("%s: missing %s" % [label, path])
|
||||
return
|
||||
if not node.text.contains(text):
|
||||
failures.append("%s: expected '%s' to contain '%s'" % [label, node.text, text])
|
||||
|
||||
|
||||
func _expect_label_not_contains(root_node: Node, path: String, text: String, label: String) -> void:
|
||||
var node := root_node.get_node_or_null(path) as Label
|
||||
if node == null:
|
||||
failures.append("%s: missing %s" % [label, path])
|
||||
return
|
||||
if node.text.contains(text):
|
||||
failures.append("%s: expected '%s' not to contain '%s'" % [label, node.text, text])
|
||||
|
||||
|
||||
func _method_arg_count(object: Object, method_name: String) -> int:
|
||||
for method: Dictionary in object.get_method_list():
|
||||
if str(method.get("name", "")) != method_name:
|
||||
continue
|
||||
var args = method.get("args", [])
|
||||
return args.size() if args is Array else 0
|
||||
return 0
|
||||
|
||||
|
||||
func _check_main_scene_binds_debug_actor() -> void:
|
||||
var main_scene: PackedScene = load("res://scenes/main/main.tscn")
|
||||
if main_scene == null:
|
||||
failures.append("main.tscn should load")
|
||||
return
|
||||
var main := main_scene.instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
var player: Node = main.call("get_player")
|
||||
var ui: Node = main.get_node("UI")
|
||||
player.get_node("StateMachine").call("set_action_phase", &"Startup")
|
||||
ui.call("refresh_debug_panel")
|
||||
_expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Startup", "Main should bind Player to UI DebugPanel")
|
||||
main.free()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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 debug ui")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbs7hl6v3kwds
|
||||
@@ -0,0 +1,98 @@
|
||||
extends SceneTree
|
||||
|
||||
## 难度小怪血量倍率:简单保持基准 650,普通 ×3 = 1950,困难 ×5 = 3250。
|
||||
## 注意 --script 测试模式下 autoload 同样会加载,GameSettings 始终存在,
|
||||
## 因此这里显式钉住难度(直接写字段、不落盘),逐档验证。
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
_ensure_named_node("EventBus", "res://autoload/event_bus.gd")
|
||||
_ensure_named_node("TimePhaseManager", "res://autoload/time_phase_manager.gd")
|
||||
var settings := _ensure_named_node("GameSettings", "res://autoload/game_settings.gd")
|
||||
|
||||
_expect_float(float(settings.call("minion_health_multiplier", &"easy")), 1.0, "easy multiplier")
|
||||
_expect_float(float(settings.call("minion_health_multiplier", &"normal")), 3.0, "normal multiplier")
|
||||
_expect_float(float(settings.call("minion_health_multiplier", &"hard")), 5.0, "hard multiplier")
|
||||
|
||||
settings.set("difficulty", &"easy")
|
||||
_expect_int(await _spawn_minion_max_health(), 650, "easy minion HP keeps the 650 baseline")
|
||||
settings.set("difficulty", &"normal")
|
||||
_expect_int(await _spawn_minion_max_health(), 1950, "normal minion HP is 3x the baseline")
|
||||
settings.set("difficulty", &"hard")
|
||||
_expect_int(await _spawn_minion_max_health(), 3250, "hard minion HP is 5x the baseline")
|
||||
|
||||
# Boss 不吃小怪倍率:难度仍为 hard 时 Boss 血量保持自身导出值。
|
||||
settings.set("difficulty", &"hard")
|
||||
var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn")
|
||||
if boss_scene != null:
|
||||
var boss := boss_scene.instantiate()
|
||||
root.add_child(boss)
|
||||
await process_frame
|
||||
var boss_health := boss.get_node_or_null("HealthComponent")
|
||||
if boss_health != null:
|
||||
_expect_int(int(boss_health.get("maximum")), int(boss.get("max_health")), "boss HP must not scale with the minion multiplier")
|
||||
boss.queue_free()
|
||||
await process_frame
|
||||
|
||||
settings.set("difficulty", &"normal")
|
||||
_finish()
|
||||
|
||||
|
||||
func _spawn_minion_max_health() -> int:
|
||||
var scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
if scene == null:
|
||||
failures.append("minion scene failed to load")
|
||||
return -1
|
||||
var minion: Node2D = scene.instantiate()
|
||||
root.add_child(minion)
|
||||
await process_frame
|
||||
var health := minion.get_node_or_null("HealthComponent")
|
||||
if health == null:
|
||||
failures.append("minion has no HealthComponent")
|
||||
minion.queue_free()
|
||||
await process_frame
|
||||
return -1
|
||||
var maximum := int(health.get("maximum"))
|
||||
var current := int(health.get("current"))
|
||||
if current != maximum:
|
||||
failures.append("minion should spawn at full HP: %d / %d" % [current, maximum])
|
||||
minion.queue_free()
|
||||
await process_frame
|
||||
return maximum
|
||||
|
||||
|
||||
func _ensure_named_node(node_name: String, script_path: String) -> Node:
|
||||
var existing := root.get_node_or_null(node_name)
|
||||
if existing != null:
|
||||
return existing
|
||||
var node: Node = load(script_path).new()
|
||||
node.name = node_name
|
||||
root.add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _expect_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if not is_equal_approx(actual, expected):
|
||||
failures.append("%s: expected %f, got %f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS difficulty minion health")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://co8c4blyov4oc
|
||||
@@ -0,0 +1,91 @@
|
||||
extends SceneTree
|
||||
|
||||
## Difficulty judgement windows + the dynamic bad-window cap
|
||||
## (AnchorV1.0 §9.5 / §21.8).
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var settings: Node = load("res://autoload/game_settings.gd").new()
|
||||
root.add_child(settings)
|
||||
var rhythm: Node = load("res://autoload/rhythm_manager.gd").new()
|
||||
rhythm.set("starts_on_ready", false)
|
||||
root.add_child(rhythm)
|
||||
await process_frame
|
||||
|
||||
# --- §21.8 window tables ---
|
||||
_expect_vector(settings.call("difficulty_windows", &"easy"), Vector3(0.090, 0.160, 0.260), "easy windows")
|
||||
_expect_vector(settings.call("difficulty_windows", &"normal"), Vector3(0.060, 0.115, 0.215), "normal windows")
|
||||
_expect_vector(settings.call("difficulty_windows", &"hard"), Vector3(0.030, 0.070, 0.170), "hard windows")
|
||||
|
||||
# --- applying a difficulty rewrites the rhythm manager windows ---
|
||||
var windows: Vector3 = settings.call("difficulty_windows", &"hard")
|
||||
rhythm.call("apply_judgement_windows", windows.x, windows.y, windows.z)
|
||||
_expect_float(float(rhythm.get("perfect_window")), 0.030, "hard perfect window applied")
|
||||
_expect_float(float(rhythm.get("good_window")), 0.070, "hard good window applied")
|
||||
_expect_float(float(rhythm.get("bad_window")), 0.170, "hard bad window applied")
|
||||
|
||||
# --- default (normal) values live on the manager itself ---
|
||||
var fresh: Node = load("res://autoload/rhythm_manager.gd").new()
|
||||
fresh.set("starts_on_ready", false)
|
||||
root.add_child(fresh)
|
||||
await process_frame
|
||||
_expect_float(float(fresh.get("perfect_window")), 0.060, "default perfect window is Normal")
|
||||
_expect_float(float(fresh.get("good_window")), 0.115, "default good window is Normal")
|
||||
_expect_float(float(fresh.get("bad_window")), 0.215, "default bad window is Normal")
|
||||
|
||||
# --- §9.5 dynamic cap: bad <= beat_interval * 0.48, cascading ---
|
||||
fresh.set("bpm", 240.0) # beat_time 0.25s → cap 0.12
|
||||
var effective: Vector3 = fresh.call("effective_windows")
|
||||
_expect_float(effective.z, 0.12, "bad window capped at 48% of the beat")
|
||||
_expect_float(effective.y, 0.115, "good window stays when under the cap")
|
||||
_expect_float(effective.x, 0.060, "perfect window stays when under the cap")
|
||||
|
||||
fresh.set("bpm", 600.0) # beat_time 0.1s → cap 0.048: everything collapses
|
||||
effective = fresh.call("effective_windows")
|
||||
_expect_float(effective.z, 0.048, "extreme BPM bad cap")
|
||||
_expect_float(effective.y, 0.048, "good window can never exceed the capped bad window")
|
||||
_expect_float(effective.x, 0.048, "perfect window can never exceed the capped good window")
|
||||
|
||||
# --- the cap is what judgement actually uses ---
|
||||
fresh.set("bpm", 240.0)
|
||||
fresh.set("running", true)
|
||||
var rating: Dictionary = fresh.call("get_rating_for_time", 0.25 + 0.13)
|
||||
_expect_string(str(rating.get("label")), "miss", "an offset outside the capped bad window judges miss")
|
||||
rating = fresh.call("get_rating_for_time", 0.25 + 0.118)
|
||||
_expect_string(str(rating.get("label")), "bad", "an offset between good and the capped bad window judges bad")
|
||||
|
||||
settings.free()
|
||||
rhythm.free()
|
||||
fresh.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect_vector(actual: Vector3, expected: Vector3, label: String) -> void:
|
||||
if (actual - expected).length() > 0.0005:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if absf(actual - expected) > 0.0005:
|
||||
failures.append("%s: expected %f, got %f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS difficulty windows")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ms0h61yd1lcw
|
||||
@@ -0,0 +1,458 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
_check_effect_scripts_and_player_component()
|
||||
_check_phase6_effect_resources_have_single_stack_baseline()
|
||||
_check_duration_and_stat_modifier()
|
||||
_check_active_effect_summaries_include_runtime_debug_data()
|
||||
_check_effect_container_exposes_rule_modifiers()
|
||||
_check_dispatch_event_adds_triggered_effects()
|
||||
_check_event_filters_gate_triggered_effects()
|
||||
_check_action_and_hit_duration_expire_on_matching_events()
|
||||
_check_measure_and_until_event_duration()
|
||||
_check_burst_effect_resource_drives_stat_provider()
|
||||
await _check_seconds_effects_tick_from_process()
|
||||
await _check_beat_event_dispatches_effect_event()
|
||||
await _check_perfect_input_dispatches_effect_event()
|
||||
await _check_player_initial_effect_loadout()
|
||||
await process_frame
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_effect_scripts_and_player_component() -> void:
|
||||
for path: String in [
|
||||
"res://resources/effects/effect_definition.gd",
|
||||
"res://resources/effects/effect_instance.gd",
|
||||
"res://resources/effects/stat_modifier.gd",
|
||||
"res://resources/effects/defense_modifier.gd",
|
||||
"res://resources/effects/action_rule_modifier.gd",
|
||||
"res://scenes/components/effect_container.gd",
|
||||
]:
|
||||
_expect(load(path) != null, "%s should load" % path)
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
var player := player_scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
_expect(player.get_node_or_null("EffectContainer") != null, "Player should own EffectContainer")
|
||||
player.queue_free()
|
||||
|
||||
|
||||
func _check_duration_and_stat_modifier() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var modifier_script: Script = load("res://resources/effects/stat_modifier.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd")
|
||||
if definition_script == null or modifier_script == null or container_script == null or stat_resolver == null:
|
||||
return
|
||||
var definition: Resource = definition_script.new()
|
||||
definition.set("id", &"test_double_damage")
|
||||
definition.set("duration_type", &"seconds")
|
||||
definition.set("duration", 0.5)
|
||||
var modifier: Resource = modifier_script.new()
|
||||
modifier.set("stat", &"damage_mult")
|
||||
modifier.set("operation", &"multiply")
|
||||
modifier.set("value", 2.0)
|
||||
var stat_modifiers: Array[Resource] = [modifier]
|
||||
definition.set("stat_modifiers", stat_modifiers)
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
container.call("add_effect", definition)
|
||||
_expect_int(int(container.call("active_count")), 1, "EffectContainer should add an effect instance")
|
||||
var damage := float(stat_resolver.call("resolve_damage", 10.0, null, {"label": "perfect"}, container, null))
|
||||
_expect_float(damage, 20.0, "StatResolver should read damage multiplier from EffectContainer")
|
||||
container.call("tick_time", 0.6)
|
||||
_expect_int(int(container.call("active_count")), 0, "EffectContainer should expire seconds-based effects")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_active_effect_summaries_include_runtime_debug_data() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
if definition_script == null or container_script == null:
|
||||
return
|
||||
var definition: Resource = definition_script.new()
|
||||
definition.set("id", &"test_debug_stack")
|
||||
definition.set("duration_type", &"seconds")
|
||||
definition.set("duration", 2.0)
|
||||
definition.set("max_stacks", 3)
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
if _method_arg_count(container, "add_effect") >= 2:
|
||||
container.call("add_effect", definition, &"test_source")
|
||||
container.call("add_effect", definition, &"test_source")
|
||||
else:
|
||||
failures.append("EffectContainer.add_effect should accept a source argument")
|
||||
container.call("add_effect", definition)
|
||||
container.call("add_effect", definition)
|
||||
_expect(container.has_method("active_effect_summaries"), "EffectContainer should expose active_effect_summaries for debug UI")
|
||||
if container.has_method("active_effect_summaries"):
|
||||
var summaries: Array = container.call("active_effect_summaries")
|
||||
_expect_int(summaries.size(), 1, "Stacked effects should produce one summary")
|
||||
if not summaries.is_empty() and summaries[0] is Dictionary:
|
||||
var summary: Dictionary = summaries[0]
|
||||
_expect_equal(summary.get("id"), &"test_debug_stack", "Effect summary should include id")
|
||||
_expect_equal(summary.get("duration_type"), &"seconds", "Effect summary should include duration_type")
|
||||
_expect_int(int(summary.get("stacks", 0)), 2, "Effect summary should include stack count")
|
||||
_expect_float(float(summary.get("remaining", 0.0)), 2.0, "Effect summary should include remaining duration")
|
||||
_expect_string(str(summary.get("source", "")), "test_source", "Effect summary should include source")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_effect_container_exposes_rule_modifiers() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var defense_modifier_script: Script = load("res://resources/effects/defense_modifier.gd")
|
||||
var action_rule_modifier_script: Script = load("res://resources/effects/action_rule_modifier.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
if definition_script == null or defense_modifier_script == null or action_rule_modifier_script == null or container_script == null:
|
||||
return
|
||||
var definition: Resource = definition_script.new()
|
||||
definition.set("id", &"test_rule_modifiers")
|
||||
definition.set("duration_type", &"infinite")
|
||||
var defense_modifier: Resource = defense_modifier_script.new()
|
||||
defense_modifier.set("defense_state", &"Invincible")
|
||||
var defense_modifiers: Array[Resource] = [defense_modifier]
|
||||
definition.set("defense_modifiers", defense_modifiers)
|
||||
var action_rule_modifier: Resource = action_rule_modifier_script.new()
|
||||
var blocked_tags: Array[StringName] = [&"spell"]
|
||||
action_rule_modifier.set("blocked_action_tags", blocked_tags)
|
||||
var action_rule_modifiers: Array[Resource] = [action_rule_modifier]
|
||||
definition.set("action_rule_modifiers", action_rule_modifiers)
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
container.call("add_effect", definition)
|
||||
_expect(container.has_method("defense_modifiers"), "EffectContainer should expose active defense modifiers")
|
||||
_expect(container.has_method("action_rule_modifiers"), "EffectContainer should expose active action-rule modifiers")
|
||||
if container.has_method("defense_modifiers"):
|
||||
_expect_int(container.call("defense_modifiers").size(), 1, "EffectContainer.defense_modifiers should return active defense modifiers")
|
||||
if container.has_method("action_rule_modifiers"):
|
||||
_expect_int(container.call("action_rule_modifiers").size(), 1, "EffectContainer.action_rule_modifiers should return active action-rule modifiers")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_dispatch_event_adds_triggered_effects() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var modifier_script: Script = load("res://resources/effects/stat_modifier.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd")
|
||||
if definition_script == null or modifier_script == null or container_script == null or stat_resolver == null:
|
||||
return
|
||||
var reward: Resource = definition_script.new()
|
||||
reward.set("id", &"test_perfect_reward_damage")
|
||||
reward.set("duration_type", &"infinite")
|
||||
var modifier: Resource = modifier_script.new()
|
||||
modifier.set("stat", &"damage_mult")
|
||||
modifier.set("operation", &"multiply")
|
||||
modifier.set("value", 1.5)
|
||||
var stat_modifiers: Array[Resource] = [modifier]
|
||||
reward.set("stat_modifiers", stat_modifiers)
|
||||
|
||||
var listener: Resource = definition_script.new()
|
||||
listener.set("id", &"test_on_perfect_listener")
|
||||
listener.set("duration_type", &"infinite")
|
||||
listener.set("trigger_event", &"on_perfect")
|
||||
var triggered_effects: Array[Resource] = [reward]
|
||||
listener.set("trigger_effects", triggered_effects)
|
||||
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
container.call("add_effect", listener)
|
||||
_expect(container.has_method("dispatch_event"), "EffectContainer should expose dispatch_event(event_name, context)")
|
||||
if container.has_method("dispatch_event"):
|
||||
container.call("dispatch_event", &"on_perfect", {})
|
||||
_expect_int(int(container.call("active_count")), 2, "dispatch_event should add triggered effects")
|
||||
var damage := float(stat_resolver.call("resolve_damage", 10.0, null, {"label": "perfect"}, container, null))
|
||||
_expect_float(damage, 15.0, "Triggered reward effect should contribute modifiers")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_phase6_effect_resources_have_single_stack_baseline() -> void:
|
||||
for path: String in _phase6_effect_resource_paths():
|
||||
var definition: Resource = load(path)
|
||||
_expect(definition != null, "%s should load" % path)
|
||||
if definition != null:
|
||||
_expect_int(int(definition.get("max_stacks")), 1, "%s max_stacks should stay at the migration baseline" % path)
|
||||
|
||||
|
||||
func _check_event_filters_gate_triggered_effects() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
if definition_script == null or container_script == null:
|
||||
return
|
||||
var reward: Resource = definition_script.new()
|
||||
reward.set("id", &"test_melee_hit_reward")
|
||||
reward.set("duration_type", &"infinite")
|
||||
var listener: Resource = definition_script.new()
|
||||
listener.set("id", &"test_melee_hit_listener")
|
||||
listener.set("duration_type", &"infinite")
|
||||
listener.set("trigger_event", &"on_hit")
|
||||
var required_tags: Array[StringName] = [&"melee"]
|
||||
listener.set("required_event_action_tags", required_tags)
|
||||
var triggered_effects: Array[Resource] = [reward]
|
||||
listener.set("trigger_effects", triggered_effects)
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
container.call("add_effect", listener)
|
||||
container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"projectile"])})
|
||||
_expect_int(int(container.call("active_count")), 1, "Filtered trigger should ignore non-matching action tags")
|
||||
container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"melee"])})
|
||||
_expect_int(int(container.call("active_count")), 2, "Filtered trigger should add rewards for matching action tags")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_action_and_hit_duration_expire_on_matching_events() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
if definition_script == null or container_script == null:
|
||||
return
|
||||
var action_limited: Resource = definition_script.new()
|
||||
action_limited.set("id", &"test_next_action_only")
|
||||
action_limited.set("duration_type", &"actions")
|
||||
action_limited.set("duration", 1.0)
|
||||
var hit_limited: Resource = definition_script.new()
|
||||
hit_limited.set("id", &"test_next_melee_hit_only")
|
||||
hit_limited.set("duration_type", &"hits")
|
||||
hit_limited.set("duration", 1.0)
|
||||
var required_tags: Array[StringName] = [&"melee"]
|
||||
hit_limited.set("required_event_action_tags", required_tags)
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
container.call("add_effect", action_limited)
|
||||
container.call("dispatch_event", &"on_action_start", {"action": _make_action_with_tags([&"skill"])})
|
||||
_expect_bool(not container.call("active_effect_ids").has(&"test_next_action_only"), true, "Action-duration effects should expire after matching action events")
|
||||
container.call("add_effect", hit_limited)
|
||||
container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"projectile"])})
|
||||
_expect_bool(container.call("active_effect_ids").has(&"test_next_melee_hit_only"), true, "Hit-duration effects should ignore non-matching action tags")
|
||||
container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"melee"])})
|
||||
_expect_bool(not container.call("active_effect_ids").has(&"test_next_melee_hit_only"), true, "Hit-duration effects should expire after matching hit events")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_measure_and_until_event_duration() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
if definition_script == null or container_script == null:
|
||||
return
|
||||
var measure_limited: Resource = definition_script.new()
|
||||
measure_limited.set("id", &"test_two_measure_buff")
|
||||
measure_limited.set("duration_type", &"measures")
|
||||
measure_limited.set("duration", 2.0)
|
||||
var until_landed: Resource = definition_script.new()
|
||||
until_landed.set("id", &"test_until_landed_buff")
|
||||
until_landed.set("duration_type", &"until_event")
|
||||
until_landed.set("until_event", &"on_landed")
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
container.call("add_effect", measure_limited)
|
||||
container.call("dispatch_event", &"on_measure_start", {})
|
||||
_expect_bool(container.call("active_effect_ids").has(&"test_two_measure_buff"), true, "Measure-duration effects should survive until all measures are consumed")
|
||||
container.call("dispatch_event", &"on_measure_start", {})
|
||||
_expect_bool(not container.call("active_effect_ids").has(&"test_two_measure_buff"), true, "Measure-duration effects should expire after N measure-start events")
|
||||
container.call("add_effect", until_landed)
|
||||
container.call("tick_time", 999.0)
|
||||
container.call("dispatch_event", &"on_hurt", {})
|
||||
_expect_bool(container.call("active_effect_ids").has(&"test_until_landed_buff"), true, "UntilEvent effects should ignore time and unrelated events")
|
||||
container.call("dispatch_event", &"on_landed", {})
|
||||
_expect_bool(not container.call("active_effect_ids").has(&"test_until_landed_buff"), true, "UntilEvent effects should expire when their configured event fires")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_burst_effect_resource_drives_stat_provider() -> void:
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd")
|
||||
var action_script: Script = load("res://resources/action_data.gd")
|
||||
var burst_effect: Resource = load("res://resources/effects/effect_burst_power.tres")
|
||||
if container_script == null or stat_resolver == null or action_script == null:
|
||||
return
|
||||
_expect(burst_effect != null, "Burst power should be represented as an EffectDefinition resource")
|
||||
if burst_effect == null:
|
||||
return
|
||||
var action: Resource = action_script.new()
|
||||
action.set("base_cost", 10.0)
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
container.call("add_effect", burst_effect, &"burst")
|
||||
var damage := float(stat_resolver.call("resolve_damage", 10.0, action, {"label": "perfect"}, container, null))
|
||||
var cost := float(stat_resolver.call("resolve_cost", action, container))
|
||||
_expect_float(damage, 12.0, "Burst effect resource should boost damage through EffectContainer stat modifiers")
|
||||
_expect_float(cost, 0.0, "Burst effect resource should make action costs free through EffectContainer stat modifiers")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_seconds_effects_tick_from_process() -> void:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
if definition_script == null or container_script == null:
|
||||
return
|
||||
var definition: Resource = definition_script.new()
|
||||
definition.set("id", &"test_runtime_seconds_expire")
|
||||
definition.set("duration_type", &"seconds")
|
||||
definition.set("duration", 0.02)
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
await process_frame
|
||||
container.call("add_effect", definition)
|
||||
await create_timer(0.08).timeout
|
||||
await process_frame
|
||||
_expect_int(int(container.call("active_count")), 0, "Seconds-based effects should expire automatically while EffectContainer is in the scene tree")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_beat_event_dispatches_effect_event() -> void:
|
||||
var event_bus := _ensure_event_bus()
|
||||
var container_script: Script = load("res://scenes/components/effect_container.gd")
|
||||
if event_bus == null or container_script == null:
|
||||
return
|
||||
var container: Node = container_script.new()
|
||||
root.add_child(container)
|
||||
await process_frame
|
||||
container.call("add_effect", _make_event_listener_effect(&"on_beat", &"test_on_beat_reward"))
|
||||
event_bus.emit_signal("beat_ticked", 8)
|
||||
_expect_int(int(container.call("active_count")), 2, "EffectContainer should dispatch on_beat when EventBus beat_ticked fires")
|
||||
container.free()
|
||||
|
||||
|
||||
func _check_perfect_input_dispatches_effect_event() -> void:
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
if player_scene == null or definition_script == null:
|
||||
return
|
||||
var player := player_scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
var effect_container: Node = player.get_node("EffectContainer")
|
||||
var controller: Node = player.get_node("ActionController")
|
||||
effect_container.call("add_effect", _make_event_listener_effect(&"on_perfect", &"test_perfect_runtime_reward"))
|
||||
controller.call("submit_intent", _perfect_intent())
|
||||
var active_ids: Array = effect_container.call("active_effect_ids")
|
||||
_expect_bool(active_ids.has(&"test_perfect_runtime_reward"), true, "Perfect input should dispatch on_perfect to EffectContainer")
|
||||
_expect_bool(active_ids.has(&"effect_perfect_damage_buff"), true, "Player loadout should turn Perfect input into the configured damage reward")
|
||||
player.free()
|
||||
|
||||
|
||||
func _check_player_initial_effect_loadout() -> void:
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
if player_scene == null:
|
||||
return
|
||||
var player := player_scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
var effect_container: Node = player.get_node("EffectContainer")
|
||||
var ids: Array = effect_container.call("active_effect_ids")
|
||||
_expect_bool(ids.has(&"effect_on_perfect_damage_reward"), true, "Player should start with the Perfect reward listener as a real Effect loadout")
|
||||
_expect_bool(ids.has(&"effect_on_landed_haste"), true, "Player should start with the landed Haste listener as a real Effect loadout")
|
||||
effect_container.call("dispatch_event", &"on_landed", {})
|
||||
_expect_bool(effect_container.call("active_effect_ids").has(&"effect_haste"), true, "Player landed listener should apply Haste through EffectContainer")
|
||||
player.free()
|
||||
|
||||
|
||||
func _make_event_listener_effect(event_name: StringName, reward_id: StringName) -> Resource:
|
||||
var definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var reward: Resource = definition_script.new()
|
||||
reward.set("id", reward_id)
|
||||
reward.set("duration_type", &"infinite")
|
||||
var listener: Resource = definition_script.new()
|
||||
listener.set("id", StringName("listener_%s" % event_name))
|
||||
listener.set("duration_type", &"infinite")
|
||||
listener.set("trigger_event", event_name)
|
||||
var triggered_effects: Array[Resource] = [reward]
|
||||
listener.set("trigger_effects", triggered_effects)
|
||||
return listener
|
||||
|
||||
|
||||
func _phase6_effect_resource_paths() -> Array[String]:
|
||||
return [
|
||||
"res://resources/effects/effect_root.tres",
|
||||
"res://resources/effects/effect_slow.tres",
|
||||
"res://resources/effects/effect_haste.tres",
|
||||
"res://resources/effects/effect_silence.tres",
|
||||
"res://resources/effects/effect_temporary_invincible.tres",
|
||||
"res://resources/effects/effect_temporary_super_armor.tres",
|
||||
"res://resources/effects/effect_burst_power.tres",
|
||||
"res://resources/effects/effect_on_landed_haste.tres",
|
||||
"res://resources/effects/effect_perfect_damage_buff.tres",
|
||||
"res://resources/effects/effect_on_perfect_damage_reward.tres",
|
||||
]
|
||||
|
||||
|
||||
func _ensure_event_bus() -> Node:
|
||||
var existing := root.get_node_or_null("EventBus")
|
||||
if existing != null:
|
||||
return existing
|
||||
var bus_script: Script = load("res://autoload/event_bus.gd")
|
||||
if bus_script == null:
|
||||
failures.append("EventBus script should load")
|
||||
return null
|
||||
var bus: Node = bus_script.new()
|
||||
bus.name = "EventBus"
|
||||
root.add_child(bus)
|
||||
return bus
|
||||
|
||||
|
||||
func _perfect_intent() -> RefCounted:
|
||||
var intent_script: Script = load("res://scenes/components/input_intent.gd")
|
||||
var intent: RefCounted = intent_script.call("create", &"A", &"a", &"pressed", float(Time.get_ticks_msec()))
|
||||
intent.set("judgement", {"label": "perfect", "diff": 0.0, "abs_diff": 0.0})
|
||||
return intent
|
||||
|
||||
|
||||
func _make_action_with_tags(tags: Array[StringName]) -> Resource:
|
||||
var action: Resource = load("res://resources/action_data.gd").new()
|
||||
action.set("action_tags", tags)
|
||||
return action
|
||||
|
||||
|
||||
func _method_arg_count(object: Object, method_name: String) -> int:
|
||||
for method: Dictionary in object.get_method_list():
|
||||
if str(method.get("name", "")) != method_name:
|
||||
continue
|
||||
var args = method.get("args", [])
|
||||
return args.size() if args is Array else 0
|
||||
return 0
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [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_equal(actual: Variant, expected: Variant, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, 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 _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS effect container")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3qmrj5615qkh
|
||||
@@ -0,0 +1,90 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var effect_definition_script: Script = load("res://resources/effects/effect_definition.gd")
|
||||
var effect_instance_script: Script = load("res://resources/effects/effect_instance.gd")
|
||||
var stat_modifier_script: Script = load("res://resources/effects/stat_modifier.gd")
|
||||
var defense_modifier_script: Script = load("res://resources/effects/defense_modifier.gd")
|
||||
var action_rule_modifier_script: Script = load("res://resources/effects/action_rule_modifier.gd")
|
||||
|
||||
_expect(effect_definition_script != null, "EffectDefinition script should load")
|
||||
_expect(effect_instance_script != null, "EffectInstance script should load")
|
||||
_expect(stat_modifier_script != null, "StatModifier script should load")
|
||||
_expect(defense_modifier_script != null, "DefenseModifier script should load")
|
||||
_expect(action_rule_modifier_script != null, "ActionRuleModifier script should load")
|
||||
if effect_definition_script == null or effect_instance_script == null or stat_modifier_script == null or defense_modifier_script == null or action_rule_modifier_script == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var definition: Resource = effect_definition_script.new()
|
||||
definition.set("id", &"test_effect")
|
||||
definition.set("duration_type", &"beats")
|
||||
definition.set("duration", 4.0)
|
||||
_expect_int(int(definition.get("max_stacks")), 1, "EffectDefinition max_stacks default")
|
||||
|
||||
var stat_modifier: Resource = stat_modifier_script.new()
|
||||
stat_modifier.set("stat", &"damage")
|
||||
stat_modifier.set("operation", "multiply")
|
||||
stat_modifier.set("value", 1.2)
|
||||
var stat_modifiers: Array[Resource] = [stat_modifier]
|
||||
definition.set("stat_modifiers", stat_modifiers)
|
||||
|
||||
var defense_modifier: Resource = defense_modifier_script.new()
|
||||
defense_modifier.set("defense_state", &"SuperArmor")
|
||||
var defense_modifiers: Array[Resource] = [defense_modifier]
|
||||
definition.set("defense_modifiers", defense_modifiers)
|
||||
|
||||
var action_rule_modifier: Resource = action_rule_modifier_script.new()
|
||||
action_rule_modifier.set("block_movement", true)
|
||||
var action_rule_modifiers: Array[Resource] = [action_rule_modifier]
|
||||
definition.set("action_rule_modifiers", action_rule_modifiers)
|
||||
|
||||
var instance: RefCounted = effect_instance_script.call("create", definition, &"test")
|
||||
_expect(instance != null, "EffectInstance.create should return an instance")
|
||||
if instance != null:
|
||||
_expect_float(float(instance.get("remaining")), 4.0, "EffectInstance should copy beat duration")
|
||||
instance.call("tick_beats", 1.0)
|
||||
_expect_float(float(instance.get("remaining")), 3.0, "EffectInstance should tick beat duration")
|
||||
_expect_bool(bool(instance.call("matches_event_context", {"action_tags": [&"melee"]})), true, "EffectInstance should accept unrestricted context")
|
||||
|
||||
_expect_int((definition.get("stat_modifiers") as Array).size(), 1, "EffectDefinition should hold stat modifiers")
|
||||
_expect_int((definition.get("defense_modifiers") as Array).size(), 1, "EffectDefinition should hold defense modifiers")
|
||||
_expect_int((definition.get("action_rule_modifiers") as Array).size(), 1, "EffectDefinition should hold action rule modifiers")
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [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 _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS effect resources")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://8ndphd3qrm0a
|
||||
@@ -0,0 +1,193 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var started_actions: Array[StringName] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
_check_scripts_and_scene()
|
||||
_check_boss_action_resources()
|
||||
await _check_enemy_driver_uses_action_controller()
|
||||
await _check_chart_event_reaches_boss_driver()
|
||||
_check_chart_event_action_id()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_scripts_and_scene() -> void:
|
||||
for path: String in [
|
||||
"res://scenes/components/ai_intent.gd",
|
||||
"res://scenes/enemies/enemy_action_driver.gd",
|
||||
"res://scenes/enemies/enemy.tscn",
|
||||
"res://scenes/enemies/boss.tscn",
|
||||
]:
|
||||
_expect(load(path) != null, "%s should load" % path)
|
||||
var action_dir := DirAccess.open("res://resources/actions/enemies")
|
||||
_expect(action_dir != null, "Enemy action resource directory should exist")
|
||||
|
||||
|
||||
func _check_boss_action_resources() -> void:
|
||||
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
|
||||
_expect(resolver_script != null, "ActionResolver should load for enemy resources")
|
||||
if resolver_script == null:
|
||||
return
|
||||
resolver_script.clear_cache()
|
||||
for action_id: StringName in _boss_action_ids():
|
||||
var action: Resource = resolver_script.get_action(action_id)
|
||||
_expect(action != null, "%s should be a real Boss ActionData resource" % action_id)
|
||||
if action == null:
|
||||
continue
|
||||
_expect_bool(str(action.resource_path).begins_with("res://resources/actions/enemies/"), true, "%s should load from the enemy action directory" % action_id)
|
||||
_expect_bool(Array(action.get("input_pattern")).is_empty(), true, "%s should not expose a player input pattern" % action_id)
|
||||
_expect_bool(Array(action.get("action_tags")).has(&"enemy"), true, "%s should carry the enemy action tag" % action_id)
|
||||
_expect_bool(Array(action.get("action_tags")).has(&"boss"), true, "%s should carry the boss action tag" % action_id)
|
||||
_expect(float(action.get("startup_beats")) > 0.0, "%s should have a startup phase" % action_id)
|
||||
_expect(float(action.get("active_beats")) > 0.0, "%s should have an active phase" % action_id)
|
||||
_expect(float(action.get("recovery_beats")) > 0.0, "%s should have a recovery phase" % action_id)
|
||||
_expect_bool(str(action.get("id")).begins_with("skill_"), false, "%s should not keep legacy skill_* naming" % action_id)
|
||||
var stab: Resource = resolver_script.get_action(&"boss_lunging_stab")
|
||||
if stab != null:
|
||||
_expect_float(float(stab.get("damage_mult")), 3.0, "boss_lunging_stab damage")
|
||||
_expect_float(float(stab.get("move_mult_x")), 2.0, "boss_lunging_stab lunge distance")
|
||||
_expect_bool(Array(stab.get("defense_tags")).has(&"super_armor"), true, "boss_lunging_stab should carry super_armor defense tag")
|
||||
var shoot: Resource = resolver_script.get_action(&"boss_shoot_1")
|
||||
if shoot != null:
|
||||
_expect_equal(shoot.get("hit_type"), &"projectile", "boss_shoot_1 should use projectile hit_type")
|
||||
_expect_bool(resolver_script.resolve_pattern("boss_combo_1") == null, true, "Boss action ids should not resolve as player combo patterns")
|
||||
resolver_script.clear_cache()
|
||||
|
||||
|
||||
func _check_enemy_driver_uses_action_controller() -> void:
|
||||
var scene: PackedScene = load("res://scenes/enemies/enemy.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
var enemy := scene.instantiate()
|
||||
root.add_child(enemy)
|
||||
await process_frame
|
||||
var controller: Node = enemy.get_node("ActionController")
|
||||
var driver: Node = enemy.get_node("EnemyActionDriver")
|
||||
controller.connect("action_started", _on_action_started)
|
||||
started_actions.clear()
|
||||
driver.call("start_action", &"boss_combo_1")
|
||||
_expect(started_actions.has(&"boss_combo_1"), "EnemyActionDriver should start Boss actions through ActionController")
|
||||
_expect_int(int(controller.get("phase")), 1, "Enemy action should enter Startup, not direct Active")
|
||||
enemy.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _check_chart_event_reaches_boss_driver() -> void:
|
||||
started_actions.clear()
|
||||
var actors := Node2D.new()
|
||||
actors.name = "ActorsContainer"
|
||||
root.add_child(actors)
|
||||
var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn")
|
||||
if boss_scene == null:
|
||||
actors.queue_free()
|
||||
return
|
||||
var boss := boss_scene.instantiate()
|
||||
boss.name = "Boss"
|
||||
actors.add_child(boss)
|
||||
await process_frame
|
||||
boss.get_node("ActionController").connect("action_started", _on_action_started)
|
||||
|
||||
var chart_script: Script = load("res://resources/beat_chart.gd")
|
||||
var track_script: Script = load("res://resources/chart_track.gd")
|
||||
var chart: Resource = chart_script.new()
|
||||
var track: Resource = track_script.new()
|
||||
chart.set("chart_id", &"boss_dispatch_chart")
|
||||
track.set("track_id", &"track_boss_melee")
|
||||
track.set("track_type", &"boss")
|
||||
track.set("events", [_make_event(1, &"Boss", &"boss_combo_1")])
|
||||
chart.set("tracks", [track])
|
||||
|
||||
var runner: Node = load("res://scenes/chart/chart_runner.gd").new()
|
||||
root.add_child(runner)
|
||||
runner.set("beat_time_override", 0.5)
|
||||
runner.set("actors_container_path", runner.get_path_to(actors))
|
||||
runner.call("set_chart", chart)
|
||||
runner.call("update_for_song_time", 0.5)
|
||||
_expect(started_actions.has(&"boss_combo_1"), "ChartRunner should dispatch Boss action_id to Boss EnemyActionDriver")
|
||||
runner.queue_free()
|
||||
actors.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _check_chart_event_action_id() -> void:
|
||||
var event_script: Script = load("res://resources/chart_event.gd")
|
||||
var event: Resource = event_script.new()
|
||||
_expect(_has_property(event, "action_id"), "ChartEvent should expose action_id for AIIntent dispatch")
|
||||
event.set("action_id", &"boss_2way_shoot")
|
||||
_expect_equal(event.get("action_id"), &"boss_2way_shoot", "ChartEvent action_id should be data-driven")
|
||||
|
||||
|
||||
func _make_event(beat: int, target_id: StringName, action_id: StringName) -> Resource:
|
||||
var event: Resource = load("res://resources/chart_event.gd").new()
|
||||
event.set("beat_index", beat)
|
||||
event.set("event_type", &"enemy_action")
|
||||
event.set("target_id", target_id)
|
||||
event.set("lead_beats", 0.0)
|
||||
event.set("action_id", action_id)
|
||||
return event
|
||||
|
||||
|
||||
func _boss_action_ids() -> Array[StringName]:
|
||||
return [
|
||||
&"boss_combo_1",
|
||||
&"boss_combo_2",
|
||||
&"boss_combo_3",
|
||||
&"boss_lunging_stab",
|
||||
&"boss_dash",
|
||||
&"boss_shoot_1",
|
||||
&"boss_shoot_2",
|
||||
&"boss_2way_shoot",
|
||||
]
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
started_actions.append(StringName(str(action.get("id"))))
|
||||
|
||||
|
||||
func _has_property(object: Object, property_name: String) -> bool:
|
||||
for property: Dictionary in object.get_property_list():
|
||||
if str(property.get("name", "")) == property_name:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
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_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 enemy action pipeline")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk2khyitxxfpg
|
||||
@@ -0,0 +1,195 @@
|
||||
extends SceneTree
|
||||
|
||||
const DriverScript := preload("res://scenes/components/frame_collision_driver.gd")
|
||||
const EmitterScript := preload("res://scenes/components/damage_emitter.gd")
|
||||
const ReceiverScript := preload("res://scenes/components/damage_receiver.gd")
|
||||
const ActionControllerScript := preload("res://scenes/components/action_controller.gd")
|
||||
const ActionDataScript := preload("res://resources/action_data.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var actor := _build_actor()
|
||||
root.add_child(actor)
|
||||
await process_frame
|
||||
|
||||
var driver: Node = actor.get_node("FrameCollisionDriver")
|
||||
var receiver := actor.get_node("DamageReceiver") as Area2D
|
||||
var receiver_shape := receiver.get_node("CollisionShape2D") as CollisionShape2D
|
||||
var emitter := actor.get_node("DamageEmitter") as Area2D
|
||||
var emitter_shape := emitter.get_node("CollisionShape2D") as CollisionShape2D
|
||||
var sprite := actor.get_node("Visual/CharacterSprite") as Sprite2D
|
||||
var controller: Node = actor.get_node("ActionController")
|
||||
|
||||
# --- hurtbox follows the displayed frame's opaque bounds ---
|
||||
sprite.frame = 0
|
||||
driver.call("refresh_now")
|
||||
var body_size := (receiver_shape.shape as RectangleShape2D).size
|
||||
_expect_float(body_size.x, 24.0, "Frame 0 hurtbox width should match the small pose")
|
||||
_expect_float(body_size.y, 40.0, "Frame 0 hurtbox height should match the pose")
|
||||
_expect_vector(receiver_shape.position, Vector2(0.0, -20.0), "Frame 0 hurtbox should centre on the pose")
|
||||
|
||||
sprite.frame = 2
|
||||
driver.call("refresh_now")
|
||||
var extended_size := (receiver_shape.shape as RectangleShape2D).size
|
||||
_expect_float(extended_size.x, 44.0, "Frame 2 hurtbox should widen with the extended pose")
|
||||
_expect_bool(extended_size.x > body_size.x, true, "Damage-receive matrix must change per frame")
|
||||
|
||||
# --- hitbox only monitors on reaching ACTIVE frames ---
|
||||
var action: Resource = ActionDataScript.new()
|
||||
action.set("hit_type", &"melee")
|
||||
action.set("range", 40.0)
|
||||
emitter.call("configure_hit", action, {"label": "perfect"})
|
||||
controller.set("phase", ActionControllerScript.Phase.ACTIVE)
|
||||
sprite.frame = 0
|
||||
driver.call("refresh_now")
|
||||
_expect_bool(emitter.monitoring, false, "Windup frame (no forward reach) must not send damage")
|
||||
sprite.frame = 2
|
||||
driver.call("refresh_now")
|
||||
_expect_bool(emitter.monitoring, true, "Weapon-extended frame must send damage")
|
||||
_expect_bool(emitter_shape.position.x < 0.0, true, "Facing left, the hit shape extends left")
|
||||
_expect_bool((emitter_shape.shape as RectangleShape2D).size.x >= 30.0, true, "Hit reach should cover max(pose reach, action range)")
|
||||
|
||||
# recovery: leaving ACTIVE closes the window on the same frame
|
||||
controller.set("phase", ActionControllerScript.Phase.RECOVERY)
|
||||
driver.call("refresh_now")
|
||||
_expect_bool(emitter.monitoring, false, "Leaving ACTIVE must close the hit window")
|
||||
controller.set("phase", ActionControllerScript.Phase.ACTIVE)
|
||||
|
||||
# --- mirroring follows heading/Visual flip ---
|
||||
actor.set("heading", Vector2.RIGHT)
|
||||
(actor.get_node("Visual") as Node2D).scale.x = -2.0
|
||||
driver.call("refresh_now")
|
||||
_expect_bool(emitter.monitoring, true, "Mirrored reach frame still sends damage")
|
||||
_expect_bool(emitter_shape.position.x > 0.0, true, "Facing right, the hit shape extends right")
|
||||
|
||||
# --- body matrix is re-asserted every tick ---
|
||||
actor.collision_layer = 5
|
||||
actor.collision_mask = 99
|
||||
driver.call("refresh_now")
|
||||
_expect_int(actor.collision_layer, 32, "Body collision layer must be re-applied per frame")
|
||||
_expect_int(actor.collision_mask, 65, "Body collision mask must be re-applied per frame")
|
||||
|
||||
# --- one hit per swing even with per-frame windows ---
|
||||
var target := Area2D.new()
|
||||
target.add_to_group("damage_receivers")
|
||||
root.add_child(target)
|
||||
emitter.call("_on_area_entered", target)
|
||||
emitter.call("_on_area_entered", target)
|
||||
_expect_bool(emitter.call("has_already_hit", target), true, "Emitter should remember receivers hit this swing")
|
||||
emitter.call("clear_hit")
|
||||
_expect_bool(emitter.call("has_already_hit", target), false, "clear_hit should reset the per-swing memory")
|
||||
|
||||
target.free()
|
||||
actor.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _build_actor() -> CharacterBody2D:
|
||||
var actor_script := GDScript.new()
|
||||
actor_script.source_code = "extends CharacterBody2D\nvar heading := Vector2.LEFT\n"
|
||||
actor_script.reload()
|
||||
var actor := CharacterBody2D.new()
|
||||
actor.set_script(actor_script)
|
||||
actor.collision_layer = 32
|
||||
actor.collision_mask = 65
|
||||
|
||||
var visual := Node2D.new()
|
||||
visual.name = "Visual"
|
||||
visual.scale = Vector2(2, 2)
|
||||
actor.add_child(visual)
|
||||
|
||||
var sprite := Sprite2D.new()
|
||||
sprite.name = "CharacterSprite"
|
||||
sprite.centered = false
|
||||
sprite.offset = Vector2(-16, -32)
|
||||
sprite.hframes = 4
|
||||
sprite.vframes = 1
|
||||
sprite.texture = _build_sheet()
|
||||
visual.add_child(sprite)
|
||||
|
||||
var emitter := Area2D.new()
|
||||
emitter.name = "DamageEmitter"
|
||||
emitter.set_script(EmitterScript)
|
||||
emitter.collision_layer = 8
|
||||
emitter.collision_mask = 4
|
||||
emitter.monitoring = false
|
||||
var emitter_shape := CollisionShape2D.new()
|
||||
emitter_shape.name = "CollisionShape2D"
|
||||
emitter_shape.shape = RectangleShape2D.new()
|
||||
emitter.add_child(emitter_shape)
|
||||
actor.add_child(emitter)
|
||||
|
||||
var receiver := Area2D.new()
|
||||
receiver.name = "DamageReceiver"
|
||||
receiver.set_script(ReceiverScript)
|
||||
receiver.collision_layer = 2
|
||||
receiver.collision_mask = 16
|
||||
var receiver_shape := CollisionShape2D.new()
|
||||
receiver_shape.name = "CollisionShape2D"
|
||||
receiver_shape.shape = RectangleShape2D.new()
|
||||
receiver.add_child(receiver_shape)
|
||||
actor.add_child(receiver)
|
||||
|
||||
var controller_script := GDScript.new()
|
||||
controller_script.source_code = "extends Node\nvar phase := 0\n"
|
||||
controller_script.reload()
|
||||
var controller := Node.new()
|
||||
controller.name = "ActionController"
|
||||
controller.set_script(controller_script)
|
||||
actor.add_child(controller)
|
||||
|
||||
var driver := Node.new()
|
||||
driver.name = "FrameCollisionDriver"
|
||||
driver.set_script(DriverScript)
|
||||
driver.set("hurt_margin", Vector2.ZERO)
|
||||
actor.add_child(driver)
|
||||
return actor
|
||||
|
||||
|
||||
func _build_sheet() -> ImageTexture:
|
||||
# 4 frames of 32x32. Frame 0: compact body (x 10..21). Frame 2: weapon
|
||||
# extended to the native-left edge (x 0..21). Frames 1/3 stay empty.
|
||||
var image := Image.create(128, 32, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color(0, 0, 0, 0))
|
||||
for y: int in range(12, 32):
|
||||
for x: int in range(10, 22):
|
||||
image.set_pixel(x, y, Color.WHITE)
|
||||
for x: int in range(64, 86):
|
||||
image.set_pixel(x, y, Color.WHITE)
|
||||
return ImageTexture.create_from_image(image)
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if absf(actual - expected) > 0.5:
|
||||
failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void:
|
||||
if actual.distance_to(expected) > 0.75:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS frame collision driver")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfswm2py74msn
|
||||
@@ -0,0 +1,148 @@
|
||||
extends SceneTree
|
||||
|
||||
## Game-flow state machine + boss-room entry rules (AnchorV1.0 §4.5 / §19.5).
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var flow: Node = load("res://autoload/game_flow_manager.gd").new()
|
||||
flow.set("manage_scene", false)
|
||||
root.add_child(flow)
|
||||
await process_frame
|
||||
|
||||
# --- screens exist for every menu state ---
|
||||
for state_name: StringName in [&"Title", &"DifficultySelect", &"Paused", &"VolumeSettings", &"ExitGameConfirm", &"ReturnTitleConfirm", &"Defeat", &"Victory", &"Result"]:
|
||||
var screens: Dictionary = flow.get("_screens")
|
||||
_expect_bool(screens.has(state_name), true, "screen for %s exists" % state_name)
|
||||
var flow_ui := flow.get_node_or_null("FlowUI")
|
||||
_expect_bool(flow_ui != null, true, "flow UI layer exists")
|
||||
if flow_ui != null:
|
||||
_expect_bool(flow_ui.get_node_or_null("PauseButton") == null, true, "HUD should not show a top-right pause button; pause is Esc-only")
|
||||
|
||||
# --- pause / resume drive the SceneTree pause + rhythm clock ---
|
||||
flow.set("state", &"Gameplay_FrontArea")
|
||||
var esc := InputEventAction.new()
|
||||
esc.action = &"ui_cancel"
|
||||
esc.pressed = true
|
||||
flow.call("_unhandled_input", esc)
|
||||
_expect_string(str(flow.get("state")), "Paused", "Esc enters Paused")
|
||||
_expect_bool(root.get_tree().paused, true, "pause stops the SceneTree")
|
||||
flow.call("_unhandled_input", esc)
|
||||
_expect_string(str(flow.get("state")), "Gameplay_FrontArea", "Esc resumes gameplay")
|
||||
_expect_bool(root.get_tree().paused, false, "resume releases the SceneTree")
|
||||
|
||||
# --- boss-room entry rules on a synthetic scene (§4.5) ---
|
||||
var scene := _build_fake_scene()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
await process_frame
|
||||
var player := scene.get_node("Stage/ActorsContainer/Player")
|
||||
var streak := player.get_node("StreakCounter")
|
||||
streak.set("streak", 9)
|
||||
var combo := player.get_node("ComboWindow")
|
||||
combo.call("record", &"A")
|
||||
combo.call("record", &"A")
|
||||
var buff := player.get_node("AttackBuffComponent")
|
||||
var container := player.get_node("EffectContainer")
|
||||
for index: int in range(6):
|
||||
container.call("add_effect", buff.get("buff_definition"), &"test")
|
||||
_expect_int(int(buff.call("attack_buff_stacks")), 6, "test setup should reach 6 buff stacks")
|
||||
|
||||
flow.set("state", &"Gameplay_FrontArea")
|
||||
flow.set("_boss_room_entered", false)
|
||||
flow.call("enter_boss_room")
|
||||
|
||||
_expect_string(str(flow.get("state")), "Gameplay_BossRoom", "entering the boss room switches state")
|
||||
_expect_int(int(streak.get("streak")), 0, "boss entry clears the combo counter")
|
||||
_expect_int((combo.call("get_slots") as Array).size(), 0, "boss entry clears the four-slot window")
|
||||
_expect_int(int(buff.call("attack_buff_stacks")), 3, "boss entry caps the attack buff at 3 stacks")
|
||||
var boss := scene.get_node("Stage/ActorsContainer/Boss")
|
||||
_expect_bool(bool(boss.get("combat_enabled")), true, "boss combat unlocks on entry")
|
||||
_expect_bool(bool(boss.get("stationary")), false, "boss leaves training-dummy mode on entry")
|
||||
|
||||
# --- re-entering is a no-op ---
|
||||
streak.set("streak", 5)
|
||||
flow.call("enter_boss_room")
|
||||
_expect_int(int(streak.get("streak")), 5, "boss entry rules apply only once")
|
||||
|
||||
# --- rank appears only on victory (§19.6) ---
|
||||
flow.set("_victory", true)
|
||||
flow.set("state", &"Result")
|
||||
flow.call("_populate_result_screen")
|
||||
var labels: Dictionary = flow.get("_result_labels")
|
||||
_expect_bool((labels.get("rank") as Label).visible, true, "victory result shows the rank")
|
||||
flow.set("_victory", false)
|
||||
flow.call("_populate_result_screen")
|
||||
_expect_bool((labels.get("rank") as Label).visible, false, "defeat result hides the rank")
|
||||
|
||||
current_scene = null
|
||||
scene.free()
|
||||
flow.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _build_fake_scene() -> Node:
|
||||
var scene := Node.new()
|
||||
scene.name = "Main"
|
||||
var stage := Node2D.new()
|
||||
stage.name = "Stage"
|
||||
scene.add_child(stage)
|
||||
var actors := Node2D.new()
|
||||
actors.name = "ActorsContainer"
|
||||
stage.add_child(actors)
|
||||
|
||||
var player := CharacterBody2D.new()
|
||||
player.name = "Player"
|
||||
actors.add_child(player)
|
||||
var streak: Node = load("res://scenes/components/streak_counter.gd").new()
|
||||
streak.name = "StreakCounter"
|
||||
player.add_child(streak)
|
||||
var combo: Node = load("res://scenes/components/combo_window.gd").new()
|
||||
combo.name = "ComboWindow"
|
||||
player.add_child(combo)
|
||||
var container: Node = load("res://scenes/components/effect_container.gd").new()
|
||||
container.name = "EffectContainer"
|
||||
player.add_child(container)
|
||||
var buff: Node = load("res://scenes/components/attack_buff_component.gd").new()
|
||||
buff.name = "AttackBuffComponent"
|
||||
player.add_child(buff)
|
||||
|
||||
var boss_script := GDScript.new()
|
||||
boss_script.source_code = "extends Node2D\nvar combat_enabled := false\nvar stationary := true\n"
|
||||
boss_script.reload()
|
||||
var boss := Node2D.new()
|
||||
boss.name = "Boss"
|
||||
boss.set_script(boss_script)
|
||||
actors.add_child(boss)
|
||||
return scene
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
paused = false
|
||||
if failures.is_empty():
|
||||
print("PASS game flow")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://3pufq1dg46jr
|
||||
@@ -0,0 +1,98 @@
|
||||
extends SceneTree
|
||||
|
||||
## Result-screen statistics + Rank formula (AnchorV1.0 §19.6 / §21.10).
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var stats: Node = load("res://autoload/game_stats.gd").new()
|
||||
root.add_child(stats)
|
||||
await process_frame
|
||||
|
||||
stats.call("start_run")
|
||||
|
||||
# --- fact subscription through a synthetic bus feed ---
|
||||
stats.call("_on_judgement_made", &"perfect", 0.0, 1)
|
||||
stats.call("_on_judgement_made", &"good", 20.0, 2)
|
||||
stats.call("_on_judgement_made", &"bad", 90.0, 3)
|
||||
stats.call("_on_judgement_made", &"miss", 300.0, 4)
|
||||
stats.call("_on_time_anchor_resolved", {"beat": 4}, true, {"label": "perfect"})
|
||||
stats.call("_on_time_anchor_resolved", {"beat": 8}, false, {})
|
||||
stats.call("_on_time_phase_changed", &"past", &"future", &"time_anchor_broken")
|
||||
stats.call("_on_time_phase_changed", &"past", &"future", &"chart_init")
|
||||
stats.call("_on_streak_changed", 12)
|
||||
stats.call("_on_streak_changed", 3)
|
||||
|
||||
_expect_int(int(stats.get("perfect_count")), 1, "perfect count")
|
||||
_expect_int(int(stats.get("good_count")), 1, "good count")
|
||||
_expect_int(int(stats.get("bad_count")), 1, "bad count")
|
||||
_expect_int(int(stats.get("miss_count")), 1, "miss count")
|
||||
_expect_int(int(stats.get("anchor_success_count")), 1, "anchor success only on held")
|
||||
_expect_int(int(stats.get("phase_shift_count")), 1, "chart_init phase change must not count as a shift")
|
||||
_expect_int(int(stats.get("max_combo")), 12, "max combo keeps the highest streak")
|
||||
|
||||
# --- accuracy: (1*1.00 + 1*0.85 + 1*0.55 + 0) / 4 * 65 ---
|
||||
_expect_float(float(stats.call("accuracy_score")), (1.0 + 0.85 + 0.55) / 4.0 * 65.0, "accuracy score formula")
|
||||
# --- anchor: 1 success / (1 + 1 shift) * 20 ---
|
||||
_expect_float(float(stats.call("anchor_score")), 10.0, "anchor control score formula")
|
||||
# --- combo: min(12/80, 1) * 15 ---
|
||||
_expect_float(float(stats.call("combo_score")), 12.0 / 80.0 * 15.0, "combo stability score formula")
|
||||
|
||||
# --- rank bands (§19.6) ---
|
||||
_expect_string(str(stats.call("rank_for_score", 95.0)), "S", "rank S from 90")
|
||||
_expect_string(str(stats.call("rank_for_score", 90.0)), "S", "rank S boundary")
|
||||
_expect_string(str(stats.call("rank_for_score", 85.0)), "A", "rank A band")
|
||||
_expect_string(str(stats.call("rank_for_score", 70.0)), "B", "rank B band")
|
||||
_expect_string(str(stats.call("rank_for_score", 55.0)), "C", "rank C band")
|
||||
_expect_string(str(stats.call("rank_for_score", 20.0)), "D", "rank D band")
|
||||
|
||||
# --- anchor_total = 0 → full anchor score (§19.6) ---
|
||||
stats.call("reset")
|
||||
_expect_float(float(stats.call("anchor_score")), 20.0, "no anchors at all should give the full 20 anchor points")
|
||||
|
||||
# --- perfect run: full 100 / S ---
|
||||
stats.call("start_run")
|
||||
for index: int in range(80):
|
||||
stats.call("_on_judgement_made", &"perfect", 0.0, index)
|
||||
stats.call("_on_streak_changed", 80)
|
||||
stats.call("_on_time_anchor_resolved", {"beat": 4}, true, {"label": "perfect"})
|
||||
_expect_float(float(stats.call("total_score")), 100.0, "perfect run should score 100")
|
||||
_expect_string(str(stats.call("current_rank")), "S", "perfect run rank")
|
||||
|
||||
# --- tracking stops after stop_tracking ---
|
||||
stats.call("stop_tracking")
|
||||
stats.call("_on_judgement_made", &"miss", 0.0, 99)
|
||||
_expect_int(int(stats.get("miss_count")), 0, "no counting after stop_tracking")
|
||||
|
||||
stats.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if absf(actual - expected) > 0.001:
|
||||
failures.append("%s: expected %f, got %f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS game stats rank")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwcdrkap0rgax
|
||||
@@ -0,0 +1,117 @@
|
||||
extends SceneTree
|
||||
|
||||
## 受伤掉层与霸体(修正案):
|
||||
## 进入受伤状态掉 floor(层数/2) 层攻击 Buff,每掉 1 层获得 0.5s SuperArmor;
|
||||
## 霸体期间伤害照吃、不被打断、无击退,因此不会连锁掉层。
|
||||
|
||||
const DefenseResolverScript := preload("res://scripts/resolvers/defense_resolver.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
_expect(player_scene != null, "player scene should load")
|
||||
if player_scene == null:
|
||||
_finish()
|
||||
return
|
||||
var player := player_scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
|
||||
var buff: Node = player.get_node("AttackBuffComponent")
|
||||
var container: Node = player.get_node("EffectContainer")
|
||||
var state_machine: Node = player.get_node("StateMachine")
|
||||
var health: Node = player.get_node("HealthComponent")
|
||||
health.call("set_values", 1000, 1000)
|
||||
|
||||
# --- 攒满 7 层,受击进入受伤状态:掉 3 层(floor(7/2)),获得 1.5s 霸体 ---
|
||||
for index: int in range(7):
|
||||
container.call("add_effect", buff.get("buff_definition"), &"test")
|
||||
_expect_int(int(buff.call("attack_buff_stacks")), 7, "test setup reaches 7 stacks")
|
||||
|
||||
health.call("receive_hit", {"damage": 10, "interrupts": true})
|
||||
_expect_equal(state_machine.call("get_life_state"), &"Hitstun", "interrupting hit enters the hurt state")
|
||||
_expect_int(int(buff.call("attack_buff_stacks")), 4, "hurt state drops floor(7/2)=3 stacks")
|
||||
_expect_int(int(container.call("effect_stacks", &"hurt_super_armor")), 1, "hurt grants the super-armor effect")
|
||||
_expect_float(_armor_remaining(container), 1.5, "3 dropped stacks buy 3 x 0.5s of armor")
|
||||
|
||||
# --- 霸体期间:防御结算变为 SuperArmor,不打断、伤害照吃 ---
|
||||
var defense: Dictionary = DefenseResolverScript.resolve_effective_defense({
|
||||
"defense_state": &"Vulnerable",
|
||||
"effect_container": container,
|
||||
})
|
||||
_expect_equal(defense.get("defense_state"), &"SuperArmor", "armor window resolves as SuperArmor")
|
||||
_expect(not bool(defense.get("interrupts", true)), "armor window blocks interrupts")
|
||||
_expect_float(float(defense.get("damage_mult", 0.0)), 1.0, "armor does not reduce damage")
|
||||
|
||||
# --- 霸体期间的后续命中(interrupts 已被防御结算拦下)不再触发掉层 ---
|
||||
state_machine.call("set_life_state", &"Alive")
|
||||
health.call("receive_hit", {"damage": 10, "interrupts": bool(defense.get("interrupts", true))})
|
||||
_expect_equal(state_machine.call("get_life_state"), &"Alive", "armored hit does not re-enter the hurt state")
|
||||
_expect_int(int(buff.call("attack_buff_stacks")), 4, "armored hit does not drop further stacks")
|
||||
|
||||
# --- 霸体 + 格挡被背刺:背刺只破格挡姿态本身,霸体仍要兜底 ---
|
||||
var back_hit_defense: Dictionary = DefenseResolverScript.resolve_effective_defense({
|
||||
"defense_state": &"Parrying",
|
||||
"attack_from_front": false,
|
||||
"effect_container": container,
|
||||
})
|
||||
_expect_equal(back_hit_defense.get("defense_state"), &"SuperArmor", "a back-hit on a blocking, armored player falls back to SuperArmor")
|
||||
_expect(not bool(back_hit_defense.get("interrupts", true)), "the armored back-hit still cannot interrupt")
|
||||
|
||||
# --- 霸体按秒到期 ---
|
||||
container.call("tick_time", 1.6)
|
||||
_expect_int(int(container.call("effect_stacks", &"hurt_super_armor")), 0, "armor expires after its duration")
|
||||
|
||||
# --- 层数不足:1 层掉 floor(0.5)=0 层,不给霸体 ---
|
||||
container.call("set_effect_stacks", &"time_anchor_attack_buff", 1)
|
||||
state_machine.call("set_life_state", &"Hitstun")
|
||||
_expect_int(int(buff.call("attack_buff_stacks")), 1, "a single stack is too few to drop")
|
||||
_expect_int(int(container.call("effect_stacks", &"hurt_super_armor")), 0, "no dropped stacks means no armor")
|
||||
|
||||
player.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _armor_remaining(container: Node) -> float:
|
||||
var summaries: Array = container.call("active_effect_summaries")
|
||||
for summary: Dictionary in summaries:
|
||||
if StringName(str(summary.get("id"))) == &"hurt_super_armor":
|
||||
return float(summary.get("remaining", -1.0))
|
||||
return -1.0
|
||||
|
||||
|
||||
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 _expect_float(actual: float, expected: float, label: String, tolerance := 0.05) -> void:
|
||||
if absf(actual - expected) > tolerance:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS hurt buff drop and super armor")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://qthttlqas1cf
|
||||
@@ -0,0 +1,100 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var intents: Array = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var component_script: Script = load("res://scenes/components/input_component.gd")
|
||||
_expect(component_script != null, "InputComponent script should load")
|
||||
if component_script == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var component: Node = component_script.new()
|
||||
root.add_child(component)
|
||||
await process_frame
|
||||
_expect(component.has_signal("intent_created"), "InputComponent should expose intent_created")
|
||||
if component.has_signal("intent_created"):
|
||||
component.connect("intent_created", _on_intent_created)
|
||||
|
||||
var expected := {
|
||||
KEY_A: [&"A", &"a"],
|
||||
KEY_D: [&"D", &"d"],
|
||||
KEY_W: [&"W", &"w"],
|
||||
KEY_S: [&"S", &"s"],
|
||||
KEY_SPACE: [&"SP", &"space"],
|
||||
}
|
||||
for key: Key in expected:
|
||||
var before := intents.size()
|
||||
var handled: bool = component.call("handle_input_event", _key_event(key, true, false, false))
|
||||
_expect_bool(handled, true, "%s keycode press should be handled" % OS.get_keycode_string(key))
|
||||
_expect_int(intents.size(), before + 1, "%s press intent count" % OS.get_keycode_string(key))
|
||||
if intents.size() == before + 1:
|
||||
_expect_string(str(intents[before].get("symbol")), str(expected[key][0]), "%s symbol" % OS.get_keycode_string(key))
|
||||
_expect_string(str(intents[before].get("rhythm_action")), str(expected[key][1]), "%s rhythm action" % OS.get_keycode_string(key))
|
||||
_expect_string(str(intents[before].get("event_type")), "pressed", "%s event type" % OS.get_keycode_string(key))
|
||||
_expect_bool(float(intents[before].get("timestamp_ms")) > 0.0, true, "%s timestamp" % OS.get_keycode_string(key))
|
||||
before = intents.size()
|
||||
handled = component.call("handle_input_event", _key_event(key, true, false, true))
|
||||
_expect_bool(handled, true, "%s physical key press should be handled" % OS.get_keycode_string(key))
|
||||
_expect_int(intents.size(), before + 1, "%s physical press intent count" % OS.get_keycode_string(key))
|
||||
|
||||
var echo_count := intents.size()
|
||||
var echo_handled: bool = component.call("handle_input_event", _key_event(KEY_A, true, true, false))
|
||||
_expect_bool(echo_handled, false, "echo press should not be handled")
|
||||
_expect_int(intents.size(), echo_count, "echo press should not emit intent")
|
||||
|
||||
var release_handled: bool = component.call("handle_input_event", _key_event(KEY_A, false, false, false))
|
||||
_expect_bool(release_handled, true, "A release should be handled")
|
||||
_expect_string(str(intents[intents.size() - 1].get("event_type")), "released", "release event type")
|
||||
_expect_string(str(intents[intents.size() - 1].get("symbol")), "A", "release symbol")
|
||||
component.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _on_intent_created(intent) -> void:
|
||||
intents.append(intent)
|
||||
|
||||
|
||||
func _key_event(key: Key, pressed: bool, echo: bool, physical_only: bool) -> InputEventKey:
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = KEY_NONE if physical_only else key
|
||||
event.physical_keycode = key if physical_only else KEY_NONE
|
||||
event.pressed = pressed
|
||||
event.echo = echo
|
||||
return event
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS input component intents")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1ouwybdpl40q
|
||||
@@ -0,0 +1,229 @@
|
||||
extends SceneTree
|
||||
|
||||
## new1 定案 (2026-07-05): 节奏点一次性消耗 + 双击容错 + 空按 MISS 触发 0.5s
|
||||
## 输入锁定,锁定期间按键完全无效;预置判定(测试/AI)绕过门控。
|
||||
|
||||
var failures: Array[String] = []
|
||||
var started: Array[StringName] = []
|
||||
var rejected: Array[StringName] = []
|
||||
var judged: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_run_rhythm_gate_unit()
|
||||
await _run_controller_integration()
|
||||
_finish()
|
||||
|
||||
|
||||
func _run_rhythm_gate_unit() -> void:
|
||||
var rhythm_script: Script = load("res://autoload/rhythm_manager.gd")
|
||||
_expect(rhythm_script != null, "RhythmManager script should load")
|
||||
if rhythm_script == null:
|
||||
return
|
||||
var rhythm: Node = rhythm_script.new()
|
||||
rhythm.set("starts_on_ready", false)
|
||||
# 钉住时钟:running + 冻结 song_position,锁定时长可被推进的时间跨过。
|
||||
rhythm.set("running", true)
|
||||
rhythm.set("_clock_paused", true)
|
||||
rhythm.set("_paused_song_position", 2.0)
|
||||
rhythm.set("input_lockout_seconds", 0.5)
|
||||
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 4))), "ok", "first press on beat 4 should pass")
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("good", 4))), "ignored", "double-tap on consumed beat 4 should be ignored once")
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("good", 4))), "miss", "third press on consumed beat 4 should downgrade to miss")
|
||||
_expect_bool(bool(rhythm.call("is_input_locked")), true, "downgraded miss should start the input lockout")
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 5))), "ignored", "press during lockout should be ignored even if on-time")
|
||||
rhythm.set("_paused_song_position", 2.6)
|
||||
_expect_bool(bool(rhythm.call("is_input_locked")), false, "lockout should expire after input_lockout_seconds")
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 5))), "ok", "beat 5 should be consumable after lockout expires")
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("miss", 6))), "miss", "off-window press should stay miss")
|
||||
_expect_bool(bool(rhythm.call("is_input_locked")), true, "off-window miss should start the input lockout")
|
||||
rhythm.set("_paused_song_position", 3.2)
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("bad", 7))), "ok", "fresh beat after lockout should pass")
|
||||
rhythm.call("start")
|
||||
rhythm.set("running", true)
|
||||
rhythm.set("_clock_paused", true)
|
||||
rhythm.set("_paused_song_position", 2.0)
|
||||
_expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 4))), "ok", "start() should clear consumed beats and lockout")
|
||||
rhythm.free()
|
||||
|
||||
|
||||
func _run_controller_integration() -> void:
|
||||
var autoload_rhythm := root.get_node_or_null("RhythmManager")
|
||||
_expect(autoload_rhythm != null, "RhythmManager autoload should exist")
|
||||
if autoload_rhythm == null:
|
||||
return
|
||||
# 钉住全局时钟,让门控/锁定完全确定。
|
||||
if bool(autoload_rhythm.get("playing")):
|
||||
autoload_rhythm.call("stop")
|
||||
autoload_rhythm.set("running", true)
|
||||
autoload_rhythm.set("_clock_paused", true)
|
||||
autoload_rhythm.set("_paused_song_position", 10.0)
|
||||
autoload_rhythm.set("_lockout_until", -1.0)
|
||||
autoload_rhythm.call("_prune_consumed_beats", 0)
|
||||
(autoload_rhythm.get("_consumed_beats") as Dictionary).clear()
|
||||
autoload_rhythm.set("input_lockout_seconds", 0.5)
|
||||
|
||||
var fixture := await _controller_fixture()
|
||||
if fixture.is_empty():
|
||||
return
|
||||
var controller: Node = fixture["controller"]
|
||||
var combo: Node = fixture["combo"]
|
||||
controller.connect("action_started", _on_action_started)
|
||||
controller.connect("action_rejected", _on_action_rejected)
|
||||
_event_bus().connect("judgement_made", _on_judgement_made)
|
||||
|
||||
controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 20))
|
||||
_expect_array(combo.call("get_slots"), [&"A"], "live perfect should enter ComboWindow")
|
||||
_expect_int(started.size(), 1, "live perfect should start an action")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
var judged_before := judged.size()
|
||||
controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 20))
|
||||
_expect_array(combo.call("get_slots"), [&"A"], "double-tap on consumed beat should leave slots untouched")
|
||||
_expect_int(started.size(), 1, "double-tap on consumed beat should not start an action")
|
||||
_expect_string(str(rejected[rejected.size() - 1]), "input_ignored", "double-tap should reject as input_ignored")
|
||||
_expect_int(judged.size(), judged_before, "ignored input should emit no judgement feedback")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 20))
|
||||
_expect_array(combo.call("get_slots"), [&"A", &"Ø"], "third press on consumed beat should record a miss slot")
|
||||
_expect_string(str(judged[judged.size() - 1].get("quality")), "miss", "third press should emit a miss judgement")
|
||||
_expect_bool(bool(autoload_rhythm.call("is_input_locked")), true, "downgraded miss should lock input")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 21))
|
||||
_expect_string(str(rejected[rejected.size() - 1]), "input_locked", "press during lockout should reject as input_locked")
|
||||
_expect_array(combo.call("get_slots"), [&"A", &"Ø"], "locked press should leave no trace in ComboWindow")
|
||||
_expect_int(started.size(), 1, "locked press should not start an action")
|
||||
|
||||
autoload_rhythm.set("_paused_song_position", 10.6)
|
||||
controller.call("_reset_to_idle")
|
||||
controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 21))
|
||||
_expect_int(started.size(), 2, "press after lockout expiry should start an action")
|
||||
|
||||
# 预置判定(无 live 标记)必须绕过门控:已消耗的拍也照常执行。
|
||||
controller.call("_reset_to_idle")
|
||||
controller.call("submit_intent", _pinned_intent(&"A", &"a", "perfect", 20))
|
||||
_expect_int(started.size(), 3, "pinned judgement should bypass the gate even on a consumed beat")
|
||||
|
||||
autoload_rhythm.set("_lockout_until", -1.0)
|
||||
(autoload_rhythm.get("_consumed_beats") as Dictionary).clear()
|
||||
fixture["root"].free()
|
||||
|
||||
|
||||
func _rating(label: String, beat: int) -> Dictionary:
|
||||
return {"label": label, "diff": 0.0, "abs_diff": 0.0, "nearest_beat": beat}
|
||||
|
||||
|
||||
func _live_intent(symbol: StringName, rhythm_action: StringName, label: String, beat: int) -> RefCounted:
|
||||
var intent := _pinned_intent(symbol, rhythm_action, label, beat)
|
||||
var judgement: Dictionary = intent.get("judgement")
|
||||
judgement["live"] = true
|
||||
intent.set("judgement", judgement)
|
||||
return intent
|
||||
|
||||
|
||||
func _pinned_intent(symbol: StringName, rhythm_action: StringName, label: String, beat: int) -> RefCounted:
|
||||
var intent_script: Script = load("res://scenes/components/input_intent.gd")
|
||||
var intent: RefCounted = intent_script.call("create", symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec()))
|
||||
intent.set("judgement", _rating(label, beat))
|
||||
return intent
|
||||
|
||||
|
||||
func _controller_fixture() -> Dictionary:
|
||||
var combo_script: Script = load("res://scenes/components/combo_window.gd")
|
||||
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
|
||||
var state_script: Script = load("res://scenes/components/state_machine.gd")
|
||||
var energy_script: Script = load("res://scenes/components/energy_component.gd")
|
||||
var controller_script: Script = load("res://scenes/components/action_controller.gd")
|
||||
if combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null:
|
||||
_expect(false, "fixture scripts should load")
|
||||
return {}
|
||||
var fixture_root := Node.new()
|
||||
root.add_child(fixture_root)
|
||||
var combo: Node = combo_script.new()
|
||||
combo.name = "ComboWindow"
|
||||
fixture_root.add_child(combo)
|
||||
var resolver: Node = resolver_script.new()
|
||||
resolver.name = "ActionResolver"
|
||||
fixture_root.add_child(resolver)
|
||||
var state: Node = state_script.new()
|
||||
state.name = "StateMachine"
|
||||
fixture_root.add_child(state)
|
||||
var energy: Node = energy_script.new()
|
||||
energy.name = "EnergyComponent"
|
||||
fixture_root.add_child(energy)
|
||||
var controller: Node = controller_script.new()
|
||||
controller.name = "ActionController"
|
||||
controller.set("combo_window_path", NodePath("../ComboWindow"))
|
||||
controller.set("action_resolver_path", NodePath("../ActionResolver"))
|
||||
controller.set("state_machine_path", NodePath("../StateMachine"))
|
||||
fixture_root.add_child(controller)
|
||||
await process_frame
|
||||
energy.call("set_values", 99, 99)
|
||||
return {
|
||||
"root": fixture_root,
|
||||
"combo": combo,
|
||||
"controller": controller,
|
||||
}
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
started.append(StringName(str(action.get("id"))))
|
||||
|
||||
|
||||
func _on_action_rejected(_intent, reason: StringName) -> void:
|
||||
rejected.append(reason)
|
||||
|
||||
|
||||
func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void:
|
||||
judged.append({"quality": quality, "offset_ms": offset_ms, "beat": beat_index})
|
||||
|
||||
|
||||
func _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 _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_array(actual: Array, expected: Array, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS input gate (new1 consume/lockout)")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bah2t3bddtgea
|
||||
@@ -0,0 +1,65 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var bus_script: Script = load("res://autoload/event_bus.gd")
|
||||
_expect(bus_script != null, "EventBus script should load")
|
||||
if bus_script != null:
|
||||
var legacy_signal := StringName("rhythm" + "_action_requested")
|
||||
var bus: Node = bus_script.new()
|
||||
_expect_int(_signal_arg_count(bus, "judgement_made"), 3, "EventBus.judgement_made should expose quality, offset_ms, beat_index")
|
||||
_expect(not bus.has_signal(legacy_signal), "EventBus should not expose the legacy rhythm action request signal")
|
||||
bus.free()
|
||||
|
||||
var rhythm_script: Script = load("res://autoload/rhythm_manager.gd")
|
||||
_expect(rhythm_script != null, "RhythmManager script should load")
|
||||
if rhythm_script != null:
|
||||
var legacy_method := StringName("judge" + "_action")
|
||||
var rhythm: Node = rhythm_script.new()
|
||||
_expect(not rhythm.has_method(legacy_method), "RhythmManager should not expose the legacy action judgement method")
|
||||
_expect(_has_property(rhythm, "clock_volume_db"), "RhythmManager should expose clock_volume_db")
|
||||
var rating: Dictionary = rhythm.call("judge", 0.0)
|
||||
_expect(rating.has("nearest_beat"), "RhythmManager.judge should return nearest_beat")
|
||||
_expect(typeof(rating.get("nearest_beat")) == TYPE_INT, "nearest_beat should be an int")
|
||||
rhythm.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _signal_arg_count(node: Node, signal_name: StringName) -> int:
|
||||
for signal_info: Dictionary in node.get_signal_list():
|
||||
if StringName(str(signal_info.get("name"))) == signal_name:
|
||||
return (signal_info.get("args") as Array).size()
|
||||
return -1
|
||||
|
||||
|
||||
func _has_property(object: Object, property_name: StringName) -> bool:
|
||||
for property: Dictionary in object.get_property_list():
|
||||
if StringName(str(property.get("name"))) == property_name:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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 judgement payload")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://7e5r7oommir5
|
||||
@@ -0,0 +1,132 @@
|
||||
extends SceneTree
|
||||
|
||||
## Regression for the first Level 1 contact: body blocking must not leave the
|
||||
## opening melee minion outside the player's basic attack hit window.
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var rhythm := root.get_node_or_null("RhythmManager")
|
||||
if rhythm != null and rhythm.has_method("stop_manager"):
|
||||
rhythm.call("stop_manager")
|
||||
|
||||
await _check_inactive_boss_is_not_front_area_body_wall()
|
||||
await _check_body_blocked_contact_hits()
|
||||
await _check_opening_sequence_can_reach_minion()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_inactive_boss_is_not_front_area_body_wall() -> void:
|
||||
var stage := await _make_stage()
|
||||
if stage == null:
|
||||
return
|
||||
var boss := stage.get_node("ActorsContainer/Boss") as CharacterBody2D
|
||||
var receiver := boss.get_node("DamageReceiver") as Area2D
|
||||
|
||||
_expect(
|
||||
(int(boss.collision_layer) & (1 << 6)) == 0,
|
||||
"Inactive Level 1 Boss should not occupy the enemy_body layer before boss-room entry"
|
||||
)
|
||||
_expect(
|
||||
not receiver.monitorable,
|
||||
"Inactive Level 1 Boss should not expose a damage receiver before boss-room entry"
|
||||
)
|
||||
|
||||
boss.set("combat_enabled", true)
|
||||
await physics_frame
|
||||
_expect(
|
||||
(int(boss.collision_layer) & (1 << 6)) != 0,
|
||||
"Boss should restore enemy_body collision when boss combat unlocks"
|
||||
)
|
||||
_expect(receiver.monitorable, "Boss damage receiver should restore when boss combat unlocks")
|
||||
stage.free()
|
||||
|
||||
|
||||
func _check_body_blocked_contact_hits() -> void:
|
||||
var stage := await _make_stage()
|
||||
if stage == null:
|
||||
return
|
||||
var container := stage.get_node("ActorsContainer")
|
||||
var player := container.get_node("Player") as Node2D
|
||||
var minion := container.get_node("JinZhanMinion") as Node2D
|
||||
var behavior := minion.get_node_or_null("MinionBehavior")
|
||||
if behavior != null:
|
||||
behavior.set("enabled", false)
|
||||
var health := minion.get_node("HealthComponent")
|
||||
|
||||
player.global_position = Vector2(minion.global_position.x - 20.0, minion.global_position.y)
|
||||
player.set("heading", Vector2.RIGHT)
|
||||
await physics_frame
|
||||
var start_health := int(health.get("current"))
|
||||
|
||||
player.call("submit_combo_input", "D", "perfect")
|
||||
await create_timer(0.8).timeout
|
||||
await physics_frame
|
||||
|
||||
_expect(
|
||||
int(health.get("current")) < start_health,
|
||||
"Body-blocked Level 1 melee contact should still be hit by D basic attack"
|
||||
)
|
||||
|
||||
stage.free()
|
||||
|
||||
|
||||
func _check_opening_sequence_can_reach_minion() -> void:
|
||||
var stage := await _make_stage()
|
||||
if stage == null:
|
||||
return
|
||||
var container := stage.get_node("ActorsContainer")
|
||||
var player := container.get_node("Player") as Node2D
|
||||
var minion := container.get_node("JinZhanMinion") as Node2D
|
||||
var health := minion.get_node("HealthComponent")
|
||||
var start_health := int(health.get("current"))
|
||||
|
||||
# new2 出生点改到地图最左侧后,玩家需要先推进一段路才遇敌;这里直接把
|
||||
# 玩家放到近战怪的追击停步距离(120px)上,验证接敌后的连招能够命中。
|
||||
player.global_position = Vector2(minion.global_position.x - 120.0, minion.global_position.y)
|
||||
player.set("heading", Vector2.RIGHT)
|
||||
await physics_frame
|
||||
|
||||
for _index: int in range(5):
|
||||
player.call("submit_combo_input", "D", "perfect")
|
||||
await create_timer(0.65).timeout
|
||||
await physics_frame
|
||||
|
||||
_expect(
|
||||
int(health.get("current")) < start_health,
|
||||
"Approach-range Level 1 sequence should be able to damage the first minion"
|
||||
)
|
||||
stage.free()
|
||||
|
||||
|
||||
func _make_stage() -> Node:
|
||||
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
|
||||
_expect(stage_scene != null, "stage.tscn should load")
|
||||
if stage_scene == null:
|
||||
return null
|
||||
var stage: Node = stage_scene.instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
await physics_frame
|
||||
return stage
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS level1 melee contact")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3uno3t3v06al
|
||||
@@ -0,0 +1,182 @@
|
||||
extends SceneTree
|
||||
|
||||
## 第一关出怪流程(关卡设计指南 §7/§8):
|
||||
## 阶段一预置近战怪 → 击杀后阶段二在玩家附近刷远程怪 → 再击杀后阶段三
|
||||
## 左右交替增援 2近战+2远程 → 全灭 6 只解封 Boss 房门。
|
||||
|
||||
var failures: Array[String] = []
|
||||
var cleared_signals := 0
|
||||
|
||||
const MINION_GATE_CLEARANCE := 220.0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
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()
|
||||
if bus.has_signal("front_area_cleared"):
|
||||
bus.connect("front_area_cleared", func() -> void: cleared_signals += 1)
|
||||
|
||||
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
|
||||
_expect(stage_scene != null, "stage.tscn should load")
|
||||
if stage_scene == null:
|
||||
_finish()
|
||||
return
|
||||
var stage: Node = stage_scene.instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var director: Node = stage.get_node_or_null("LevelDirector")
|
||||
var gate: Node = stage.get_node_or_null("BossRoomGate")
|
||||
var container: Node = stage.get_node("ActorsContainer")
|
||||
var player: Node2D = container.get_node("Player")
|
||||
_expect(director != null, "Stage should carry a LevelDirector")
|
||||
_expect(gate != null, "Stage should carry a BossRoomGate")
|
||||
if director == null or gate == null:
|
||||
stage.free()
|
||||
_finish()
|
||||
return
|
||||
director.set("reinforcement_time_scale", 0.01)
|
||||
|
||||
# ---- 阶段一:只有 1 只预置近战怪,Boss 房门封锁 ----
|
||||
var initial := _alive_minions(container)
|
||||
_expect_int(initial.size(), 1, "Phase 1 should start with exactly one pre-placed minion")
|
||||
if initial.size() == 1:
|
||||
_expect_equal(initial[0].get("strength_profile"), &"past_strong", "The pre-placed minion should be the melee (past-strong) one")
|
||||
_expect_minions_left_of_gate(initial, gate, "Initial pre-placed minion")
|
||||
var initial_behavior := initial[0].get_node_or_null("MinionBehavior")
|
||||
if initial_behavior != null:
|
||||
_expect_float(float(initial_behavior.get("arena_max_x")), float(gate.get("gate_x")) - MINION_GATE_CLEARANCE, "Initial pre-placed minion should use the front-area safe patrol max")
|
||||
_expect(bool(gate.get("sealed")), "Gate should start sealed")
|
||||
var wall_shape := gate.get_node("GateWall").get_child(0) as CollisionShape2D
|
||||
_expect(not wall_shape.disabled, "Sealed gate should enable its blocking wall")
|
||||
_expect_int(int(director.get("phase")), 1, "Director should start in phase 1")
|
||||
|
||||
# ---- 击杀近战怪 → 阶段二远程怪在玩家附近刷新(可感知、不贴脸)----
|
||||
_kill(initial[0])
|
||||
await process_frame
|
||||
_expect_int(int(director.get("phase")), 2, "First kill should advance to phase 2")
|
||||
var phase2 := _alive_minions(container)
|
||||
_expect_int(phase2.size(), 1, "Phase 2 should spawn exactly one new minion")
|
||||
if phase2.size() == 1:
|
||||
var ranged: Node2D = phase2[0]
|
||||
_expect_equal(ranged.get("strength_profile"), &"future_strong", "Phase 2 minion should be the ranged (future-strong) one")
|
||||
_expect_minions_left_of_gate(phase2, gate, "Phase 2 minion")
|
||||
var distance := absf(ranged.global_position.x - player.global_position.x)
|
||||
_expect(distance >= 200.0 and distance <= 480.0, "Ranged minion should spawn near but not on the player (got %.0f px)" % distance)
|
||||
var behavior := ranged.get_node_or_null("MinionBehavior")
|
||||
_expect(behavior != null and behavior.get("role") == &"ranged", "Phase 2 minion behavior should use the ranged role")
|
||||
|
||||
# ---- 击杀远程怪 → 阶段三增援:近战L → 远程R → 近战R → 远程L ----
|
||||
_kill(ranged)
|
||||
await process_frame
|
||||
_expect_int(int(director.get("phase")), 3, "Second kill should advance to phase 3")
|
||||
# 缩放后的增援间隔与 headless 帧长同数量级:只断言 0 秒那只已经
|
||||
# 立即出现,后续数量交给下面“全部四只”断言覆盖。
|
||||
var first_wave := _alive_minions(container)
|
||||
_expect(first_wave.size() >= 1, "First reinforcement should spawn immediately (got %d)" % first_wave.size())
|
||||
if first_wave.size() >= 1:
|
||||
_expect_equal(first_wave[0].get("strength_profile"), &"past_strong", "First reinforcement should be melee")
|
||||
_expect(first_wave[0].global_position.x < 1500.0, "First reinforcement should enter from the LEFT edge")
|
||||
# 其余三只按 0.01 缩放的间隔陆续出现。
|
||||
await create_timer(0.5).timeout
|
||||
await process_frame
|
||||
var reinforcements := _alive_minions(container)
|
||||
_expect_int(reinforcements.size(), 4, "All four reinforcements should have spawned")
|
||||
_expect_minions_left_of_gate(reinforcements, gate, "Phase 3 reinforcement")
|
||||
_expect_int(int(director.get("total_spawned")), 6, "Level should field six minions in total")
|
||||
var melee_count := 0
|
||||
var ranged_count := 0
|
||||
for minion: Node2D in reinforcements:
|
||||
if minion.get("strength_profile") == &"past_strong":
|
||||
melee_count += 1
|
||||
else:
|
||||
ranged_count += 1
|
||||
_expect_int(melee_count, 2, "Reinforcements should include two melee minions")
|
||||
_expect_int(ranged_count, 2, "Reinforcements should include two ranged minions")
|
||||
|
||||
# ---- 全灭 → 解封 Boss 房门 ----
|
||||
_expect(bool(gate.get("sealed")), "Gate must stay sealed while reinforcements live")
|
||||
for minion: Node2D in reinforcements:
|
||||
_kill(minion)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(bool(director.get("cleared")), "Director should report the front area cleared")
|
||||
_expect(not bool(gate.get("sealed")), "Clearing all six minions should unseal the gate")
|
||||
_expect(wall_shape.disabled, "Unsealed gate should disable its blocking wall")
|
||||
_expect_int(cleared_signals, 1, "front_area_cleared should broadcast exactly once")
|
||||
|
||||
stage.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _alive_minions(container: Node) -> Array[Node2D]:
|
||||
var result: Array[Node2D] = []
|
||||
for child: Node in container.get_children():
|
||||
if not child.is_in_group("enemies"):
|
||||
continue
|
||||
var health := child.get_node_or_null("HealthComponent")
|
||||
if health != null and int(health.get("current")) > 0:
|
||||
result.append(child as Node2D)
|
||||
return result
|
||||
|
||||
|
||||
func _kill(minion: Node) -> void:
|
||||
var health := minion.get_node_or_null("HealthComponent")
|
||||
if health != null:
|
||||
health.call("apply_damage", 999999)
|
||||
|
||||
|
||||
func _expect_minions_left_of_gate(minions: Array[Node2D], gate: Node, label: String) -> void:
|
||||
var limit := float(gate.get("gate_x")) - MINION_GATE_CLEARANCE
|
||||
for minion: Node2D in minions:
|
||||
_expect(
|
||||
minion.global_position.x <= limit,
|
||||
"%s should stay left of the boss-room air wall safety line %.0f, got %.0f" % [label, limit, minion.global_position.x]
|
||||
)
|
||||
|
||||
|
||||
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 _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
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_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void:
|
||||
if absf(actual - expected) > tolerance:
|
||||
failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS level1 spawn flow")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hyqpwd2tiigq
|
||||
@@ -0,0 +1,51 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1152, 648)
|
||||
var main_scene: PackedScene = load("res://scenes/main/main.tscn")
|
||||
if main_scene == null:
|
||||
failures.append("main.tscn should load")
|
||||
_finish()
|
||||
return
|
||||
var main := main_scene.instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
|
||||
_expect_bool(main.get_node_or_null("UILayer") is CanvasLayer, true, "Main UI should stay in the x1 CanvasLayer")
|
||||
var ui := main.get_node_or_null("UILayer/UI") as Control
|
||||
_expect_bool(ui != null, true, "Main UI should be under UILayer/UI")
|
||||
if ui != null:
|
||||
_expect_vector(ui.position, Vector2.ZERO, "Main UI should start at the viewport origin inside the CanvasLayer")
|
||||
_expect_vector(ui.size, Vector2(1152.0, 648.0), "Main UI should fill the viewport")
|
||||
_expect_vector(ui.scale, Vector2.ONE, "Main UI should not counter-scale against the world camera")
|
||||
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
_finish()
|
||||
|
||||
|
||||
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_bool(actual: bool, expected: bool, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS main ui camera canvas")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c24pli2m867qe
|
||||
@@ -0,0 +1,99 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1152, 648)
|
||||
var settings := _ensure_game_settings()
|
||||
settings.call("set_difficulty", &"normal")
|
||||
var flow: Node = load("res://autoload/game_flow_manager.gd").new()
|
||||
flow.name = "FlowUnderTest"
|
||||
flow.set("manage_scene", false)
|
||||
root.add_child(flow)
|
||||
await process_frame
|
||||
flow.call("_set_state", &"Title")
|
||||
await process_frame
|
||||
_expect_equal(StringName(str(flow.get("state"))), &"Title", "Flow should start on title")
|
||||
var title_screen := flow.get("_screens").get(&"Title", null) as Control
|
||||
_expect(title_screen != null, "Title screen should exist")
|
||||
if title_screen != null:
|
||||
_expect(title_screen.get_node_or_null("MenuArt") is TextureRect, "Title should use the panel frame art")
|
||||
_expect(title_screen.get_node_or_null("TitleLogo") is TextureRect, "Title should show the game logo art")
|
||||
_expect(title_screen.get_node_or_null("TitleDifficultyValue") is Label, "Title should show the current-difficulty footer (2026-07-05 mockup)")
|
||||
_expect(title_screen.get_node_or_null("StartGameButton") is Button, "Title should expose the start button")
|
||||
_expect(title_screen.get_node_or_null("DifficultyButton") is Button, "Title should expose the difficulty button")
|
||||
_expect(title_screen.get_node_or_null("QuitGameButton") is Button, "Title should expose the quit button")
|
||||
|
||||
await _click(Vector2(191, 355))
|
||||
_expect_equal(StringName(str(flow.get("state"))), &"DifficultySelect", "Clicking difficulty opens the difficulty page")
|
||||
var difficulty_screen := flow.get("_screens").get(&"DifficultySelect", null) as Control
|
||||
_expect(difficulty_screen != null, "Difficulty screen should exist")
|
||||
if difficulty_screen != null:
|
||||
_expect(difficulty_screen.get_node_or_null("MenuArt") is TextureRect, "Difficulty should use the panel frame art")
|
||||
_expect(difficulty_screen.get_node_or_null("DifficultyFooterValue") is Label, "Difficulty screen should show the current-difficulty footer (2026-07-05 mockup)")
|
||||
_expect(difficulty_screen.get_node_or_null("ConfirmStartButton") is Button, "Difficulty should expose the start command")
|
||||
_expect(difficulty_screen.get_node_or_null("BackTitleButton") is Button, "Difficulty should expose the back command")
|
||||
|
||||
await _click(Vector2(191, 221))
|
||||
_expect_equal(StringName(str(flow.get("_pending_difficulty"))), &"easy", "Clicking easy selects the easy difficulty")
|
||||
_expect_equal(StringName(str(settings.get("difficulty"))), &"easy", "Clicking easy applies the selected difficulty immediately")
|
||||
|
||||
await _click(Vector2(191, 558))
|
||||
_expect_equal(StringName(str(flow.get("state"))), &"Title", "Clicking the reference back button returns to title")
|
||||
|
||||
flow.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _click(position: Vector2) -> void:
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = position
|
||||
root.push_input(motion)
|
||||
await process_frame
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.position = position
|
||||
press.pressed = true
|
||||
root.push_input(press)
|
||||
await process_frame
|
||||
var release := InputEventMouseButton.new()
|
||||
release.button_index = MOUSE_BUTTON_LEFT
|
||||
release.position = position
|
||||
release.pressed = false
|
||||
root.push_input(release)
|
||||
await process_frame
|
||||
|
||||
|
||||
func _ensure_game_settings() -> Node:
|
||||
var settings := root.get_node_or_null("GameSettings")
|
||||
if settings != null:
|
||||
return settings
|
||||
settings = load("res://autoload/game_settings.gd").new()
|
||||
settings.name = "GameSettings"
|
||||
root.add_child(settings)
|
||||
return settings
|
||||
|
||||
|
||||
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 _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS menu difficulty confirm")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://doye5hjetb20k
|
||||
@@ -0,0 +1,147 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_check_phase0_files()
|
||||
_check_directories()
|
||||
_check_project_settings()
|
||||
_check_phase1_files()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_phase0_files() -> void:
|
||||
_expect(_file_contains("res://.gitignore", ".godot/"), ".gitignore should ignore .godot/")
|
||||
_expect(_file_contains("res://.gitignore", ".DS_Store"), ".gitignore should ignore .DS_Store")
|
||||
_expect(_file_contains("res://.gitignore", "*.import.tmp"), ".gitignore should ignore *.import.tmp")
|
||||
_expect(FileAccess.file_exists("res://README.md"), "README.md should exist")
|
||||
_expect(_file_contains("res://docs/decisions.md", "body collision audit"), "docs/decisions.md should record the body collision audit")
|
||||
_expect(_file_contains("res://docs/decisions.md", "player_body"), "body collision audit should record player_body layer decision")
|
||||
_expect(_file_contains("res://docs/decisions.md", "enemy_body"), "body collision audit should record enemy_body layer decision")
|
||||
|
||||
|
||||
func _check_directories() -> void:
|
||||
for dir_path: String in [
|
||||
"res://autoload",
|
||||
"res://scripts/resolvers",
|
||||
"res://resources/actions",
|
||||
"res://resources/actions/enemies",
|
||||
"res://resources/effects",
|
||||
"res://resources/charts",
|
||||
"res://scenes/characters",
|
||||
"res://scenes/chart",
|
||||
"res://scenes/combat",
|
||||
"res://scenes/components",
|
||||
"res://scenes/enemies",
|
||||
"res://scenes/ground",
|
||||
"res://scenes/main",
|
||||
"res://scenes/stage",
|
||||
"res://scenes/ui",
|
||||
"res://assets/art",
|
||||
"res://assets/audio",
|
||||
"res://assets/ui",
|
||||
"res://tools",
|
||||
"res://tests",
|
||||
"res://docs",
|
||||
]:
|
||||
_expect(DirAccess.open(dir_path) != null, "%s should exist" % dir_path)
|
||||
_expect(DirAccess.open("res://resources/effects/time_phase") != null, "resources/effects/time_phase should contain implemented time-phase effects")
|
||||
|
||||
|
||||
func _check_project_settings() -> void:
|
||||
var expected_layers := {
|
||||
"layer_names/2d_physics/layer_1": "world",
|
||||
"layer_names/2d_physics/layer_2": "player_hurtbox",
|
||||
"layer_names/2d_physics/layer_3": "enemy_hurtbox",
|
||||
"layer_names/2d_physics/layer_4": "player_hitbox",
|
||||
"layer_names/2d_physics/layer_5": "enemy_hitbox",
|
||||
"layer_names/2d_physics/layer_6": "player_body",
|
||||
"layer_names/2d_physics/layer_7": "enemy_body",
|
||||
}
|
||||
for key: String in expected_layers:
|
||||
_expect(ProjectSettings.has_setting(key), "%s should be configured" % key)
|
||||
if ProjectSettings.has_setting(key):
|
||||
_expect_equal(str(ProjectSettings.get_setting(key)), expected_layers[key], "%s name" % key)
|
||||
|
||||
for autoload_name: String in ["EventBus", "RhythmManager"]:
|
||||
var key := "autoload/%s" % autoload_name
|
||||
_expect(ProjectSettings.has_setting(key), "%s should be registered as an autoload" % autoload_name)
|
||||
if ProjectSettings.has_setting("autoload/EventBus"):
|
||||
_expect(str(ProjectSettings.get_setting("autoload/EventBus")).contains("res://autoload/event_bus.gd"), "EventBus autoload path")
|
||||
if ProjectSettings.has_setting("autoload/RhythmManager"):
|
||||
_expect(str(ProjectSettings.get_setting("autoload/RhythmManager")).contains("res://autoload/rhythm_manager.gd"), "RhythmManager autoload path")
|
||||
|
||||
_expect_action_unbound("move_left")
|
||||
_expect_action_unbound("move_right")
|
||||
_expect_action_key("combo_a", KEY_A)
|
||||
_expect_action_key("combo_d", KEY_D)
|
||||
_expect_action_key("combo_w", KEY_W)
|
||||
_expect_action_key("combo_s", KEY_S)
|
||||
_expect_action_key("combo_space", KEY_SPACE)
|
||||
|
||||
|
||||
func _check_phase1_files() -> void:
|
||||
_expect(FileAccess.file_exists("res://autoload/event_bus.gd"), "event_bus.gd should be migrated")
|
||||
_expect(FileAccess.file_exists("res://autoload/event_bus.gd.uid"), "event_bus.gd.uid should be migrated")
|
||||
_expect(FileAccess.file_exists("res://autoload/rhythm_manager.gd"), "rhythm_manager.gd should be migrated")
|
||||
_expect(FileAccess.file_exists("res://autoload/rhythm_manager.gd.uid"), "rhythm_manager.gd.uid should be migrated")
|
||||
_expect(FileAccess.file_exists("res://assets/audio/song.ogg"), "song.ogg should be migrated")
|
||||
_expect(FileAccess.file_exists("res://assets/audio/song.ogg.import"), "song.ogg.import should be migrated")
|
||||
|
||||
|
||||
func _expect_action_unbound(action_name: StringName) -> void:
|
||||
_expect(InputMap.has_action(action_name), "%s action should exist" % action_name)
|
||||
if InputMap.has_action(action_name):
|
||||
_expect_int(InputMap.action_get_events(action_name).size(), 0, "%s should be registered but physically unbound" % action_name)
|
||||
|
||||
|
||||
func _expect_action_key(action_name: StringName, keycode: Key) -> void:
|
||||
_expect(InputMap.has_action(action_name), "%s action should exist" % action_name)
|
||||
var found := false
|
||||
if InputMap.has_action(action_name):
|
||||
for event: InputEvent in InputMap.action_get_events(action_name):
|
||||
var key := event as InputEventKey
|
||||
if key != null and (key.physical_keycode == keycode or key.keycode == keycode):
|
||||
found = true
|
||||
_expect(found, "%s should bind %s" % [action_name, OS.get_keycode_string(keycode)])
|
||||
|
||||
|
||||
func _file_contains(path: String, needle: String) -> bool:
|
||||
if not FileAccess.file_exists(path):
|
||||
return false
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return false
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
return text.contains(needle)
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_equal(actual, expected, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, str(expected), str(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 migration phase 0/1")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://la6ssnq52fkv
|
||||
@@ -0,0 +1,115 @@
|
||||
extends SceneTree
|
||||
|
||||
## 小怪行为状态机运行时验证(docs/Level1Design.md §2):
|
||||
## 近战怪:玩家在警戒外巡逻 → 玩家进入警戒后下一拍追击 → 进入攻击距离按拍出手。
|
||||
## 远程怪:站位固定 → 玩家近身过久触发后撤 → 拉开距离后回到站位固定。
|
||||
|
||||
var failures: Array[String] = []
|
||||
var started_actions := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
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 manager := _ensure_time_phase_manager()
|
||||
|
||||
var stage: Node = (load("res://scenes/stage/stage.tscn") as PackedScene).instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
manager.call("reset_to_initial", &"past")
|
||||
await process_frame
|
||||
|
||||
var container: Node = stage.get_node("ActorsContainer")
|
||||
var player: Node2D = container.get_node("Player")
|
||||
var melee: Node2D = container.get_node("JinZhanMinion")
|
||||
var behavior: Node = melee.get_node("MinionBehavior")
|
||||
var director: Node = stage.get_node("LevelDirector")
|
||||
var ground_y := float(director.get("ground_y"))
|
||||
|
||||
# ---- 巡逻:玩家在警戒范围外 ----
|
||||
player.global_position = Vector2(1250.0, ground_y)
|
||||
for i: int in range(8):
|
||||
await physics_frame
|
||||
_expect_equal(behavior.get("state"), &"Patrol", "Melee minion should patrol while the player is outside alert range")
|
||||
_expect(absf(float(melee.get("approach_direction"))) > 0.0, "Patrol should keep the minion strolling")
|
||||
|
||||
# ---- 警戒 → 下一拍进入追击 ----
|
||||
player.global_position = Vector2(melee.global_position.x - 300.0, ground_y)
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
_expect_equal(behavior.get("state"), &"Patrol", "Alert alone must not change state before the next beat")
|
||||
bus.emit_signal("beat_ticked", 100)
|
||||
await physics_frame
|
||||
_expect_equal(behavior.get("state"), &"Chase", "Melee minion should switch to Chase on the beat after alert")
|
||||
|
||||
# ---- 进入攻击距离 → 按拍出手 ----
|
||||
var controller: Node = melee.get_node("ActionController")
|
||||
controller.connect("action_started", func(_action: Resource, _intent: Variant) -> void: started_actions += 1)
|
||||
player.global_position = Vector2(melee.global_position.x - 80.0, ground_y)
|
||||
await physics_frame
|
||||
bus.emit_signal("beat_ticked", 101)
|
||||
await process_frame
|
||||
_expect(started_actions >= 1, "Melee minion should attack on the beat once in range")
|
||||
|
||||
# ---- 远程怪:站位固定 → 近身触发后撤 → 重新固定 ----
|
||||
var ranged: Node2D = director.call("spawn_minion", &"ranged", Vector2(2050.0, ground_y))
|
||||
var ranged_behavior: Node = ranged.get_node("MinionBehavior")
|
||||
ranged_behavior.set("too_close_seconds", 0.2)
|
||||
await process_frame
|
||||
_expect_equal(ranged_behavior.get("state"), &"Hold", "Ranged minion should hold its station after spawning")
|
||||
var station_x := ranged.global_position.x
|
||||
player.global_position = Vector2(station_x - 60.0, ground_y)
|
||||
await create_timer(0.5).timeout
|
||||
var retreat_state: StringName = ranged_behavior.get("state")
|
||||
_expect(retreat_state == &"Retreat" or ranged.global_position.x > station_x + 40.0, "Crowded ranged minion should retreat (state %s)" % retreat_state)
|
||||
await create_timer(1.4).timeout
|
||||
_expect_equal(ranged_behavior.get("state"), &"Hold", "Ranged minion should settle on a new station after retreating")
|
||||
_expect(ranged.global_position.x > station_x + 100.0, "Retreat should actually gain distance (moved %.0f px)" % (ranged.global_position.x - station_x))
|
||||
|
||||
stage.free()
|
||||
_finish()
|
||||
|
||||
|
||||
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 _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 _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS minion behavior states")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ebs6yqox0x02
|
||||
@@ -0,0 +1,374 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
const FORM_NATIVE_FACING := {
|
||||
&"jin_zhan_1": Vector2.LEFT,
|
||||
&"jin_zhan_3": Vector2.RIGHT,
|
||||
# 小女孩素材原生朝右:配置成 LEFT 曾导致她背对玩家攻击(镜像反了)。
|
||||
&"yuan_cheng_1": Vector2.RIGHT,
|
||||
&"yuan_cheng_2": Vector2.RIGHT,
|
||||
}
|
||||
|
||||
## 远程弹道离 actor 原点的高度:可见脚底约 -40px,再加出手高度,对齐手部/法球。
|
||||
const RANGED_PROJECTILE_HEIGHT := 86.0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await _check_attack_intervals()
|
||||
await _check_form_native_facing()
|
||||
await _check_animation_feet_are_ground_locked()
|
||||
await _check_witch_projectile_stays_horizontal()
|
||||
await _check_little_girl_projectile_stays_horizontal()
|
||||
await _check_ranged_form_visual_heights_match()
|
||||
await _check_projectile_visual_rotates_with_direction()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_attack_intervals() -> void:
|
||||
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 past_strong := _spawn_form(scene, &"jin_zhan_3", &"jin_zhan_1", &"past_strong")
|
||||
manager.call("reset_to_initial", &"past")
|
||||
await process_frame
|
||||
_expect_int(int(past_strong.call("current_attack_interval_beats")), 2, "Strong minion phase should attack once every two beats")
|
||||
manager.call("set_time_phase", &"future", &"debug")
|
||||
await process_frame
|
||||
_expect_int(int(past_strong.call("current_attack_interval_beats")), 4, "Weak minion phase should attack once every four beats")
|
||||
past_strong.queue_free()
|
||||
|
||||
var future_strong := _spawn_form(scene, &"yuan_cheng_1", &"yuan_cheng_2", &"future_strong")
|
||||
manager.call("set_time_phase", &"past", &"debug")
|
||||
await process_frame
|
||||
_expect_int(int(future_strong.call("current_attack_interval_beats")), 4, "Future-strong minion should be weak in Past and attack once every four beats")
|
||||
manager.call("set_time_phase", &"future", &"debug")
|
||||
await process_frame
|
||||
_expect_int(int(future_strong.call("current_attack_interval_beats")), 2, "Future-strong minion should be strong in Future and attack once every two beats")
|
||||
future_strong.queue_free()
|
||||
|
||||
|
||||
func _check_form_native_facing() -> void:
|
||||
var scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
for form_id: StringName in FORM_NATIVE_FACING.keys():
|
||||
var minion := _spawn_form(scene, form_id, form_id, &"past_strong")
|
||||
await process_frame
|
||||
var target := Node2D.new()
|
||||
target.name = "Target"
|
||||
root.add_child(target)
|
||||
|
||||
minion.global_position = Vector2(100.0, 100.0)
|
||||
target.global_position = Vector2(40.0, 100.0)
|
||||
minion.call("look_at_target", target)
|
||||
minion.call("flip_sprites")
|
||||
_expect_visual_scale_for_heading(minion, form_id, Vector2.LEFT, "%s should face left when target is left" % form_id)
|
||||
|
||||
target.global_position = Vector2(160.0, 100.0)
|
||||
minion.call("look_at_target", target)
|
||||
minion.call("flip_sprites")
|
||||
_expect_visual_scale_for_heading(minion, form_id, Vector2.RIGHT, "%s should face right when target is right" % form_id)
|
||||
|
||||
target.queue_free()
|
||||
minion.queue_free()
|
||||
|
||||
|
||||
func _check_animation_feet_are_ground_locked() -> void:
|
||||
var scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
if scene == null:
|
||||
return
|
||||
for form_id: StringName in FORM_NATIVE_FACING.keys():
|
||||
var minion := _spawn_form(scene, form_id, form_id, &"past_strong")
|
||||
await process_frame
|
||||
minion.set_physics_process(false)
|
||||
var animation_player := minion.get_node("AnimationPlayer") as AnimationPlayer
|
||||
var sprite := minion.get_node("Visual/CharacterSprite") as Sprite2D
|
||||
var baseline := _animation_baseline(animation_player, sprite, StringName("%s_idle" % form_id))
|
||||
_expect(baseline < INF, "%s should have a measurable idle baseline" % form_id)
|
||||
for animation_name: StringName in animation_player.get_animation_list():
|
||||
if not str(animation_name).begins_with(str(form_id)):
|
||||
continue
|
||||
_expect_animation_feet(animation_player, sprite, animation_name, baseline)
|
||||
minion.queue_free()
|
||||
|
||||
|
||||
func _check_witch_projectile_stays_horizontal() -> void:
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
var action: Resource = load("res://resources/actions/enemies/minion_yuan_cheng_2_attack.tres")
|
||||
_expect(player_scene != null, "Player scene should load for witch projectile")
|
||||
_expect(minion_scene != null, "Minion scene should load for witch projectile")
|
||||
_expect(action != null, "Witch projectile action should load")
|
||||
if player_scene == null or minion_scene == null or action == null:
|
||||
return
|
||||
|
||||
var container := Node2D.new()
|
||||
container.name = "ActorsContainer"
|
||||
root.add_child(container)
|
||||
var player := player_scene.instantiate() as Node2D
|
||||
player.name = "Player"
|
||||
container.add_child(player)
|
||||
var witch := _spawn_form_under(container, minion_scene, &"yuan_cheng_2", &"yuan_cheng_2", &"future_strong")
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
player.global_position = Vector2(100.0, 560.0)
|
||||
witch.global_position = Vector2(360.0, 560.0)
|
||||
witch.call("look_at_target", player)
|
||||
var left_request: Dictionary = (witch.call("projectile_requests_for_action", action) as Array)[0]
|
||||
var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO)
|
||||
_expect_vector(left_direction, Vector2.LEFT, "Witch projectile should stay horizontal when firing left")
|
||||
|
||||
player.global_position = Vector2(620.0, 560.0)
|
||||
witch.call("look_at_target", player)
|
||||
var right_request: Dictionary = (witch.call("projectile_requests_for_action", action) as Array)[0]
|
||||
var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO)
|
||||
_expect_vector(right_direction, Vector2.RIGHT, "Witch projectile should stay horizontal when firing right")
|
||||
container.queue_free()
|
||||
|
||||
|
||||
func _check_little_girl_projectile_stays_horizontal() -> void:
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
var action: Resource = load("res://resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres")
|
||||
_expect(player_scene != null, "Player scene should load for little girl projectile aim")
|
||||
_expect(minion_scene != null, "Minion scene should load for little girl projectile aim")
|
||||
_expect(action != null, "Little girl projectile action should load")
|
||||
if player_scene == null or minion_scene == null or action == null:
|
||||
return
|
||||
|
||||
var container := Node2D.new()
|
||||
container.name = "ActorsContainer"
|
||||
root.add_child(container)
|
||||
var player := player_scene.instantiate() as Node2D
|
||||
player.name = "Player"
|
||||
container.add_child(player)
|
||||
var girl := _spawn_form_under(container, minion_scene, &"yuan_cheng_1", &"yuan_cheng_1", &"past_strong")
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
player.global_position = Vector2(100.0, 560.0)
|
||||
girl.global_position = Vector2(360.0, 560.0)
|
||||
girl.call("look_at_target", player)
|
||||
var left_request: Dictionary = (girl.call("projectile_requests_for_action", action) as Array)[0]
|
||||
var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO)
|
||||
_expect_vector(left_direction, Vector2.LEFT, "Little girl projectile should stay horizontal when firing left")
|
||||
|
||||
player.global_position = Vector2(620.0, 560.0)
|
||||
girl.call("look_at_target", player)
|
||||
var right_request: Dictionary = (girl.call("projectile_requests_for_action", action) as Array)[0]
|
||||
var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO)
|
||||
_expect_vector(right_direction, Vector2.RIGHT, "Little girl projectile should stay horizontal when firing right")
|
||||
container.queue_free()
|
||||
|
||||
|
||||
func _check_ranged_form_visual_heights_match() -> void:
|
||||
var scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
_expect(scene != null, "Minion scene should load for ranged height check")
|
||||
if scene == null:
|
||||
return
|
||||
var girl := _spawn_form(scene, &"yuan_cheng_1", &"yuan_cheng_1", &"past_strong")
|
||||
var witch := _spawn_form(scene, &"yuan_cheng_2", &"yuan_cheng_2", &"past_strong")
|
||||
await process_frame
|
||||
var girl_height := _visible_sprite_height(girl)
|
||||
var witch_height := _visible_sprite_height(witch)
|
||||
_expect(girl_height > 0.0, "Little girl visible height should be measurable")
|
||||
_expect(witch_height > 0.0, "Witch visible height should be measurable")
|
||||
_expect_float(witch_height, girl_height, "Witch visible height should match little girl height after normalization", 8.0)
|
||||
_expect_float((witch.call("projectile_spawn_position", null) as Vector2).y, (girl.call("projectile_spawn_position", null) as Vector2).y, "Witch projectile lane should match little girl lane", 1.0)
|
||||
_expect_float(
|
||||
(girl.call("projectile_spawn_position", null) as Vector2).y - girl.global_position.y,
|
||||
-RANGED_PROJECTILE_HEIGHT,
|
||||
"Ranged projectiles should launch from hand/orb height, not the ankles",
|
||||
0.5
|
||||
)
|
||||
girl.queue_free()
|
||||
witch.queue_free()
|
||||
|
||||
|
||||
func _check_projectile_visual_rotates_with_direction() -> void:
|
||||
var projectile_scene: PackedScene = load("res://scenes/combat/player_projectile.tscn")
|
||||
_expect(projectile_scene != null, "Projectile scene should load")
|
||||
if projectile_scene == null:
|
||||
return
|
||||
var projectile := projectile_scene.instantiate() as Area2D
|
||||
var direction := Vector2(-0.8, -0.2).normalized()
|
||||
projectile.set("direction", direction)
|
||||
root.add_child(projectile)
|
||||
await process_frame
|
||||
var sprite := projectile.get_node_or_null("Sprite") as Sprite2D
|
||||
_expect(sprite != null, "Projectile should create a Sprite")
|
||||
if sprite != null:
|
||||
_expect_float(float(sprite.rotation), direction.angle(), "Projectile Sprite should rotate along its travel direction")
|
||||
_expect_equal(sprite.flip_h, false, "Projectile Sprite should use rotation rather than horizontal flip for aiming")
|
||||
projectile.queue_free()
|
||||
|
||||
|
||||
func _spawn_form(scene: PackedScene, past_form: StringName, future_form: StringName, strength_profile: StringName) -> Node2D:
|
||||
return _spawn_form_under(root, scene, past_form, future_form, strength_profile)
|
||||
|
||||
|
||||
func _spawn_form_under(parent: Node, scene: PackedScene, past_form: StringName, future_form: StringName, strength_profile: StringName) -> Node2D:
|
||||
var minion := scene.instantiate() as Node2D
|
||||
minion.set("past_form_id", past_form)
|
||||
minion.set("future_form_id", future_form)
|
||||
minion.set("strength_profile", strength_profile)
|
||||
parent.add_child(minion)
|
||||
return minion
|
||||
|
||||
|
||||
func _expect_visual_scale_for_heading(minion: Node, form_id: StringName, heading: Vector2, label: String) -> void:
|
||||
_expect_equal(minion.get("heading"), heading, label)
|
||||
var native_facing: Vector2 = FORM_NATIVE_FACING.get(form_id, Vector2.RIGHT)
|
||||
var expected_sign := 1.0 if heading == native_facing else -1.0
|
||||
var scale_x := float(minion.get_node("Visual").scale.x)
|
||||
if signf(scale_x) != expected_sign:
|
||||
failures.append("%s: expected visual scale sign %.0f, got %.3f" % [label, expected_sign, scale_x])
|
||||
|
||||
|
||||
func _animation_baseline(animation_player: AnimationPlayer, sprite: Sprite2D, animation_name: StringName) -> float:
|
||||
if not animation_player.has_animation(animation_name):
|
||||
return INF
|
||||
animation_player.play(animation_name)
|
||||
animation_player.seek(0.0, true)
|
||||
animation_player.advance(0.0)
|
||||
return _visible_sprite_bottom_global_y(sprite)
|
||||
|
||||
|
||||
func _expect_animation_feet(animation_player: AnimationPlayer, sprite: Sprite2D, animation_name: StringName, baseline: float) -> void:
|
||||
var animation := animation_player.get_animation(animation_name)
|
||||
if animation == null:
|
||||
return
|
||||
var frame_track := _frame_track_index(animation)
|
||||
if frame_track < 0:
|
||||
return
|
||||
for key_index: int in range(animation.track_get_key_count(frame_track)):
|
||||
var key_time := float(animation.track_get_key_time(frame_track, key_index))
|
||||
animation_player.play(animation_name)
|
||||
animation_player.seek(key_time, true)
|
||||
animation_player.advance(0.0)
|
||||
var feet := _visible_sprite_bottom_global_y(sprite)
|
||||
if absf(feet - baseline) > 0.5:
|
||||
failures.append("%s frame %d should keep feet on %.2f, got %.2f" % [animation_name, key_index, baseline, feet])
|
||||
|
||||
|
||||
func _frame_track_index(animation: Animation) -> int:
|
||||
for track_index: int in range(animation.get_track_count()):
|
||||
if str(animation.track_get_path(track_index)).ends_with(":frame"):
|
||||
return track_index
|
||||
return -1
|
||||
|
||||
|
||||
func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float:
|
||||
if sprite == null or sprite.texture == null:
|
||||
return INF
|
||||
var image := sprite.texture.get_image()
|
||||
if image == null or image.is_empty():
|
||||
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:
|
||||
return INF
|
||||
return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y
|
||||
|
||||
|
||||
func _visible_sprite_height(minion: Node2D) -> float:
|
||||
var sprite := minion.get_node_or_null("Visual/CharacterSprite") as Sprite2D
|
||||
var visual := minion.get_node_or_null("Visual") as Node2D
|
||||
if sprite == null or visual == null or sprite.texture == null:
|
||||
return 0.0
|
||||
var bounds := _visible_sprite_bounds_local(sprite)
|
||||
if bounds.size == Vector2.ZERO:
|
||||
return 0.0
|
||||
return bounds.size.y * absf(visual.scale.y)
|
||||
|
||||
|
||||
func _visible_sprite_bounds_local(sprite: Sprite2D) -> Rect2:
|
||||
var image := sprite.texture.get_image()
|
||||
if image == null or image.is_empty():
|
||||
return Rect2()
|
||||
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 left := frame_width
|
||||
var right := -1
|
||||
var top := frame_height
|
||||
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:
|
||||
left = mini(left, x)
|
||||
right = maxi(right, x)
|
||||
top = mini(top, y)
|
||||
bottom = maxi(bottom, y)
|
||||
if right < left or bottom < top:
|
||||
return Rect2()
|
||||
return Rect2(Vector2(left, top), Vector2(right - left, bottom - top))
|
||||
|
||||
|
||||
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 _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 _expect_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void:
|
||||
if absf(actual - expected) > tolerance:
|
||||
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.01:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS minion cadence and facing")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cikycp4tmve5h
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://djoccr2u4i0qh
|
||||
@@ -0,0 +1,166 @@
|
||||
extends SceneTree
|
||||
|
||||
## new3 定案 (2026-07-05) 专项回归:
|
||||
## 1. Boss 对不耗能量的攻击恒霸体(掉血但不打断、不击退);耗能技能可压制。
|
||||
## 2. 近战小怪基础攻击 50 / 远程 25(LevelDirector 按职责配置)。
|
||||
## 3. 远程小怪连续受击 4 次触发无敌闪烁 + 跳跃脱离。
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var rhythm := root.get_node_or_null("RhythmManager")
|
||||
if rhythm != null and rhythm.has_method("stop_manager"):
|
||||
rhythm.call("stop_manager")
|
||||
await _check_boss_super_armor_rule()
|
||||
await _check_role_based_base_attack()
|
||||
await _check_ranged_hit_escape()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_boss_super_armor_rule() -> void:
|
||||
var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn")
|
||||
_expect(boss_scene != null, "boss.tscn should load")
|
||||
if boss_scene == null:
|
||||
return
|
||||
var boss: Node = boss_scene.instantiate()
|
||||
root.add_child(boss)
|
||||
await process_frame
|
||||
|
||||
var normal_action: Resource = load("res://resources/actions/ground_attack_left_1.tres")
|
||||
var energy_action: Resource = load("res://resources/actions/dash_slash_left.tres")
|
||||
_expect_bool(bool(boss.call("shrugs_off_hit", null)), true, "Boss should shrug off action-less hits")
|
||||
_expect_bool(bool(boss.call("shrugs_off_hit", normal_action)), true, "Boss should shrug off zero-cost normal attacks")
|
||||
_expect_bool(bool(boss.call("shrugs_off_hit", energy_action)), false, "Boss should be suppressed by energy-cost skills")
|
||||
|
||||
var receiver := boss.get_node("DamageReceiver") as Area2D
|
||||
var emitter_script: Script = load("res://scenes/components/damage_emitter.gd")
|
||||
var emitter: Area2D = emitter_script.new()
|
||||
emitter.set("damage", 100)
|
||||
root.add_child(emitter)
|
||||
|
||||
var resolver: Script = load("res://scripts/resolvers/combat_resolver.gd")
|
||||
|
||||
emitter.set("action_context", normal_action)
|
||||
emitter.set("judgement_context", {"label": "perfect"})
|
||||
var normal_result: Dictionary = resolver.call("resolve_hit", emitter, receiver)
|
||||
_expect_bool(int(normal_result.get("damage", 0)) > 0, true, "Normal attack should still damage the Boss")
|
||||
_expect_bool(bool(normal_result.get("interrupts", true)), false, "Normal attack should not interrupt the Boss")
|
||||
_expect_bool((normal_result.get("knockback", Vector2.ZERO) as Vector2) == Vector2.ZERO, true, "Normal attack should not knock the Boss back")
|
||||
|
||||
emitter.set("action_context", energy_action)
|
||||
emitter.set("judgement_context", {"label": "perfect"})
|
||||
var skill_result: Dictionary = resolver.call("resolve_hit", emitter, receiver)
|
||||
_expect_bool(int(skill_result.get("damage", 0)) > 0, true, "Energy skill should damage the Boss")
|
||||
_expect_bool(bool(skill_result.get("interrupts", true)), true, "Energy skill should interrupt the Boss")
|
||||
_expect_bool((skill_result.get("knockback", Vector2.ZERO) as Vector2).x != 0.0, true, "Energy skill should knock the Boss back")
|
||||
|
||||
emitter.free()
|
||||
boss.free()
|
||||
|
||||
|
||||
func _check_role_based_base_attack() -> void:
|
||||
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
|
||||
_expect(stage_scene != null, "stage.tscn should load")
|
||||
if stage_scene == null:
|
||||
return
|
||||
var stage: Node = stage_scene.instantiate()
|
||||
root.add_child(stage)
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var jin := stage.get_node_or_null("ActorsContainer/JinZhanMinion")
|
||||
_expect(jin != null, "Stage should keep the preplaced melee minion")
|
||||
if jin != null:
|
||||
var emitter := jin.get_node("DamageEmitter")
|
||||
_expect_int(int(emitter.get("damage")), 50, "Melee minion base attack should be doubled to 50")
|
||||
|
||||
var director := stage.get_node_or_null("LevelDirector")
|
||||
if director != null:
|
||||
var yuan: Node = director.call("spawn_minion", &"ranged", Vector2(2100.0, 560.0))
|
||||
if yuan != null:
|
||||
var yuan_emitter := yuan.get_node("DamageEmitter")
|
||||
_expect_int(int(yuan_emitter.get("damage")), 25, "Ranged minion base attack should stay 25")
|
||||
var behavior := yuan.get_node("MinionBehavior")
|
||||
_expect_bool(float(behavior.get("attack_range")) <= 460.0, true, "Ranged minion must not fire from off-screen (attack range within camera half-width)")
|
||||
stage.free()
|
||||
|
||||
|
||||
func _check_ranged_hit_escape() -> void:
|
||||
var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn")
|
||||
_expect(minion_scene != null, "minion.tscn should load")
|
||||
if minion_scene == null:
|
||||
return
|
||||
var minion: Node = minion_scene.instantiate()
|
||||
minion.set("past_form_id", &"yuan_cheng_1")
|
||||
minion.set("future_form_id", &"yuan_cheng_2")
|
||||
minion.set("strength_profile", &"future_strong")
|
||||
root.add_child(minion)
|
||||
await process_frame
|
||||
|
||||
var behavior := minion.get_node("MinionBehavior")
|
||||
behavior.set("role", &"ranged")
|
||||
behavior.set("state", &"Hold")
|
||||
var receiver := minion.get_node("DamageReceiver") as Area2D
|
||||
|
||||
# 连续受击 3 次仍不脱离;第 4 次触发。
|
||||
for _index: int in range(3):
|
||||
behavior.call("_on_damage_received", 10, &"melee", Vector2.ZERO)
|
||||
_expect_bool(bool(behavior.call("_should_escape")), false, "Three consecutive hits should not trigger the escape yet")
|
||||
behavior.call("_on_damage_received", 10, &"melee", Vector2.ZERO)
|
||||
_expect_bool(bool(behavior.call("_should_escape")), true, "Fourth consecutive hit should trigger the escape")
|
||||
|
||||
behavior.call("_begin_escape")
|
||||
_expect_equal(behavior.get("state"), &"Retreat", "Escape should enter the Retreat state")
|
||||
_expect_bool(bool(behavior.get("_escaping")), true, "Escape retreat should use the jump-out mode")
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
_expect_bool(receiver.monitoring, false, "Escape should grant brief invulnerability (receiver off)")
|
||||
|
||||
# 无敌结束后恢复可受击。
|
||||
behavior.call("_tick_escape_invulnerability", 1.0)
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
_expect_bool(receiver.monitoring, true, "Invulnerability should expire and restore the receiver")
|
||||
|
||||
# 触发后进入冷却:不能连续无敌脱离。
|
||||
for _index: int in range(4):
|
||||
behavior.call("_on_damage_received", 10, &"melee", Vector2.ZERO)
|
||||
_expect_bool(bool(behavior.call("_should_escape")), false, "Escape must not chain while the retreat cooldown is running")
|
||||
|
||||
minion.free()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_equal(actual: Variant, expected: Variant, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS new3 enemy rules")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgoqj5ta5vv1b
|
||||
@@ -0,0 +1,98 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_check_action_data_schema()
|
||||
_check_chart_schema()
|
||||
_check_effect_schema()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_action_data_schema() -> void:
|
||||
var action_script: Script = load("res://resources/action_data.gd")
|
||||
_expect(action_script != null, "ActionData script should load")
|
||||
if action_script == null:
|
||||
return
|
||||
|
||||
var action: Resource = action_script.new()
|
||||
_expect(_has_property(action, "input_pattern"), "ActionData should expose input_pattern")
|
||||
_expect(_has_property(action, "startup_beats"), "ActionData should expose startup_beats")
|
||||
_expect(_has_property(action, "active_beats"), "ActionData should expose active_beats")
|
||||
_expect(_has_property(action, "recovery_beats"), "ActionData should expose recovery_beats")
|
||||
_expect(_has_property(action, "knockback_mult_x"), "ActionData should expose knockback_mult_x")
|
||||
_expect(_has_property(action, "knockback_mult_y"), "ActionData should expose knockback_mult_y")
|
||||
_expect_float(float(action.get("knockback_mult_x")), 0.0, "knockback_mult_x should default to zero")
|
||||
_expect_float(float(action.get("knockback_mult_y")), 0.0, "knockback_mult_y should default to zero")
|
||||
_expect(not _has_property(action, "allowed_time_phases"), "ActionData should keep action execution independent from time phase fields")
|
||||
|
||||
|
||||
func _check_chart_schema() -> void:
|
||||
var beat_chart_script: Script = load("res://resources/beat_chart.gd")
|
||||
var chart_track_script: Script = load("res://resources/chart_track.gd")
|
||||
var chart_event_script: Script = load("res://resources/chart_event.gd")
|
||||
_expect(beat_chart_script != null, "BeatChart script should load")
|
||||
_expect(chart_track_script != null, "ChartTrack script should load")
|
||||
_expect(chart_event_script != null, "ChartEvent script should load")
|
||||
if beat_chart_script == null or chart_track_script == null or chart_event_script == null:
|
||||
return
|
||||
|
||||
var chart: Resource = beat_chart_script.new()
|
||||
var track: Resource = chart_track_script.new()
|
||||
var event: Resource = chart_event_script.new()
|
||||
|
||||
_expect(_has_property(chart, "tracks"), "BeatChart should expose tracks")
|
||||
_expect(_has_property(track, "events"), "ChartTrack should expose events")
|
||||
_expect(_has_property(event, "event_type"), "ChartEvent should expose event_type")
|
||||
_expect(_has_property(event, "payload"), "ChartEvent should expose payload")
|
||||
_expect(_has_property(event, "lead_beats"), "ChartEvent should expose lead_beats")
|
||||
_expect(_has_property(event, "time_phase_mask"), "ChartEvent should expose time_phase_mask")
|
||||
_expect(_has_property(chart, "initial_time_phase"), "BeatChart should expose initial_time_phase")
|
||||
|
||||
event.set("beat_index", 3)
|
||||
event.set("subdivision", 1)
|
||||
event.set("subdivisions_per_beat", 4)
|
||||
_expect_float(float(event.call("beat_position")), 3.25, "ChartEvent beat_position should include subdivisions")
|
||||
|
||||
|
||||
func _check_effect_schema() -> void:
|
||||
for path: String in [
|
||||
"res://resources/effects/effect_definition.gd",
|
||||
"res://resources/effects/effect_instance.gd",
|
||||
"res://resources/effects/stat_modifier.gd",
|
||||
"res://resources/effects/defense_modifier.gd",
|
||||
"res://resources/effects/action_rule_modifier.gd",
|
||||
]:
|
||||
_expect(load(path) != null, "%s should load" % path)
|
||||
|
||||
|
||||
func _has_property(object: Object, property_name: StringName) -> bool:
|
||||
for property: Dictionary in object.get_property_list():
|
||||
if StringName(str(property.get("name"))) == property_name:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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 _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS phase 2 schema layer")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://r6pge647kdht
|
||||
@@ -0,0 +1,113 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var ui_scene := load("res://scenes/ui/main_ui.tscn") as PackedScene
|
||||
_expect(ui_scene != null, "Main UI scene should load")
|
||||
if ui_scene == null:
|
||||
_finish()
|
||||
return
|
||||
var ui := ui_scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
_expect(ui.get_node_or_null("StatusBars/HealthBar") is ProgressBar, "UI should keep Player HealthBar")
|
||||
_expect(ui.get_node_or_null("BossStatus/BossHealthBar") is ProgressBar, "UI should include BossHealthBar")
|
||||
_expect(ui.get_node_or_null("BossStatus/BossPortrait") is TextureRect, "BossStatus should include the Boss portrait art")
|
||||
_expect(ui.has_method("bind_boss_actor"), "MainUI should expose bind_boss_actor")
|
||||
var boss_status := ui.get_node_or_null("BossStatus") as Control
|
||||
var player_health_bar := ui.get_node_or_null("StatusBars/HealthBar") as ProgressBar
|
||||
var boss_health_bar := ui.get_node_or_null("BossStatus/BossHealthBar") as ProgressBar
|
||||
var boss_portrait := ui.get_node_or_null("BossStatus/BossPortrait") as TextureRect
|
||||
if boss_status == null or player_health_bar == null or boss_health_bar == null or boss_portrait == null:
|
||||
ui.free()
|
||||
_finish()
|
||||
return
|
||||
_expect_equal(boss_portrait.texture.resource_path if boss_portrait.texture != null else "", "res://assets/ui/panels/boss_portrait.png", "Boss portrait should use the supplied boss art")
|
||||
_expect_float(boss_portrait.size.x, 57.0, "Boss portrait should keep the supplied art width")
|
||||
_expect_float(boss_portrait.size.y, 58.0, "Boss portrait should keep the supplied art height")
|
||||
_expect_bool(boss_status.visible, false, "BossStatus should stay hidden before boss-room entry")
|
||||
var player := _actor_with_health("Player", 1000, 1000)
|
||||
var boss := _actor_with_health("Boss", 28000, 28000)
|
||||
root.add_child(player)
|
||||
root.add_child(boss)
|
||||
await process_frame
|
||||
if ui.has_method("bind_boss_actor"):
|
||||
ui.call("bind_boss_actor", boss)
|
||||
await process_frame
|
||||
_expect_bool(boss_status.visible, false, "Binding a Boss actor should not show BossStatus before boss-room entry")
|
||||
var bus := root.get_node_or_null("EventBus")
|
||||
_expect(bus != null and bus.has_signal("flow_state_changed"), "EventBus should expose flow_state_changed")
|
||||
if bus != null and bus.has_signal("flow_state_changed"):
|
||||
bus.emit_signal("flow_state_changed", &"Title", &"Gameplay_FrontArea")
|
||||
await process_frame
|
||||
_expect_bool(boss_status.visible, false, "BossStatus should remain hidden in the front area")
|
||||
bus.emit_signal("flow_state_changed", &"Gameplay_FrontArea", &"Gameplay_BossRoom")
|
||||
await process_frame
|
||||
_expect_bool(boss_status.visible, true, "BossStatus should show after boss-room entry")
|
||||
bus.emit_signal("flow_state_changed", &"Gameplay_BossRoom", &"Title")
|
||||
await process_frame
|
||||
_expect_bool(boss_status.visible, false, "BossStatus should hide outside boss-room gameplay")
|
||||
_expect_float(float(player_health_bar.max_value), 1000.0, "Player HealthBar should initialize from Player HealthComponent")
|
||||
_expect_float(float(boss_health_bar.max_value), 28000.0, "BossHealthBar max should follow Boss HealthComponent")
|
||||
_expect_float(float(boss_health_bar.value), 28000.0, "BossHealthBar value should follow Boss HealthComponent")
|
||||
boss.get_node("HealthComponent").call("apply_damage", 1200)
|
||||
await process_frame
|
||||
_expect_float(float(boss_health_bar.value), 26800.0, "BossHealthBar should follow Boss damage")
|
||||
_expect_float(float(player_health_bar.value), 1000.0, "Boss damage should not change Player HealthBar")
|
||||
player.get_node("HealthComponent").call("set_values", 640, 1000)
|
||||
await process_frame
|
||||
_expect_float(float(player_health_bar.max_value), 1000.0, "Player HealthBar should follow Player HealthComponent when bound by event")
|
||||
_expect_float(float(player_health_bar.value), 640.0, "Player HealthBar should follow Player damage")
|
||||
_expect_float(float(boss_health_bar.value), 26800.0, "Player damage should not change BossHealthBar")
|
||||
ui.free()
|
||||
player.free()
|
||||
boss.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _actor_with_health(actor_name: String, current: int, maximum: int) -> Node:
|
||||
var actor := Node2D.new()
|
||||
actor.name = actor_name
|
||||
var health_script: Script = load("res://scenes/components/health_component.gd")
|
||||
var health: Node = health_script.new()
|
||||
health.name = "HealthComponent"
|
||||
actor.add_child(health)
|
||||
health.set("maximum", maximum)
|
||||
health.set("current", current)
|
||||
return actor
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if absf(actual - expected) > 0.001:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_equal(actual: Variant, expected: Variant, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, str(expected), str(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 _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS phase9 boss health ui")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://devpxc3mhfk2a
|
||||
@@ -0,0 +1,220 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var resolver: Script = load("res://scenes/combat/action_resolver.gd")
|
||||
if resolver != null and resolver.has_method("clear_cache"):
|
||||
resolver.call("clear_cache")
|
||||
_check_player_action_set()
|
||||
_check_pattern_export()
|
||||
_check_charge_bindings()
|
||||
await _check_player_animation_coverage()
|
||||
_check_reserved_terms()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_player_action_set() -> void:
|
||||
var expected := {
|
||||
&"ground_attack_left_1": {"pattern": [&"A"], "damage": 1.0, "reward": 10.0, "cost": 0.0, "move_x": -1.0, "move_y": 0.0, "animation": &"atk_ground_1"},
|
||||
&"ground_attack_left_2": {"pattern": [&"A", &"A"], "damage": 1.2, "reward": 12.0, "cost": 0.0, "move_x": -1.1, "move_y": 0.0, "animation": &"atk_ground_2"},
|
||||
&"ground_attack_left_3": {"pattern": [&"A", &"A", &"A"], "damage": 1.5, "reward": 15.0, "cost": 0.0, "move_x": -1.2, "move_y": 0.0, "animation": &"atk_ground_3"},
|
||||
&"dash_slash_left": {"pattern": [&"A", &"SP"], "damage": 1.6, "reward": 0.0, "cost": 20.0, "move_x": -2.6, "move_y": 0.0, "animation": &"dash_slash", "tag": &"dash_through"},
|
||||
&"combo_finisher_left": {"pattern": [&"A", &"A", &"SP"], "damage": 2.2, "reward": 0.0, "cost": 30.0, "move_x": -1.3, "move_y": 0.0, "animation": &"combo_finisher"},
|
||||
&"ground_smash_left": {"pattern": [&"A", &"A", &"A", &"SP"], "damage": 2.8, "reward": 0.0, "cost": 40.0, "move_x": -0.8, "move_y": 0.0, "knock_y": 1.8, "animation": &"ground_smash", "tag": &"knockup"},
|
||||
&"ground_attack_right_1": {"pattern": [&"D"], "damage": 1.0, "reward": 10.0, "cost": 0.0, "move_x": 1.0, "move_y": 0.0, "animation": &"atk_ground_1"},
|
||||
&"ground_attack_right_2": {"pattern": [&"D", &"D"], "damage": 1.2, "reward": 12.0, "cost": 0.0, "move_x": 1.1, "move_y": 0.0, "animation": &"atk_ground_2"},
|
||||
&"ground_attack_right_3": {"pattern": [&"D", &"D", &"D"], "damage": 1.5, "reward": 15.0, "cost": 0.0, "move_x": 1.2, "move_y": 0.0, "animation": &"atk_ground_3"},
|
||||
&"dash_slash_right": {"pattern": [&"D", &"SP"], "damage": 1.6, "reward": 0.0, "cost": 20.0, "move_x": 2.6, "move_y": 0.0, "animation": &"dash_slash", "tag": &"dash_through"},
|
||||
&"combo_finisher_right": {"pattern": [&"D", &"D", &"SP"], "damage": 2.2, "reward": 0.0, "cost": 30.0, "move_x": 1.3, "move_y": 0.0, "animation": &"combo_finisher"},
|
||||
&"ground_smash_right": {"pattern": [&"D", &"D", &"D", &"SP"], "damage": 2.8, "reward": 0.0, "cost": 40.0, "move_x": 0.8, "move_y": 0.0, "knock_y": 1.8, "animation": &"ground_smash", "tag": &"knockup"},
|
||||
&"launcher_up": {"pattern": [&"W"], "damage": 1.2, "reward": 12.0, "cost": 0.0, "move_x": 0.6, "move_y": 1.8, "knock_y": 1.8, "animation": &"rising_slash"},
|
||||
&"air_attack_left": {"pattern": [&"A"], "damage": 1.3, "reward": 10.0, "cost": 0.0, "move_x": -5.8, "move_y": -1.6, "animation": &"atk_air", "ground": [&"Airborne"]},
|
||||
&"air_attack_right": {"pattern": [&"D"], "damage": 1.3, "reward": 10.0, "cost": 0.0, "move_x": 5.8, "move_y": -1.6, "animation": &"atk_air", "ground": [&"Airborne"]},
|
||||
&"plunging_strike": {"pattern": [&"S"], "damage": 1.8, "reward": 15.0, "cost": 0.0, "move_x": 0.0, "move_y": -2.6, "knock_y": 1.4, "animation": &"plunge_start", "ground": [&"Airborne"], "tag": &"knockup"},
|
||||
&"block_start": {"pattern": [&"S"], "damage": 0.0, "reward": 0.0, "cost": 0.0, "move_x": 0.0, "move_y": 0.0, "animation": &"block_start", "ground": [&"Grounded"], "hit_type": &"defense", "defense": &"parry"},
|
||||
&"jian_yu_left_lv1": {"pattern": [], "damage": 1.8, "reward": 0.0, "cost": 20.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"},
|
||||
&"jian_yu_left_lv2": {"pattern": [], "damage": 2.8, "reward": 0.0, "cost": 35.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"},
|
||||
&"jian_yu_left_lv3": {"pattern": [], "damage": 4.2, "reward": 0.0, "cost": 50.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"},
|
||||
&"jian_yu_right_lv1": {"pattern": [], "damage": 1.8, "reward": 0.0, "cost": 20.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"},
|
||||
&"jian_yu_right_lv2": {"pattern": [], "damage": 2.8, "reward": 0.0, "cost": 35.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"},
|
||||
&"jian_yu_right_lv3": {"pattern": [], "damage": 4.2, "reward": 0.0, "cost": 50.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"},
|
||||
&"zhan_bo_lv1": {"pattern": [], "damage": 1.2, "reward": 0.0, "cost": 15.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_wave_cast", "phase": [&"Charging"], "hit_type": &"projectile"},
|
||||
&"zhan_bo_lv2": {"pattern": [], "damage": 2.0, "reward": 0.0, "cost": 25.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_wave_cast", "phase": [&"Charging"], "hit_type": &"projectile"},
|
||||
&"zhan_bo_lv3": {"pattern": [], "damage": 3.2, "reward": 0.0, "cost": 40.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_wave_cast", "phase": [&"Charging"], "hit_type": &"projectile"},
|
||||
}
|
||||
for action_id: StringName in expected.keys():
|
||||
var action := _load_action(action_id)
|
||||
_expect(action != null, "%s should exist as phase-9 ActionData" % action_id)
|
||||
if action == null:
|
||||
continue
|
||||
var spec: Dictionary = expected[action_id]
|
||||
_expect_array(action.get("input_pattern"), spec.get("pattern", []), "%s input_pattern" % action_id)
|
||||
_expect_float(float(action.get("damage_mult")), float(spec["damage"]), "%s damage_mult" % action_id)
|
||||
_expect_float(float(action.get("base_reward")), float(spec["reward"]), "%s base_reward" % action_id)
|
||||
_expect_float(float(action.get("base_cost")), float(spec["cost"]), "%s base_cost" % action_id)
|
||||
_expect_float(float(action.get("move_mult_x")), float(spec["move_x"]), "%s move_mult_x" % action_id)
|
||||
_expect_float(float(action.get("move_mult_y")), float(spec["move_y"]), "%s move_mult_y" % action_id)
|
||||
if spec.has("knock_y"):
|
||||
_expect_float(float(action.get("knockback_mult_y")), float(spec["knock_y"]), "%s knockback_mult_y" % action_id)
|
||||
_expect_equal(StringName(str(action.get("animation"))), spec["animation"], "%s animation" % action_id)
|
||||
if spec.has("ground"):
|
||||
_expect_array(action.get("allowed_ground_states"), spec["ground"], "%s allowed_ground_states" % action_id)
|
||||
if spec.has("phase"):
|
||||
_expect_array(action.get("allowed_action_phases"), spec["phase"], "%s allowed_action_phases" % action_id)
|
||||
if spec.has("hit_type"):
|
||||
_expect_equal(StringName(str(action.get("hit_type"))), spec["hit_type"], "%s hit_type" % action_id)
|
||||
if spec.has("tag"):
|
||||
_expect((action.get("action_tags") as Array).has(spec["tag"]), "%s should include action tag %s" % [action_id, spec["tag"]])
|
||||
if spec.has("defense"):
|
||||
_expect((action.get("defense_tags") as Array).has(spec["defense"]), "%s should include defense tag %s" % [action_id, spec["defense"]])
|
||||
|
||||
|
||||
func _check_pattern_export() -> void:
|
||||
var exporter: Script = load("res://tools/export_action_patterns.gd")
|
||||
_expect(exporter != null, "ActionPatternExporter should load")
|
||||
if exporter == null:
|
||||
return
|
||||
var patterns: Dictionary = exporter.call("export_patterns")
|
||||
var expected := {
|
||||
"A": &"ground_attack_left_1",
|
||||
"AA": &"ground_attack_left_2",
|
||||
"AAA": &"ground_attack_left_3",
|
||||
"ASP": &"dash_slash_left",
|
||||
"AASP": &"combo_finisher_left",
|
||||
"AAASP": &"ground_smash_left",
|
||||
"D": &"ground_attack_right_1",
|
||||
"DD": &"ground_attack_right_2",
|
||||
"DDD": &"ground_attack_right_3",
|
||||
"DSP": &"dash_slash_right",
|
||||
"DDSP": &"combo_finisher_right",
|
||||
"DDDSP": &"ground_smash_right",
|
||||
"W": &"launcher_up",
|
||||
"S": &"block_start",
|
||||
}
|
||||
for key: String in expected:
|
||||
_expect_equal(patterns.get(key), expected[key], "Pattern %s" % key)
|
||||
for forbidden: String in ["AD", "DA", "ADSP", "DASP", "ASPSP", "SS"]:
|
||||
_expect(not patterns.has(forbidden), "Forbidden pattern %s should not exist" % forbidden)
|
||||
|
||||
|
||||
func _check_charge_bindings() -> void:
|
||||
var bindings := load("res://resources/player_action_bindings.tres")
|
||||
_expect(bindings != null, "PlayerSkillBindings resource should exist")
|
||||
if bindings == null:
|
||||
return
|
||||
var entries: Array = bindings.get("charge_entries")
|
||||
_expect_int(entries.size(), 3, "charge_entries size")
|
||||
_expect_charge_entry(entries, &"A", &"after_tap", &"on_release", [&"jian_yu_left_lv1", &"jian_yu_left_lv2", &"jian_yu_left_lv3"])
|
||||
_expect_charge_entry(entries, &"D", &"after_tap", &"on_release", [&"jian_yu_right_lv1", &"jian_yu_right_lv2", &"jian_yu_right_lv3"])
|
||||
_expect_charge_entry(entries, &"S", &"after_tap", &"on_secondary_key", [&"zhan_bo_lv1", &"zhan_bo_lv2", &"zhan_bo_lv3"])
|
||||
|
||||
|
||||
func _check_player_animation_coverage() -> void:
|
||||
var scene := load("res://scenes/characters/player.tscn") as PackedScene
|
||||
_expect(scene != null, "Player scene should load")
|
||||
if scene == null:
|
||||
return
|
||||
var player := scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
_expect(player.has_method("has_player_animation"), "Player should expose animation coverage checks")
|
||||
if player.has_method("has_player_animation"):
|
||||
for animation_name: StringName in [&"atk_ground_1", &"atk_ground_2", &"atk_ground_3", &"dash_slash", &"combo_finisher", &"ground_smash", &"rising_slash", &"block_start", &"blade_rain_cast", &"blade_wave_cast", &"atk_air", &"plunge_start", &"idle", &"turn"]:
|
||||
_expect(bool(player.call("has_player_animation", animation_name)), "Player should be able to play %s" % animation_name)
|
||||
player.free()
|
||||
|
||||
|
||||
func _check_reserved_terms() -> void:
|
||||
var required := ["TimePhaseManager", "TimeAnchorSystem"]
|
||||
var files := _gd_files("res://autoload") + _gd_files("res://resources") + _gd_files("res://scenes") + _gd_files("res://scripts")
|
||||
var corpus := ""
|
||||
for path: String in files:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
continue
|
||||
corpus += file.get_as_text()
|
||||
for needle: String in required:
|
||||
_expect(corpus.contains(needle), "implemented time systems should reference %s" % needle)
|
||||
|
||||
|
||||
func _expect_charge_entry(entries: Array, symbol: StringName, mode: StringName, trigger: StringName, ids: Array[StringName]) -> void:
|
||||
for entry: Variant in entries:
|
||||
if entry is Dictionary and StringName(str(entry.get("symbol", &""))) == symbol:
|
||||
_expect_equal(StringName(str(entry.get("entry_mode", &""))), mode, "%s charge entry mode" % symbol)
|
||||
_expect_equal(StringName(str(entry.get("cast_trigger", &""))), trigger, "%s charge trigger" % symbol)
|
||||
var cast_ids: Dictionary = entry.get("cast_action_ids", {})
|
||||
for index: int in range(ids.size()):
|
||||
_expect_equal(cast_ids.get(index + 1), ids[index], "%s charge level %d" % [symbol, index + 1])
|
||||
return
|
||||
failures.append("Missing charge entry %s" % symbol)
|
||||
|
||||
|
||||
func _load_action(action_id: StringName) -> Resource:
|
||||
var files := _tres_files("res://resources/actions")
|
||||
for path: String in files:
|
||||
var resource := load(path) as Resource
|
||||
if resource != null and StringName(str(resource.get("id"))) == action_id:
|
||||
return resource
|
||||
return null
|
||||
|
||||
|
||||
func _tres_files(path: String) -> Array[String]:
|
||||
return _files_with_extension(path, ".tres")
|
||||
|
||||
|
||||
func _gd_files(path: String) -> Array[String]:
|
||||
return _files_with_extension(path, ".gd")
|
||||
|
||||
|
||||
func _files_with_extension(path: String, extension: String) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return result
|
||||
for subdir: String in dir.get_directories():
|
||||
result.append_array(_files_with_extension("%s/%s" % [path, subdir], extension))
|
||||
for file_name: String in dir.get_files():
|
||||
if file_name.ends_with(extension):
|
||||
result.append("%s/%s" % [path, file_name])
|
||||
return result
|
||||
|
||||
|
||||
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_array(actual: Variant, expected: Array, label: String) -> void:
|
||||
if not actual is Array or actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if absf(actual - expected) > 0.001:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [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 phase9 content completion")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2f71vfa3qspv
|
||||
@@ -0,0 +1,249 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await _check_player_attack_buff_visual_scales_with_stacks()
|
||||
await _check_minion_strong_buff_visual_and_health_position()
|
||||
await _check_ranged_forms_projectiles_stay_horizontal()
|
||||
await _check_witch_visual_height_matches_little_girl()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_player_attack_buff_visual_scales_with_stacks() -> void:
|
||||
_expect(ResourceLoader.exists("res://scenes/components/attack_buff_visual.gd"), "AttackBuffVisual script should exist")
|
||||
var scene := load("res://scenes/characters/player.tscn") as PackedScene
|
||||
_expect(scene != null, "Player scene should load for attack buff visual")
|
||||
if scene == null:
|
||||
return
|
||||
var player := scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
var visual := player.get_node_or_null("Visual/AttackBuffVisual") as Node2D
|
||||
_expect(visual != null, "Player should have AttackBuffVisual under Visual")
|
||||
if visual != null:
|
||||
_expect_int(int(visual.call("frame_count")), 24, "AttackBuffVisual should load the original 24 buff frames")
|
||||
visual.call("set_attack_buff", 0, 7)
|
||||
_expect_bool(visual.visible, false, "AttackBuffVisual should hide at zero stacks")
|
||||
visual.call("set_attack_buff", 1, 7)
|
||||
await process_frame
|
||||
var sprite := visual.get_node_or_null("BuffEffect") as Sprite2D
|
||||
_expect(sprite != null and sprite.visible, "AttackBuffVisual should show a sprite when stacks are present")
|
||||
var one_stack_scale := sprite.scale.x if sprite != null else 0.0
|
||||
var one_stack_alpha := sprite.modulate.a if sprite != null else 0.0
|
||||
visual.call("set_attack_buff", 7, 7)
|
||||
await process_frame
|
||||
if sprite != null:
|
||||
_expect(sprite.scale.x > one_stack_scale, "AttackBuffVisual should get larger as buff stacks grow")
|
||||
_expect(sprite.modulate.a > one_stack_alpha, "AttackBuffVisual should get more opaque as buff stacks grow")
|
||||
player.free()
|
||||
|
||||
|
||||
func _check_minion_strong_buff_visual_and_health_position() -> void:
|
||||
_expect(ResourceLoader.exists("res://scenes/components/strong_buff_visual.gd"), "StrongBuffVisual script should exist")
|
||||
var manager := _ensure_time_phase_manager()
|
||||
var scene := load("res://scenes/enemies/minion.tscn") as PackedScene
|
||||
_expect(scene != null, "Minion scene should load for strong buff visual")
|
||||
if scene == null:
|
||||
return
|
||||
var minion := scene.instantiate() as Node2D
|
||||
minion.set("past_form_id", &"yuan_cheng_1")
|
||||
minion.set("future_form_id", &"yuan_cheng_2")
|
||||
minion.set("strength_profile", &"future_strong")
|
||||
root.add_child(minion)
|
||||
await process_frame
|
||||
var strong_visual := minion.get_node_or_null("Visual/StrongBuffVisual") as Node2D
|
||||
_expect(strong_visual != null, "Minion should have StrongBuffVisual under Visual")
|
||||
if strong_visual != null:
|
||||
_expect_int(int(strong_visual.call("frame_count")), 30, "StrongBuffVisual should load the original 30 strong-state frames")
|
||||
manager.call("reset_to_initial", &"past")
|
||||
await process_frame
|
||||
if strong_visual != null:
|
||||
_expect_bool(bool(strong_visual.call("is_active")), false, "Future-strong witch should hide strong effect in Past")
|
||||
var health_bar := minion.get_node_or_null("OverheadHealthBar") as Node2D
|
||||
_expect(health_bar != null, "Minion should have OverheadHealthBar")
|
||||
if health_bar != null:
|
||||
# 血条在物理帧刷新、动画 offset 在渲染帧变化;断言前强制同步一次,
|
||||
# 消除两者错开一个动画帧时的偏差误报。
|
||||
minion.call("_refresh_overhead_health_bar_position")
|
||||
_expect_float(health_bar.position.y, _expected_health_bar_y(minion), "OverheadHealthBar should sit above the current sprite")
|
||||
manager.call("set_time_phase", &"future", &"debug")
|
||||
await process_frame
|
||||
if strong_visual != null:
|
||||
_expect_bool(bool(strong_visual.call("is_active")), true, "Future-strong witch should show strong effect in Future")
|
||||
if health_bar != null:
|
||||
minion.call("_refresh_overhead_health_bar_position")
|
||||
_expect_float(health_bar.position.y, _expected_health_bar_y(minion), "OverheadHealthBar should move with the swapped sprite")
|
||||
minion.free()
|
||||
_cleanup_node(manager)
|
||||
|
||||
|
||||
func _check_ranged_forms_projectiles_stay_horizontal() -> void:
|
||||
var player_scene := load("res://scenes/characters/player.tscn") as PackedScene
|
||||
var minion_scene := load("res://scenes/enemies/minion.tscn") as PackedScene
|
||||
var girl_action := load("res://resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres") as Resource
|
||||
var witch_action := load("res://resources/actions/enemies/minion_yuan_cheng_2_attack.tres") as Resource
|
||||
_expect(player_scene != null, "Player scene should load for ranged projectile")
|
||||
_expect(minion_scene != null, "Minion scene should load for ranged projectile")
|
||||
_expect(girl_action != null, "Little girl projectile action should load")
|
||||
_expect(witch_action != null, "Witch projectile action should load")
|
||||
if player_scene == null or minion_scene == null or girl_action == null or witch_action == null:
|
||||
return
|
||||
var container := Node2D.new()
|
||||
container.name = "ActorsContainer"
|
||||
root.add_child(container)
|
||||
var player := player_scene.instantiate() as Node2D
|
||||
player.name = "Player"
|
||||
container.add_child(player)
|
||||
await _expect_form_horizontal_shot(container, minion_scene, player, &"yuan_cheng_1", girl_action, "Little girl")
|
||||
await _expect_form_horizontal_shot(container, minion_scene, player, &"yuan_cheng_2", witch_action, "Witch")
|
||||
container.free()
|
||||
|
||||
|
||||
func _expect_form_horizontal_shot(container: Node, minion_scene: PackedScene, player: Node2D, form_id: StringName, action: Resource, label: String) -> void:
|
||||
var shooter := minion_scene.instantiate() as Node2D
|
||||
shooter.set("past_form_id", form_id)
|
||||
shooter.set("future_form_id", form_id)
|
||||
shooter.set("strength_profile", &"past_strong")
|
||||
container.add_child(shooter)
|
||||
await process_frame
|
||||
player.global_position = Vector2(100.0, 560.0)
|
||||
shooter.global_position = Vector2(360.0, 560.0)
|
||||
shooter.call("look_at_target", player)
|
||||
var left_request: Dictionary = (shooter.call("projectile_requests_for_action", action) as Array)[0]
|
||||
var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO)
|
||||
_expect_vector(left_direction, Vector2.LEFT, "%s projectile should fire horizontally left" % label)
|
||||
player.global_position = Vector2(620.0, 560.0)
|
||||
shooter.call("look_at_target", player)
|
||||
var right_request: Dictionary = (shooter.call("projectile_requests_for_action", action) as Array)[0]
|
||||
var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO)
|
||||
_expect_vector(right_direction, Vector2.RIGHT, "%s projectile should fire horizontally right" % label)
|
||||
shooter.queue_free()
|
||||
|
||||
|
||||
func _check_witch_visual_height_matches_little_girl() -> void:
|
||||
var scene := load("res://scenes/enemies/minion.tscn") as PackedScene
|
||||
_expect(scene != null, "Minion scene should load for ranged visual height")
|
||||
if scene == null:
|
||||
return
|
||||
var girl := _spawn_single_form(scene, &"yuan_cheng_1")
|
||||
var witch := _spawn_single_form(scene, &"yuan_cheng_2")
|
||||
await process_frame
|
||||
var girl_height := _visible_sprite_height(girl)
|
||||
var witch_height := _visible_sprite_height(witch)
|
||||
_expect(girl_height > 0.0, "Little girl visible height should be measurable")
|
||||
_expect(witch_height > 0.0, "Witch visible height should be measurable")
|
||||
_expect_float(witch_height, girl_height, "Witch visible height should match little girl height after anchor normalization", 8.0)
|
||||
_expect_float((witch.call("projectile_spawn_position", null) as Vector2).y, (girl.call("projectile_spawn_position", null) as Vector2).y, "Witch projectile lane should match little girl lane", 1.0)
|
||||
girl.queue_free()
|
||||
witch.queue_free()
|
||||
|
||||
|
||||
func _spawn_single_form(scene: PackedScene, form_id: StringName) -> Node2D:
|
||||
var minion := scene.instantiate() as Node2D
|
||||
minion.set("past_form_id", form_id)
|
||||
minion.set("future_form_id", form_id)
|
||||
minion.set("strength_profile", &"past_strong")
|
||||
root.add_child(minion)
|
||||
return minion
|
||||
|
||||
|
||||
func _visible_sprite_height(minion: Node2D) -> float:
|
||||
var sprite := minion.get_node_or_null("Visual/CharacterSprite") as Sprite2D
|
||||
var visual := minion.get_node_or_null("Visual") as Node2D
|
||||
if sprite == null or visual == null or sprite.texture == null:
|
||||
return 0.0
|
||||
var bounds := _visible_sprite_bounds_local(sprite)
|
||||
if bounds.size == Vector2.ZERO:
|
||||
return 0.0
|
||||
return bounds.size.y * absf(visual.scale.y)
|
||||
|
||||
|
||||
func _visible_sprite_bounds_local(sprite: Sprite2D) -> Rect2:
|
||||
var image := sprite.texture.get_image()
|
||||
if image == null or image.is_empty():
|
||||
return Rect2()
|
||||
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 left := frame_width
|
||||
var right := -1
|
||||
var top := frame_height
|
||||
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:
|
||||
left = mini(left, x)
|
||||
right = maxi(right, x)
|
||||
top = mini(top, y)
|
||||
bottom = maxi(bottom, y)
|
||||
if right < left or bottom < top:
|
||||
return Rect2()
|
||||
return Rect2(Vector2(left, top), Vector2(right - left, bottom - top))
|
||||
|
||||
|
||||
func _expected_health_bar_y(minion: Node2D) -> float:
|
||||
var visual := minion.get_node_or_null("Visual") as Node2D
|
||||
var sprite := minion.get_node_or_null("Visual/CharacterSprite") as Sprite2D
|
||||
if visual == null or sprite == null:
|
||||
return -118.0
|
||||
return visual.position.y + sprite.offset.y * absf(visual.scale.y) - 10.0
|
||||
|
||||
|
||||
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 and is_instance_valid(node):
|
||||
node.queue_free()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
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, tolerance := 0.5) -> void:
|
||||
if absf(actual - expected) > tolerance:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [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 _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void:
|
||||
if actual.distance_to(expected) > 0.02:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS player2 fx and witch projectile")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3gcc6571kmxx
|
||||
@@ -0,0 +1,66 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var player := await _player_fixture()
|
||||
if player == null:
|
||||
_finish(null)
|
||||
return
|
||||
_set_beat_time(0.2)
|
||||
|
||||
var basic_action: Resource = load("res://resources/actions/ground_attack_left_1.tres")
|
||||
player.call("_play_action_animation", "atk_ground_1", basic_action)
|
||||
var animation_player := player.get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||
if animation_player == null:
|
||||
failures.append("Player should include AnimationPlayer")
|
||||
else:
|
||||
_expect_float(float(animation_player.speed_scale), 1.75, 0.01, "AnimationPlayer actions should speed up to fit shortened beat durations")
|
||||
|
||||
var smash_action: Resource = load("res://resources/actions/ground_smash_left.tres")
|
||||
player.call("_play_action_animation", "ground_smash", smash_action)
|
||||
player.call("_tick_manual_animation", 0.05)
|
||||
_expect_float(float(player.get("_manual_animation_time")), 0.1, 0.01, "Manual player action animation time should advance on the tempo-scaled action clock")
|
||||
|
||||
_finish(player)
|
||||
|
||||
|
||||
func _player_fixture() -> Node:
|
||||
var scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
if scene == null:
|
||||
failures.append("Player scene should load")
|
||||
return null
|
||||
var player := scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
return player
|
||||
|
||||
|
||||
func _set_beat_time(next_beat_time: float) -> void:
|
||||
var rhythm := root.get_node_or_null("RhythmManager")
|
||||
if rhythm == null:
|
||||
failures.append("RhythmManager autoload should exist")
|
||||
return
|
||||
rhythm.set("beat_time", next_beat_time)
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, tolerance: float, label: String) -> void:
|
||||
if absf(actual - expected) > tolerance:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish(player: Node) -> void:
|
||||
if player != null:
|
||||
player.free()
|
||||
if failures.is_empty():
|
||||
print("PASS player action animation tempo")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3ujromburlym
|
||||
@@ -0,0 +1,169 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var started: Array[StringName] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var fixture := await _fixture()
|
||||
if fixture.is_empty():
|
||||
_finish()
|
||||
return
|
||||
var input_component: Node = fixture["input"]
|
||||
var controller: Node = fixture["controller"]
|
||||
var combo: Node = fixture["combo"]
|
||||
controller.connect("action_started", _on_action_started)
|
||||
input_component.connect("intent_created", controller.submit_intent)
|
||||
|
||||
# 每次按键前把节奏钟钉到一个新的拍心:判定确定为 perfect(消除旧的
|
||||
# 墙钟 flake),且不与 new1 的"节奏点一次性消耗"规则冲突。
|
||||
_pin_beat(100)
|
||||
input_component.call("handle_input_event", _key_event(KEY_A))
|
||||
_expect_bool(started.is_empty(), false, "physical A should start an action")
|
||||
_expect_string(_last_started(), "ground_attack_left_1", "physical A should start left attack")
|
||||
controller.call("_reset_to_idle")
|
||||
_pin_beat(101)
|
||||
input_component.call("handle_input_event", _key_event(KEY_A))
|
||||
_expect_array(combo.call("get_slots"), [&"A", &"A"], "physical A A should fill two slots")
|
||||
_expect_string(_last_started(), "ground_attack_left_2", "physical A A should start left second attack")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
_pin_beat(102)
|
||||
input_component.call("handle_input_event", _key_event(KEY_D))
|
||||
controller.call("_reset_to_idle")
|
||||
_pin_beat(103)
|
||||
input_component.call("handle_input_event", _key_event(KEY_D))
|
||||
_expect_array(combo.call("get_slots"), [&"D", &"D"], "physical D D should fill two slots")
|
||||
_expect_string(_last_started(), "ground_attack_right_2", "physical D D should start right second attack")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
_pin_beat(104)
|
||||
input_component.call("handle_input_event", _key_event(KEY_W))
|
||||
_expect_string(_last_started(), "launcher_up", "physical W should start launcher")
|
||||
_expect_array(combo.call("get_slots"), [&"W"], "physical W should enter ComboWindow")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
combo.call("clear", &"test-reset")
|
||||
started.clear()
|
||||
_pin_beat(105)
|
||||
input_component.call("handle_input_event", _key_event(KEY_S))
|
||||
_expect_string(_last_started(), "block_start", "physical S should start temporary block animation")
|
||||
_expect_array(combo.call("get_slots"), [&"S"], "physical S should enter ComboWindow")
|
||||
|
||||
controller.call("_reset_to_idle")
|
||||
started.clear()
|
||||
_pin_beat(106)
|
||||
input_component.call("handle_input_event", _key_event(KEY_A))
|
||||
controller.call("_reset_to_idle")
|
||||
_pin_beat(107)
|
||||
input_component.call("handle_input_event", _key_event(KEY_S))
|
||||
_expect_string(_last_started(), "block_start", "physical S should still block after a previous basic input remains in ComboWindow")
|
||||
|
||||
fixture["root"].free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _last_started() -> String:
|
||||
if started.is_empty():
|
||||
return ""
|
||||
return str(started[started.size() - 1])
|
||||
|
||||
|
||||
## 把 RhythmManager 的时钟偏移钉到指定拍心:紧随其后的实时按键判定为 perfect。
|
||||
func _pin_beat(beat: int) -> void:
|
||||
var rhythm := root.get_node_or_null("RhythmManager")
|
||||
if rhythm == null:
|
||||
failures.append("RhythmManager autoload should exist for beat pinning")
|
||||
return
|
||||
rhythm.set("running", true)
|
||||
var beat_time := float(rhythm.get("beat_time"))
|
||||
var beat_offset := float(rhythm.get("beat_offset"))
|
||||
rhythm.set("_clock_offset", float(beat) * beat_time - beat_offset - Time.get_ticks_msec() / 1000.0)
|
||||
|
||||
|
||||
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _fixture() -> Dictionary:
|
||||
var input_script: Script = load("res://scenes/components/input_component.gd")
|
||||
var combo_script: Script = load("res://scenes/components/combo_window.gd")
|
||||
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
|
||||
var state_script: Script = load("res://scenes/components/state_machine.gd")
|
||||
var energy_script: Script = load("res://scenes/components/energy_component.gd")
|
||||
var controller_script: Script = load("res://scenes/components/action_controller.gd")
|
||||
if input_script == null or combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null:
|
||||
failures.append("Phase 3 input/action scripts should load")
|
||||
return {}
|
||||
var fixture_root := Node.new()
|
||||
root.add_child(fixture_root)
|
||||
var input_component: Node = input_script.new()
|
||||
input_component.name = "InputComponent"
|
||||
fixture_root.add_child(input_component)
|
||||
var combo: Node = combo_script.new()
|
||||
combo.name = "ComboWindow"
|
||||
fixture_root.add_child(combo)
|
||||
var resolver: Node = resolver_script.new()
|
||||
resolver.name = "ActionResolver"
|
||||
fixture_root.add_child(resolver)
|
||||
var state: Node = state_script.new()
|
||||
state.name = "StateMachine"
|
||||
fixture_root.add_child(state)
|
||||
var energy: Node = energy_script.new()
|
||||
energy.name = "EnergyComponent"
|
||||
fixture_root.add_child(energy)
|
||||
var controller: Node = controller_script.new()
|
||||
controller.name = "ActionController"
|
||||
controller.set("combo_window_path", NodePath("../ComboWindow"))
|
||||
controller.set("action_resolver_path", NodePath("../ActionResolver"))
|
||||
controller.set("state_machine_path", NodePath("../StateMachine"))
|
||||
fixture_root.add_child(controller)
|
||||
await process_frame
|
||||
energy.call("set_values", 99, 99)
|
||||
return {
|
||||
"root": fixture_root,
|
||||
"input": input_component,
|
||||
"combo": combo,
|
||||
"controller": controller,
|
||||
}
|
||||
|
||||
|
||||
func _key_event(key: Key) -> InputEventKey:
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = key
|
||||
event.physical_keycode = key
|
||||
event.pressed = true
|
||||
return event
|
||||
|
||||
|
||||
func _on_action_started(action: Resource, _intent) -> void:
|
||||
started.append(StringName(str(action.get("id"))))
|
||||
|
||||
|
||||
func _expect_array(actual: Array, expected: Array, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS player combo input")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqoowxp1n1jgi
|
||||
@@ -0,0 +1,112 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
for path: String in [
|
||||
"res://scenes/components/input_intent.gd",
|
||||
"res://scenes/components/input_component.gd",
|
||||
"res://scenes/components/combo_window.gd",
|
||||
"res://scenes/components/state_machine.gd",
|
||||
"res://scenes/components/energy_component.gd",
|
||||
"res://scenes/components/burst_component.gd",
|
||||
"res://scenes/components/charge_component.gd",
|
||||
"res://scenes/components/action_controller.gd",
|
||||
"res://scenes/combat/action_resolver.gd",
|
||||
"res://resources/player_action_bindings.gd",
|
||||
"res://resources/player_action_bindings.tres",
|
||||
]:
|
||||
_expect(load(path) != null, "%s should load" % path)
|
||||
|
||||
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
|
||||
if resolver_script != null:
|
||||
if resolver_script.has_method("clear_cache"):
|
||||
resolver_script.call("clear_cache")
|
||||
var expected := {
|
||||
"A": &"ground_attack_left_1",
|
||||
"AA": &"ground_attack_left_2",
|
||||
"D": &"ground_attack_right_1",
|
||||
"DD": &"ground_attack_right_2",
|
||||
"W": &"launcher_up",
|
||||
"S": &"block_start",
|
||||
"SS": &"block_start",
|
||||
}
|
||||
for pattern: String in expected:
|
||||
var action: Resource = resolver_script.call("resolve_pattern", pattern)
|
||||
_expect(action != null, "ActionResolver should resolve %s" % pattern)
|
||||
if action != null:
|
||||
_expect(StringName(str(action.get("id"))) == expected[pattern], "%s should resolve to %s" % [pattern, expected[pattern]])
|
||||
_check_s_basic_fallback_keeps_exact_combos(resolver_script)
|
||||
|
||||
var gd_files := _all_gd_files(["res://scenes/components", "res://scenes/combat", "res://resources"])
|
||||
for path: String in gd_files:
|
||||
var text := _read_text(path)
|
||||
_expect(not text.contains("legacy_skill_"), "%s should not hardcode old skill ids" % path)
|
||||
_expect(not text.contains("legacy_time_phase"), "%s should not keep legacy time phase placeholders" % path)
|
||||
_expect(not text.contains("legacy_time_anchor"), "%s should not keep legacy time anchor placeholders" % path)
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_s_basic_fallback_keeps_exact_combos(resolver_script: Script) -> void:
|
||||
resolver_script.call("reload", "res://tests/fixtures/actions")
|
||||
var s_action: Resource = resolver_script.call("resolve_pattern", "S")
|
||||
var combo_action: Resource = resolver_script.call("resolve_pattern", "SD")
|
||||
var fallback_action: Resource = resolver_script.call("resolve_pattern", "AS")
|
||||
_expect(s_action != null, "S fixture action should resolve")
|
||||
if s_action != null:
|
||||
_expect(StringName(str(s_action.get("id"))) == &"test_block_start", "S alone should resolve to the immediate block action")
|
||||
_expect(combo_action != null, "S follow-up combo fixture should resolve")
|
||||
if combo_action != null:
|
||||
_expect(StringName(str(combo_action.get("id"))) == &"test_s_followup_combo", "S follow-up combo should beat trailing S fallback")
|
||||
_expect(fallback_action != null, "Unmatched pattern ending in S should still fall back to immediate block")
|
||||
if fallback_action != null:
|
||||
_expect(StringName(str(fallback_action.get("id"))) == &"test_block_start", "Only unmatched S suffixes should use block fallback")
|
||||
resolver_script.call("clear_cache")
|
||||
|
||||
|
||||
func _all_gd_files(dirs: Array[String]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for dir_path: String in dirs:
|
||||
_collect_gd_files(dir_path, result)
|
||||
return result
|
||||
|
||||
|
||||
func _collect_gd_files(dir_path: String, result: Array[String]) -> void:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return
|
||||
for subdir: String in dir.get_directories():
|
||||
_collect_gd_files("%s/%s" % [dir_path, subdir], result)
|
||||
for file_name: String in dir.get_files():
|
||||
if file_name.ends_with(".gd"):
|
||||
result.append("%s/%s" % [dir_path, file_name])
|
||||
|
||||
|
||||
func _read_text(path: String) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("Could not read %s" % path)
|
||||
return ""
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
return text
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS rhythm action architecture")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b27nn83sjvaug
|
||||
@@ -0,0 +1,119 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_pin_rhythm_clock()
|
||||
await _check_time_anchor_movers_are_visibly_larger()
|
||||
await _check_center_hit_feedback_is_visibly_larger()
|
||||
await _check_e_toggles_move_list()
|
||||
_finish()
|
||||
|
||||
|
||||
func _pin_rhythm_clock() -> void:
|
||||
# RhythmManager 自启动的墙钟会让 _upcoming_beat_index 随启动耗时漂移
|
||||
# (启动超过一个拍长 0.46s 时 upcoming 越过第 1 拍,特殊拍断言偶发失败)。
|
||||
# 钉停时钟,让 upcoming 走确定性的 _last_beat_index+1 兜底路径。
|
||||
var rhythm := root.get_node_or_null("RhythmManager")
|
||||
if rhythm != null and rhythm.has_method("stop_manager"):
|
||||
rhythm.call("stop_manager")
|
||||
|
||||
|
||||
func _check_time_anchor_movers_are_visibly_larger() -> void:
|
||||
var scene := load("res://scenes/ui/rhythm_track.tscn") as PackedScene
|
||||
_expect(scene != null, "RhythmTrack scene should load")
|
||||
if scene == null:
|
||||
return
|
||||
var track := scene.instantiate() as Control
|
||||
track.size = Vector2(1152.0, 648.0)
|
||||
root.add_child(track)
|
||||
await process_frame
|
||||
track.size = Vector2(1152.0, 648.0)
|
||||
track.call("_apply_responsive_layout")
|
||||
var left_mover := track.get_node_or_null("LeftMover") as Control
|
||||
_expect(left_mover != null, "RhythmTrack should have LeftMover")
|
||||
if left_mover == null:
|
||||
track.queue_free()
|
||||
return
|
||||
var normal_size := left_mover.size
|
||||
track.call("_on_time_anchor_scheduled", {"beat": 1})
|
||||
track.call("_on_beat_ticked", 0)
|
||||
await process_frame
|
||||
var special_size := left_mover.size
|
||||
_expect(special_size.x >= normal_size.x * 1.5, "Special rhythm point should be much wider than normal point")
|
||||
_expect(special_size.y >= normal_size.y * 1.5, "Special rhythm point should be much taller than normal point")
|
||||
track.queue_free()
|
||||
|
||||
|
||||
func _check_center_hit_feedback_is_visibly_larger() -> void:
|
||||
var scene := load("res://scenes/ui/rhythm_track.tscn") as PackedScene
|
||||
if scene == null:
|
||||
return
|
||||
var track := scene.instantiate() as Control
|
||||
track.size = Vector2(1152.0, 648.0)
|
||||
root.add_child(track)
|
||||
await process_frame
|
||||
track.size = Vector2(1152.0, 648.0)
|
||||
track.call("_apply_responsive_layout")
|
||||
var center_flash := track.get_node_or_null("CenterFlash") as Control
|
||||
_expect(center_flash != null, "RhythmTrack should have CenterFlash")
|
||||
if center_flash == null:
|
||||
track.queue_free()
|
||||
return
|
||||
var normal_size := center_flash.size
|
||||
track.call("_show_center_hit", true)
|
||||
await process_frame
|
||||
var hit_size := center_flash.size
|
||||
_expect(hit_size.x >= normal_size.x * 1.45, "Center hit effect should be much wider than idle flash")
|
||||
_expect(hit_size.y >= normal_size.y * 1.45, "Center hit effect should be much taller than idle flash")
|
||||
track.queue_free()
|
||||
|
||||
|
||||
func _check_e_toggles_move_list() -> void:
|
||||
var scene := load("res://scenes/ui/main_ui.tscn") as PackedScene
|
||||
_expect(scene != null, "MainUI scene should load")
|
||||
if scene == null:
|
||||
return
|
||||
var ui := scene.instantiate() as Control
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
_expect(ui.has_method("toggle_move_list"), "MainUI should expose toggle_move_list")
|
||||
_expect(InputMap.has_action("toggle_move_list"), "E key action should be registered as toggle_move_list")
|
||||
var panel := ui.get_node_or_null("MoveListPanel") as Control
|
||||
_expect(panel != null, "MainUI should build a MoveListPanel")
|
||||
if panel != null and ui.has_method("toggle_move_list"):
|
||||
_expect_bool(panel.visible, false, "Move list should start hidden")
|
||||
ui.call("toggle_move_list")
|
||||
_expect_bool(panel.visible, true, "toggle_move_list should show the move list")
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = KEY_E
|
||||
event.physical_keycode = KEY_E
|
||||
event.pressed = true
|
||||
ui.call("_unhandled_input", event)
|
||||
_expect_bool(panel.visible, false, "Pressing E should hide the visible move list")
|
||||
ui.queue_free()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS rhythm feedback and move list")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3ekprrba3j6t
|
||||
@@ -0,0 +1,84 @@
|
||||
extends SceneTree
|
||||
|
||||
## 节奏点从两侧向中心的移动必须由音乐时钟连续驱动:
|
||||
## 进度 = RhythmManager 当前拍内进度,不允许 beat_flash 期间瞬移到中心。
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1152, 648)
|
||||
var rhythm := root.get_node_or_null("RhythmManager")
|
||||
_expect(rhythm != null, "RhythmManager autoload should exist")
|
||||
if rhythm == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var scene: PackedScene = load("res://scenes/ui/rhythm_track.tscn")
|
||||
_expect(scene != null, "rhythm_track.tscn should load")
|
||||
if scene == null:
|
||||
_finish()
|
||||
return
|
||||
var track := scene.instantiate() as Control
|
||||
root.add_child(track)
|
||||
await process_frame
|
||||
|
||||
var beat_time := float(rhythm.get("beat_time"))
|
||||
var left_mover := track.get_node("LeftMover") as Control
|
||||
var right_mover := track.get_node("RightMover") as Control
|
||||
var left_start := track.get("left_mover_start") as Vector2
|
||||
var right_start := track.get("right_mover_start") as Vector2
|
||||
var center := track.get("track_center") as Vector2
|
||||
|
||||
# 把音乐时钟冻结在拍内 37% 处,节奏点必须停在 37% 的插值位置。
|
||||
rhythm.call("pause_clock")
|
||||
rhythm.set("_paused_song_position", beat_time * 0.37)
|
||||
# beat_flash 拉满:旧实现会在此瞬移到中心,新实现必须继续跟随时钟。
|
||||
track.set("beat_flash", 1.0)
|
||||
track.call("_update_movers")
|
||||
_expect_mover_at(left_mover, left_start.lerp(center, 0.37), "left mover follows the musical clock at 37%")
|
||||
_expect_mover_at(right_mover, right_start.lerp(center, 0.37), "right mover follows the musical clock at 37%")
|
||||
|
||||
# 时钟推进到 82%:节奏点连续前进,不回跳。
|
||||
rhythm.set("_paused_song_position", beat_time * 0.82)
|
||||
track.call("_update_movers")
|
||||
_expect_mover_at(left_mover, left_start.lerp(center, 0.82), "left mover keeps gliding at 82%")
|
||||
_expect_mover_at(right_mover, right_start.lerp(center, 0.82), "right mover keeps gliding at 82%")
|
||||
|
||||
# 拍点整数时刻(进度 0):节奏点回到两侧起点,开始下一拍收束。
|
||||
rhythm.set("_paused_song_position", beat_time * 1.0)
|
||||
track.call("_update_movers")
|
||||
_expect_mover_at(left_mover, left_start, "left mover restarts from the edge on the beat")
|
||||
_expect_mover_at(right_mover, right_start, "right mover restarts from the edge on the beat")
|
||||
|
||||
rhythm.call("resume_clock")
|
||||
track.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _expect_mover_at(mover: Control, expected_center: Vector2, label: String) -> void:
|
||||
var actual := Vector2(
|
||||
(mover.offset_left + mover.offset_right) * 0.5,
|
||||
(mover.offset_top + mover.offset_bottom) * 0.5
|
||||
)
|
||||
if actual.distance_to(expected_center) > 0.75:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected_center, actual])
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS rhythm mover smoothness")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b800h1hmixxyv
|
||||
@@ -0,0 +1,81 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_check_rhythm_autoload()
|
||||
_check_rhythm_manager_contract()
|
||||
_check_debug_scene()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check_rhythm_autoload() -> void:
|
||||
_expect(ProjectSettings.has_setting("autoload/EventBus"), "EventBus autoload should be configured")
|
||||
_expect(ProjectSettings.has_setting("autoload/RhythmManager"), "RhythmManager autoload should be configured")
|
||||
if ProjectSettings.has_setting("autoload/RhythmManager"):
|
||||
_expect(str(ProjectSettings.get_setting("autoload/RhythmManager")).contains("res://autoload/rhythm_manager.gd"), "RhythmManager autoload path")
|
||||
|
||||
|
||||
func _check_rhythm_manager_contract() -> void:
|
||||
var rhythm_script: Script = load("res://autoload/rhythm_manager.gd")
|
||||
_expect(rhythm_script != null, "RhythmManager script should load")
|
||||
if rhythm_script == null:
|
||||
return
|
||||
|
||||
var rhythm: Node = rhythm_script.new()
|
||||
_expect_float(float(rhythm.get("bpm")), 129.2, "RhythmManager default BPM should match ev_past1.mp3")
|
||||
_expect(_file_contains("res://autoload/rhythm_manager.gd", "res://assets/audio/ev_past1.mp3"), "RhythmManager should load ev_past1.mp3 by default")
|
||||
_expect(rhythm.has_method("input_to_song_time"), "RhythmManager should expose input_to_song_time")
|
||||
_expect(rhythm.has_method("judge"), "RhythmManager should expose judge")
|
||||
var rating: Dictionary = rhythm.call("judge", 0.0)
|
||||
_expect(rating.has("nearest_beat"), "judge result should include nearest_beat")
|
||||
_expect(rating.has("diff"), "judge result should include diff")
|
||||
rhythm.free()
|
||||
|
||||
|
||||
func _check_debug_scene() -> void:
|
||||
var debug_scene: PackedScene = load("res://scenes/main/clock_debug.tscn")
|
||||
_expect(debug_scene != null, "clock_debug scene should load")
|
||||
if debug_scene == null:
|
||||
return
|
||||
|
||||
var debug_root := debug_scene.instantiate()
|
||||
_expect(debug_root.get_script() != null, "clock_debug root script should load")
|
||||
_expect(debug_root.has_method("_print_clock_sample"), "clock_debug should expose manual sample printing")
|
||||
debug_root.free()
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _file_contains(path: String, needle: String) -> bool:
|
||||
if not FileAccess.file_exists(path):
|
||||
return false
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return false
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
return text.contains(needle)
|
||||
|
||||
|
||||
func _expect_float(actual: float, expected: float, label: String) -> void:
|
||||
if absf(actual - expected) > 0.001:
|
||||
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS rhythm scene")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvcxr7cxyewo8
|
||||
@@ -0,0 +1,129 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var scene: PackedScene = load("res://scenes/ui/main_ui.tscn")
|
||||
if scene == null:
|
||||
push_error("Could not load main_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui := scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
for node_path: String in [
|
||||
"RhythmTrack",
|
||||
"RhythmTrack/JudgementLabel",
|
||||
"RhythmTrack/HudReadoutRow/ComboCountLabel",
|
||||
"RhythmTrack/HudReadoutRow/AttackBuffValueLabel",
|
||||
"RhythmTrack/BuffPipRow",
|
||||
"RhythmTrack/JudgementArt",
|
||||
"RhythmTrack/ChartMarkerContainer",
|
||||
"ComboWindow",
|
||||
"StatusBars/HealthBar",
|
||||
"StatusBars/EnergyBar",
|
||||
"StatusBars/ChargeBar",
|
||||
]:
|
||||
if not ui.has_node(node_path):
|
||||
failures.append("Missing rhythm UI node: %s" % node_path)
|
||||
|
||||
var bus := _event_bus()
|
||||
bus.emit_signal("judgement_made", &"perfect", 0.0, 12)
|
||||
bus.emit_signal("streak_changed", 20)
|
||||
bus.emit_signal("attack_buff_changed", 5, 7)
|
||||
await process_frame
|
||||
var label := ui.get_node("RhythmTrack/JudgementLabel") as Label
|
||||
if not label.text.contains("PERFECT"):
|
||||
failures.append("RhythmTrack should render EventBus judgement text")
|
||||
var judgement_art := ui.get_node_or_null("RhythmTrack/JudgementArt") as TextureRect
|
||||
var combo_label := ui.get_node_or_null("RhythmTrack/HudReadoutRow/ComboCountLabel") as Label
|
||||
var buff_label := ui.get_node_or_null("RhythmTrack/HudReadoutRow/AttackBuffValueLabel") as Label
|
||||
var buff_row := ui.get_node_or_null("RhythmTrack/BuffPipRow")
|
||||
if judgement_art != null:
|
||||
_expect_bool(judgement_art.visible, true, "RhythmTrack should show judgement art after a judgement")
|
||||
_expect_string(_texture_file(judgement_art.texture), "perfect.png", "Perfect judgement should use the r/perfect art")
|
||||
if combo_label != null:
|
||||
_expect_string(combo_label.text, "combo 20", "RhythmTrack should render combo count under the track")
|
||||
if buff_label != null:
|
||||
_expect_string(buff_label.text, "ATK x 500%", "RhythmTrack should render buff stacks as ATK multiplier text")
|
||||
if buff_row != null:
|
||||
_expect_buff_pips(buff_row, 5)
|
||||
bus.emit_signal("judgement_made", &"miss", 0.0, 13)
|
||||
await process_frame
|
||||
if judgement_art != null:
|
||||
_expect_string(_texture_file(judgement_art.texture), "miss.png", "Miss judgement should use the r/miss art")
|
||||
|
||||
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
|
||||
if player_scene == null:
|
||||
failures.append("Player scene should load for EventBus skill facts")
|
||||
else:
|
||||
var player := player_scene.instantiate()
|
||||
root.add_child(player)
|
||||
await process_frame
|
||||
player.call("submit_combo_input", "A", "perfect")
|
||||
await process_frame
|
||||
var skill_label := ui.get_node("ComboSkillLabel") as Label
|
||||
_expect_bool(skill_label.visible, false, "MainUI should keep ComboSkillLabel hidden in the reference HUD")
|
||||
_expect_string(skill_label.text, "", "Hidden ComboSkillLabel should not render skill_executed facts")
|
||||
player.free()
|
||||
|
||||
ui.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _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 _expect_buff_pips(row: Node, lit_count: int) -> void:
|
||||
_expect_int(row.get_child_count(), 7, "BuffPipRow should always render seven buff slots")
|
||||
for index: int in range(row.get_child_count()):
|
||||
var pip := row.get_child(index) as TextureRect
|
||||
if pip == null:
|
||||
failures.append("BuffPipRow child %d should be a TextureRect" % index)
|
||||
continue
|
||||
var expected_file := "buff_on.png" if index < lit_count else "buff_off.png"
|
||||
_expect_string(_texture_file(pip.texture), expected_file, "Buff pip %d should use %s" % [index, expected_file])
|
||||
|
||||
|
||||
func _texture_file(texture: Texture2D) -> String:
|
||||
if texture == null:
|
||||
return ""
|
||||
return texture.resource_path.get_file()
|
||||
|
||||
|
||||
func _expect_int(actual: int, expected: int, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [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_string(actual: String, expected: String, label: String) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("PASS rhythm ui")
|
||||
quit(0)
|
||||
else:
|
||||
for failure: String in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user