Files
2026-07-06 22:59:47 -07:00

230 lines
9.7 KiB
GDScript

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)