42 KiB
Input Intent and Action Phase Fix Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix duplicate/missed inputs, silent action failures, repeated Space-chain failures, and missing startup/active/recovery timing by inserting a timestamped intent and action adjudication layer between input collection and action execution.
Architecture: Keep the existing RhythmManager, ComboWindow, ActionResolver, ActionExecutor, MotionExecutor, and CombatManager architecture. Add one central ActionController as the only component allowed to turn input intent into actions. Do not add a traditional fighting-game InputBuffer; store already-judged intents only so rhythm timing remains tied to the original key press.
Tech Stack: Godot 4.6 GDScript, SceneTree headless tests, .tres ActionData resources, existing EventBus/Autoload services.
Current Problem Summary
The current implementation has some input cleanup already:
- Raw
KEY_*matching has been removed fromPlayer. player_spacehas been removed fromInputMap.- Echo key events are filtered in
InputComponent. - Combo/action data now lives in
ActionDataresources.
The remaining architecture problem is that Player still receives a key event and immediately calls submit_combo_input(), ActionResolver, and ActionExecutor. This keeps the old "press key -> do action now" behavior alive inside a componentized shell.
The fix is to make this the only legal path:
InputComponent
-> InputIntent(symbol, rhythm_action, event_type, timestamp_ms)
-> ActionController.submit_intent(intent)
-> RhythmManager.judge(intent.timestamp_ms)
-> ComboWindow record/clear
-> ActionResolver with full context
-> ActionController phase/state adjudication
-> ActionExecutor / MotionExecutor / Player visual hooks
Important project rule: ComboWindow must not auto-fill Ø on empty beats. Ø remains an explicit Miss placeholder only.
File Responsibility Map
Create:
-
scenes/components/input_intent.gd- A small
RefCountedvalue object for one physical input event. - Carries symbol, rhythm action, press/release type, timestamp, and rhythm judgement.
- A small
-
scenes/components/action_controller.gd- The central action adjudicator.
- Owns action phase:
idle,startup,active,recovery. - Receives
InputIntent, judges rhythm, updatesComboWindow, resolves actions, executes allowed actions, stores at most one already-judged pending intent for cancel-window consumption. - Builds full
ActionResolvercontext for Space priority rules.
-
tests/test_input_component_intents.gd- Covers echo filtering, exact action matching, timestamped intent emission, and no duplicate intent for one event.
-
tests/test_action_controller_flow.gd- Covers Miss clearing, repeated
S+SP, energy failure cleanup, action phase lockout, and cancel-window consumption.
- Covers Miss clearing, repeated
Modify:
-
resources/action_data.gd- Add phase fields in beats:
startup_beats,active_beats,recovery_beats,cancel_from.
- Add phase fields in beats:
-
resources/actions/*.tres- Add explicit phase defaults.
- Mark Space/projectile chain resources with
can_chainwhere needed.
-
scenes/components/input_component.gd- Replace
combo_pressed/combo_releasedas Player-facing API withintent_created(intent). - Keep old signals only temporarily during migration tests if needed, then remove Player usage.
- Replace
-
scenes/characters/player.gd- Stop calling
submit_combo_input()directly from_on_combo_pressed. - Delegate all discrete input to
ActionController. - Keep visual orchestration hooks: animation playback, projectile request, charge UI events.
- Stop calling
-
scenes/characters/player.tscn- Add
ActionControllernode. - Wire exported NodePaths to existing components.
- Add
-
scenes/components/state_machine.gd- Remain a thin state name provider owned by
ActionControllerfor this plan. Stop treating it as the action phase authority.
- Remain a thin state name provider owned by
-
tests/test_player_combo_input.gd- Update tests to assert Player routes input through
ActionController.
- Update tests to assert Player routes input through
-
tests/test_rhythm_action_architecture.gd- Add architecture assertions that Player no longer performs direct action resolution/execution from input callbacks.
Design Decisions
No Traditional InputBuffer
Do not implement "press early, consume after delay, then judge at consumption time." That would change rhythm timing.
Allowed:
- Capture original
timestamp_msat input event time. - Judge immediately or before any possible delay using that timestamp.
- Store the already-judged intent briefly if action phase cannot consume it yet.
- Consume it during a cancel window without changing its judgement.
Not allowed:
- Re-judging a buffered input at consumption time.
- Moving a player's early input onto a different beat.
One Pending Intent, Not a Queue
The first implementation should store at most one pending intent.
Reason:
- This is enough to remove common "pressed during recovery and got ignored" feel.
- It avoids building a general-purpose combo buffer that fights the rhythm design.
- It keeps testing simple.
Pending intent replacement rule:
- If a new valid intent arrives while one is pending, replace the older pending intent.
- Emit
intent_rejected(old_intent, &"replaced")for observability.
Action Phases Use Beats
Add these fields to ActionData:
@export var startup_beats := 0.25
@export var active_beats := 0.25
@export var recovery_beats := 0.5
@export var cancel_from := 0.5
Interpretation:
startup_beats: action has begun visually, but hitbox/projectile has not fired.active_beats: action is allowed to apply damage/spawn projectile.recovery_beats: action is cooling down.cancel_from: fraction of recovery after which one pending intent may be consumed.
Default total is one beat. Individual action resources can override these values in the same resource-normalization pass.
ActionController Owns Adjudication, Player Owns Presentation
ActionController decides if an action starts.
Player responds to signals:
action_started(action, judgement)action_active_started(action)action_active_finished(action)action_finished(action)action_rejected(intent, reason)
Player may play animation, request projectile, or update visual state in response. Player must not resolve actions directly.
Task 1: Input Intent Value Object
Files:
-
Create:
scenes/components/input_intent.gd -
Create:
tests/test_input_component_intents.gd -
Step 1: Write the failing test
Create tests/test_input_component_intents.gd:
extends SceneTree
var failures: Array[String] = []
var intents: Array = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var component: Node = load("res://scenes/components/input_component.gd").new()
root.add_child(component)
await process_frame
if not component.has_signal("intent_created"):
failures.append("InputComponent should expose intent_created")
else:
component.connect("intent_created", _on_intent_created)
var normal := InputEventKey.new()
normal.pressed = true
normal.physical_keycode = KEY_A
var handled := component.call("handle_input_event", normal)
_expect_bool(handled, true, "A press should be handled")
_expect_int(intents.size(), 1, "A press should emit one intent")
if intents.size() == 1:
_expect_string(str(intents[0].get("symbol")), "A", "intent symbol")
_expect_string(str(intents[0].get("rhythm_action")), "a", "intent rhythm action")
_expect_string(str(intents[0].get("event_type")), "pressed", "intent event type")
_expect_bool(float(intents[0].get("timestamp_ms")) > 0.0, true, "intent timestamp")
var echo := InputEventKey.new()
echo.pressed = true
echo.echo = true
echo.physical_keycode = KEY_A
handled = component.call("handle_input_event", echo)
_expect_bool(handled, false, "echo press should not be handled")
_expect_int(intents.size(), 1, "echo press should not emit another intent")
var release := InputEventKey.new()
release.pressed = false
release.physical_keycode = KEY_A
handled = component.call("handle_input_event", release)
_expect_bool(handled, true, "A release should be handled")
_expect_int(intents.size(), 2, "A release should emit one release intent")
if intents.size() == 2:
_expect_string(str(intents[1].get("event_type")), "released", "release event type")
component.free()
_finish()
func _on_intent_created(intent) -> void:
intents.append(intent)
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)
- Step 2: Run the test to verify it fails
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_input_component_intents.gd
Expected: FAIL because intent_created and InputIntent do not exist yet.
- Step 3: Create
InputIntent
Create scenes/components/input_intent.gd:
class_name InputIntent
extends RefCounted
var symbol: StringName
var rhythm_action: StringName
var event_type: StringName
var timestamp_ms := 0.0
var judgement: Dictionary = {}
static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> InputIntent:
var intent := InputIntent.new()
intent.symbol = next_symbol
intent.rhythm_action = next_rhythm_action
intent.event_type = next_event_type
intent.timestamp_ms = next_timestamp_ms
return intent
func is_pressed() -> bool:
return event_type == &"pressed"
func is_released() -> bool:
return event_type == &"released"
func with_judgement(next_judgement: Dictionary) -> InputIntent:
var copy := InputIntent.create(symbol, rhythm_action, event_type, timestamp_ms)
copy.judgement = next_judgement.duplicate()
return copy
- Step 4: Update
InputComponentto emit intents
Modify scenes/components/input_component.gd:
class_name InputComponent
extends Node
signal intent_created(intent: InputIntent)
signal combo_pressed(symbol: StringName, rhythm_action: StringName)
signal combo_released(symbol: StringName)
const COMBO_ACTIONS: Dictionary = {
&"combo_w": [&"W", &"w"],
&"combo_a": [&"A", &"a"],
&"combo_d": [&"D", &"d"],
&"combo_s": [&"S", &"s"],
&"combo_space": [&"SP", &"space"],
}
const COMBO_ACTION_ORDER: Array[StringName] = [
&"combo_w",
&"combo_a",
&"combo_d",
&"combo_s",
&"combo_space",
]
var _suppressed_movement: Dictionary = {
&"move_left": false,
&"move_right": false,
}
func handle_input_event(event: InputEvent) -> bool:
var key_event := event as InputEventKey
if key_event != null and key_event.echo:
return false
for action_name: StringName in COMBO_ACTION_ORDER:
if event.is_action_pressed(action_name, false, true):
var data: Array = COMBO_ACTIONS[action_name]
var intent := InputIntent.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec()))
intent_created.emit(intent)
combo_pressed.emit(data[0], data[1])
return true
if event.is_action_released(action_name, true):
var data: Array = COMBO_ACTIONS[action_name]
var intent := InputIntent.create(data[0], data[1], &"released", float(Time.get_ticks_msec()))
intent_created.emit(intent)
combo_released.emit(data[0])
return true
return false
func set_direction_suppressed(symbol: StringName, suppressed: bool) -> void:
if symbol == &"A":
_suppressed_movement[&"move_left"] = suppressed
elif symbol == &"D":
_suppressed_movement[&"move_right"] = suppressed
func get_horizontal_axis() -> float:
var axis := 0.0
if Input.is_action_pressed(&"move_left") and not bool(_suppressed_movement.get(&"move_left", false)):
axis -= 1.0
if Input.is_action_pressed(&"move_right") and not bool(_suppressed_movement.get(&"move_right", false)):
axis += 1.0
return axis
This step keeps old signals temporarily so existing Player tests can still pass before Task 5 migrates Player.
- Step 5: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_input_component_intents.gd
Expected: PASS.
Task 2: ActionData Phase Fields
Files:
-
Modify:
resources/action_data.gd -
Modify:
resources/actions/*.tres -
Modify:
tests/test_rhythm_action_architecture.gd -
Step 1: Add failing architecture assertions
In tests/test_rhythm_action_architecture.gd, extend _check_action_data() so it expects these fields:
for property_name: String in [
"id",
"display_name",
"input_pattern",
"required_state",
"base_cost",
"damage_mult",
"move_mult_x",
"move_mult_y",
"action_beats",
"hit_type",
"range",
"target_type",
"armor_level",
"clear_window",
"can_chain",
"special",
"startup_beats",
"active_beats",
"recovery_beats",
"cancel_from",
]:
_expect(_has_property(action, property_name), "ActionData should expose %s" % property_name)
- Step 2: Run test to verify it fails
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
Expected: FAIL because the phase fields are absent.
- Step 3: Add fields to
ActionData
Modify resources/action_data.gd:
@export var startup_beats := 0.25
@export var active_beats := 0.25
@export var recovery_beats := 0.5
@export_range(0.0, 1.0, 0.05) var cancel_from := 0.5
Keep the existing cancel_window compatibility field until all code and tests stop relying on it. Do not remove existing fields in this task.
- Step 4: Normalize resource defaults
For each file in resources/actions/*.tres, add explicit values when absent:
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
For projectile chain resources:
can_chain = true
Apply that to:
-
resources/actions/skill_s_projectile_1.tres -
resources/actions/skill_s_projectile_2.tres -
resources/actions/skill_s_projectile_3.tres -
Step 5: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
Expected: PASS.
Task 3: ActionController Skeleton and Miss Cleanup
Files:
-
Create:
scenes/components/action_controller.gd -
Modify:
scenes/characters/player.tscn -
Create:
tests/test_action_controller_flow.gd -
Step 1: Write the failing test
Create tests/test_action_controller_flow.gd:
extends SceneTree
var failures: Array[String] = []
var rejected: Array[StringName] = []
var started: Array[StringName] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/characters/player.tscn")
var player := scene.instantiate()
root.add_child(player)
await process_frame
var controller := player.get_node_or_null("ActionController")
_expect_bool(controller != null, true, "Player should have ActionController")
if controller == null:
player.free()
_finish()
return
controller.connect("action_rejected", _on_action_rejected)
controller.connect("action_started", _on_action_started)
player.get_node("ComboWindow").record(&"S")
var miss := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
miss.judgement = {"label": "miss", "diff": 0.0}
controller.call("submit_intent", miss)
_expect_array(player.call("get_combo_slots"), [], "Miss intent should clear ComboWindow immediately")
_expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss intent rejection reason")
player.free()
_finish()
func _on_action_rejected(_intent: InputIntent, reason: StringName) -> void:
rejected.append(reason)
func _on_action_started(action: Resource, _intent: InputIntent) -> void:
started.append(StringName(str(action.get("id"))))
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 _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 action controller flow")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)
- Step 2: Run test to verify it fails
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
Expected: FAIL because ActionController does not exist on Player.
- Step 3: Create
ActionControllerskeleton
Create scenes/components/action_controller.gd:
class_name ActionController
extends Node
signal action_started(action: Resource, intent: InputIntent)
signal action_active_started(action: Resource)
signal action_active_finished(action: Resource)
signal action_finished(action: Resource)
signal action_rejected(intent: InputIntent, reason: StringName)
enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY }
@export var combo_window_path: NodePath
@export var action_resolver_path: NodePath
@export var action_executor_path: NodePath
@export var state_machine_path: NodePath
@export var burst_component_path: NodePath
@onready var combo_window: Node = get_node_or_null(combo_window_path)
@onready var action_resolver: Node = get_node_or_null(action_resolver_path)
@onready var action_executor: Node = get_node_or_null(action_executor_path)
@onready var state_machine: Node = get_node_or_null(state_machine_path)
@onready var burst_component: Node = get_node_or_null(burst_component_path)
var phase := Phase.IDLE
var current_action: Resource
var current_intent: InputIntent
var pending_intent: InputIntent
var phase_elapsed := 0.0
var phase_duration := 0.0
func submit_intent(intent: InputIntent) -> void:
if intent == null:
return
var judged_intent := _ensure_judged(intent)
if judged_intent.is_released():
action_rejected.emit(judged_intent, &"release_not_action")
return
if _judgement_label(judged_intent) == &"miss":
_record_miss_and_clear(judged_intent)
action_rejected.emit(judged_intent, &"miss")
return
if phase == Phase.IDLE:
_consume_intent(judged_intent)
return
if _can_cancel_now():
_consume_intent(judged_intent)
return
_store_pending_intent(judged_intent)
func _physics_process(delta: float) -> void:
if phase == Phase.IDLE:
return
phase_elapsed += delta
if phase_elapsed < phase_duration:
return
match phase:
Phase.STARTUP:
_enter_phase(Phase.ACTIVE)
action_active_started.emit(current_action)
Phase.ACTIVE:
action_active_finished.emit(current_action)
_enter_phase(Phase.RECOVERY)
Phase.RECOVERY:
var finished_action := current_action
_reset_to_idle()
action_finished.emit(finished_action)
if pending_intent != null:
var next_intent := pending_intent
pending_intent = null
submit_intent(next_intent)
func _consume_intent(intent: InputIntent) -> void:
if combo_window == null or action_resolver == null or action_executor == null:
action_rejected.emit(intent, &"missing_component")
return
_record_intent_symbol(intent)
var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context())
if action == null:
action_rejected.emit(intent, &"unresolved")
return
if not action_executor.execute(action, StringName(str(intent.judgement.get("label", "perfect"))), burst_component):
combo_window.flush_pending_clear()
combo_window.clear(&"action_failed")
action_rejected.emit(intent, &"execution_failed")
return
current_action = action
current_intent = intent
_enter_phase(Phase.STARTUP)
action_started.emit(action, intent)
if bool(action.get("clear_window")):
combo_window.queue_clear(StringName("skill:%s" % action.get("id")))
func _record_intent_symbol(intent: InputIntent) -> void:
if combo_window.has_pending_clear():
if str(combo_window.pending_clear_reason).begins_with("skill:"):
combo_window.cancel_pending_clear()
else:
combo_window.flush_pending_clear()
combo_window.record(intent.symbol)
func _record_miss_and_clear(intent: InputIntent) -> void:
if combo_window != null:
combo_window.record(&"Ø")
combo_window.flush_pending_clear()
func _store_pending_intent(intent: InputIntent) -> void:
if pending_intent != null:
action_rejected.emit(pending_intent, &"replaced")
pending_intent = intent
func _enter_phase(next_phase: Phase) -> void:
phase = next_phase
phase_elapsed = 0.0
phase_duration = _phase_duration_seconds(next_phase)
func _phase_duration_seconds(next_phase: Phase) -> float:
if current_action == null:
return 0.0
var beat_time := _beat_time()
match next_phase:
Phase.STARTUP:
return maxf(0.01, float(current_action.get("startup_beats")) * beat_time)
Phase.ACTIVE:
return maxf(0.01, float(current_action.get("active_beats")) * beat_time)
Phase.RECOVERY:
return maxf(0.01, float(current_action.get("recovery_beats")) * beat_time)
return 0.0
func _can_cancel_now() -> bool:
if phase != Phase.RECOVERY or current_action == null:
return false
var duration := maxf(0.01, phase_duration)
var progress := clampf(phase_elapsed / duration, 0.0, 1.0)
return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0)
func _reset_to_idle() -> void:
phase = Phase.IDLE
current_action = null
current_intent = null
phase_elapsed = 0.0
phase_duration = 0.0
func _ensure_judged(intent: InputIntent) -> InputIntent:
if not intent.judgement.is_empty():
return intent
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
if rhythm != null and rhythm.has_method("judge"):
return intent.with_judgement(rhythm.call("judge", intent.timestamp_ms))
return intent.with_judgement({"label": "perfect", "diff": 0.0, "abs_diff": 0.0})
func _judgement_label(intent: InputIntent) -> StringName:
return StringName(str(intent.judgement.get("label", "miss")))
func _resolver_context() -> Dictionary:
var state := &"any"
if state_machine != null and state_machine.has_method("get_current_state_name"):
state = StringName(str(state_machine.call("get_current_state_name")))
return {
"state": state,
"blade_chain_active": _blade_chain_active(),
}
func _blade_chain_active() -> bool:
if current_action == null:
return false
return bool(current_action.get("can_chain"))
func _beat_time() -> float:
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
if rhythm != null:
return float(rhythm.get("beat_time"))
return 0.5
- Step 4: Add
ActionControllerto Player scene
Add a node to scenes/characters/player.tscn under Player:
[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="18_action_controller"]
[node name="ActionController" type="Node" parent="."]
script = ExtResource("18_action_controller")
combo_window_path = NodePath("../ComboWindow")
action_resolver_path = NodePath("../ActionResolver")
action_executor_path = NodePath("../ActionExecutor")
state_machine_path = NodePath("../StateMachine")
burst_component_path = NodePath("../BurstComponent")
Use the next available ext_resource id if 18_action_controller is already taken.
- Step 5: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
Expected: PASS.
Task 4: Route Player Input Through ActionController
Files:
-
Modify:
scenes/characters/player.gd -
Modify:
tests/test_player_combo_input.gd -
Modify:
tests/test_rhythm_action_architecture.gd -
Step 1: Write failing architecture assertion
In tests/test_rhythm_action_architecture.gd, add checks to _check_no_legacy_runtime_architecture():
_expect(player_source.contains("intent_created.connect"), "Player should connect InputComponent intents to ActionController")
_expect(not player_source.contains("action_resolver.resolve_window"), "Player should not resolve actions directly")
_expect(not player_source.contains("action_executor.execute"), "Player should not execute actions directly")
- Step 2: Run test to verify it fails
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
Expected: FAIL because Player still calls resolver/executor directly.
- Step 3: Modify Player references and signal wiring
In scenes/characters/player.gd, add:
@onready var action_controller: Node = $ActionController
In _ready(), replace direct combo press wiring with intent wiring:
input_component.intent_created.connect(_on_input_intent_created)
action_controller.action_started.connect(_on_action_started)
action_controller.action_active_started.connect(_on_action_active_started)
action_controller.action_finished.connect(_on_action_finished)
action_controller.action_rejected.connect(_on_action_rejected)
Leave release handling for charge in place only until Task 7 moves charge mode into controller context.
- Step 4: Add Player handlers
Add to scenes/characters/player.gd:
func _on_input_intent_created(intent: InputIntent) -> void:
if intent.symbol == &"A":
heading = Vector2.LEFT
elif intent.symbol == &"D":
heading = Vector2.RIGHT
action_controller.submit_intent(intent)
if intent.is_pressed() and (intent.symbol == &"A" or intent.symbol == &"D"):
input_component.set_direction_suppressed(intent.symbol, true)
if intent.is_released() and (intent.symbol == &"A" or intent.symbol == &"D"):
input_component.set_direction_suppressed(intent.symbol, false)
_finish_charge_hold(intent.symbol)
func _on_action_started(action: Resource, intent: InputIntent) -> void:
current_energy = energy_component.current
last_requested_skill_id = str(action.get("id"))
current_skill_animation = str(action.get("animation"))
skill_requested.emit(last_requested_skill_id)
_event_bus().emit_signal("skill_executed", action, StringName(str(intent.judgement.get("label", "perfect"))))
_play_skill_animation(current_skill_animation, _skill_displacement_direction(action), action)
func _on_action_active_started(action: Resource) -> void:
if bool(action.get("spawns_projectile")):
_request_projectile(action)
func _on_action_finished(_action: Resource) -> void:
pass
func _on_action_rejected(_intent: InputIntent, _reason: StringName) -> void:
pass
- Step 5: Replace
submit_combo_input()implementation for tests
Keep submit_combo_input(symbol, forced_rating) as a test helper, but route it through ActionController:
func submit_combo_input(symbol: String, forced_rating := "") -> String:
var data := _symbol_to_intent_data(symbol)
if data.is_empty():
return ""
var intent := InputIntent.create(data["symbol"], data["rhythm_action"], &"pressed", float(Time.get_ticks_msec()))
if not forced_rating.is_empty():
intent.judgement = _rating_result(StringName(forced_rating), 0.0)
action_controller.submit_intent(intent)
return last_requested_skill_id
func _symbol_to_intent_data(symbol: String) -> Dictionary:
match symbol:
"W":
return {"symbol": &"W", "rhythm_action": &"w"}
"A":
return {"symbol": &"A", "rhythm_action": &"a"}
"D":
return {"symbol": &"D", "rhythm_action": &"d"}
"S":
return {"symbol": &"S", "rhythm_action": &"s"}
"SP":
return {"symbol": &"SP", "rhythm_action": &"space"}
return {}
- Step 6: Remove direct input execution
Remove or stop using:
_on_combo_pressed_record_combo_symbol_record_rated_combo_input_record_combo_input_execute_combo_skill
Keep helper methods used by presentation:
-
_play_skill_animation -
_skill_displacement_direction -
_request_projectile -
_rating_result -
Step 7: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
Expected: both PASS.
Task 5: Phase Lockout and Cancel Window
Files:
-
Modify:
tests/test_action_controller_flow.gd -
Modify:
scenes/components/action_controller.gd -
Step 1: Add failing test for no immediate reentry
Append to tests/test_action_controller_flow.gd inside _run() after the Miss check:
started.clear()
player.get_node("EnergyComponent").set_current(10)
var first := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
first.judgement = {"label": "perfect", "diff": 0.0}
controller.call("submit_intent", first)
var second := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
second.judgement = {"label": "perfect", "diff": 0.0}
controller.call("submit_intent", second)
_expect_int(started.size(), 1, "Second input during startup should not start a second action immediately")
Add helper:
func _expect_int(actual: int, expected: int, label: String) -> void:
if actual != expected:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
- Step 2: Run test to verify it fails or exposes current behavior
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
Expected before controller phase is complete: FAIL if two actions start immediately, or PASS if Task 3 already blocks this path.
- Step 3: Add test for cancel-window consumption
Append:
await create_timer(0.45).timeout
_expect_bool(started.size() >= 2, true, "Pending input should start after phase completes or cancel window opens")
This uses the default one-beat timing at 120 BPM. If project BPM differs in test setup, instantiate a test RhythmManager with beat_time = 0.5.
- Step 4: Implement pending intent consumption during recovery
Update ActionController._physics_process(delta) so recovery can consume pending intent as soon as cancel window opens:
if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now():
var next_intent := pending_intent
pending_intent = null
_reset_to_idle()
submit_intent(next_intent)
return
Place this before the phase_elapsed < phase_duration return.
- Step 5: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
Expected: PASS.
Task 6: Repeated S+SP and Blade Chain Restart
Files:
-
Modify:
tests/test_action_controller_flow.gd -
Modify:
scenes/components/action_controller.gd -
Modify:
resources/actions/skill_s_projectile_1.tres -
Modify:
resources/actions/skill_s_projectile_2.tres -
Modify:
resources/actions/skill_s_projectile_3.tres -
Step 1: Add failing test for repeated
S+SP
Append to tests/test_action_controller_flow.gd:
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
started.clear()
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
await process_frame
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
await process_frame
_expect_bool(started.has(&"skill_s_projectile_1"), true, "First S+SP should start projectile")
_expect_bool(started.count(&"skill_s_projectile_1") >= 2, true, "Second S+SP should restart projectile chain")
Add helper:
func _perfect_intent(symbol: StringName, rhythm_action: StringName) -> InputIntent:
var intent := InputIntent.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec()))
intent.judgement = {"label": "perfect", "diff": 0.0}
return intent
- Step 2: Run test to verify it fails
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
Expected: FAIL because [S, SP, S, SP] does not resolve.
- Step 3: Implement chain restart rule
Add to ActionController.submit_intent() before normal phase handling:
if _should_restart_blade_chain(judged_intent):
combo_window.clear(&"blade_chain_restart")
Add helper:
func _should_restart_blade_chain(intent: InputIntent) -> bool:
if combo_window == null:
return false
if intent.symbol != &"S":
return false
var pattern := combo_window.get_contiguous_pattern()
return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP"
This preserves S+SP+SP chaining while allowing a new S to begin a fresh chain.
- Step 4: Verify chain resources
Ensure projectile chain resources contain:
can_chain = true
clear_window = false
for:
-
skill_s_projectile_1.tres -
skill_s_projectile_2.tres -
skill_s_projectile_3.tres -
Step 5: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_combo_window.gd
Expected: both PASS.
Task 7: Space Priority Context
Files:
-
Modify:
scenes/components/action_controller.gd -
Modify:
tests/test_action_controller_flow.gd -
Create: action resources only if the referenced action id does not exist.
-
Step 1: Add resolver-context test
In tests/test_action_controller_flow.gd, add a direct context assertion:
var context: Dictionary = controller.call("_resolver_context")
_expect_bool(context.has("state"), true, "Resolver context should include state")
_expect_bool(context.has("blade_chain_active"), true, "Resolver context should include blade_chain_active")
_expect_bool(context.has("counter_ready"), true, "Resolver context should include counter_ready")
_expect_bool(context.has("burst_action_id"), true, "Resolver context should include burst_action_id")
- Step 2: Run test to verify it fails
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
Expected: FAIL because context is incomplete.
- Step 3: Expand
_resolver_context()
Use this implementation:
func _resolver_context() -> Dictionary:
var state := &"any"
if state_machine != null and state_machine.has_method("get_current_state_name"):
state = StringName(str(state_machine.call("get_current_state_name")))
return {
"state": state,
"charge_release_action_id": _charge_release_action_id(),
"burst_action_id": _burst_action_id(),
"counter_action_id": _counter_action_id(),
"counter_ready": _counter_ready(),
"blade_chain_action_id": _blade_chain_action_id(),
"blade_chain_active": _blade_chain_active(),
}
func _charge_release_action_id() -> StringName:
return &""
func _burst_action_id() -> StringName:
if burst_component != null and bool(burst_component.get("burst_ready")):
return &"skill_burst_activate"
return &""
func _counter_action_id() -> StringName:
return &"skill_s_counter_projectile"
func _counter_ready() -> bool:
return false
func _blade_chain_action_id() -> StringName:
if _blade_chain_active():
return &"skill_s_projectile_2"
return &""
The empty charge release id is intentional until Task 8 moves charge release into the same intent path.
- Step 4: Verify missing action ids
Run:
rg -n 'skill_burst_activate|skill_s_counter_projectile' resources/actions scenes tests
Expected: If no resources exist for those ids yet, tests must not expect them to resolve. They are context keys reserved for future states.
- Step 5: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
Expected: PASS.
Task 8: Player Presentation Cleanup
Files:
-
Modify:
scenes/characters/player.gd -
Modify:
tests/test_rhythm_action_architecture.gd -
Step 1: Add source hygiene assertions
In tests/test_rhythm_action_architecture.gd, add:
_expect(not player_source.contains("func _record_combo_symbol"), "Player should not own combo symbol adjudication")
_expect(not player_source.contains("func _execute_combo_skill"), "Player should not own action execution")
_expect(player_source.contains("func _on_action_started"), "Player should present actions from ActionController")
- Step 2: Run test to verify it fails
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
Expected: FAIL while old Player methods remain.
- Step 3: Remove direct adjudication methods from Player
Delete these methods after tests have moved to ActionController:
_record_combo_symbol_record_rated_combo_input_record_combo_input_execute_combo_skill_resolve_full_window_fallback_is_projectile_space_chain
Keep:
submit_combo_input_symbol_to_intent_data_play_skill_animation_skill_displacement_direction_request_projectile
submit_combo_input remains only as a test/helper API and must route through ActionController.
- Step 4: Verify
Run:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd
Expected: both PASS.
Task 9: Full Regression and Residue Scan
Files:
-
Modify tests only if assertions need to follow the new architecture.
-
Step 1: Run full test suite with strict error scan
Run:
for test in tests/*.gd; do
output=$(/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s "res://$test" 2>&1)
rc=$?
printf '## %s exit=%s\n' "$test" "$rc"
printf '%s\n' "$output" | rg 'PASS|FAIL|SCRIPT ERROR|ERROR:' || true
if [ "$rc" -ne 0 ] || printf '%s\n' "$output" | rg -q 'SCRIPT ERROR|ERROR:'; then
printf '%s\n' "$output"
exit 1
fi
done
Expected: every test exits 0, every test prints PASS, and no SCRIPT ERROR or ERROR: appears.
- Step 2: Scan for direct input/action coupling
Run:
rg -n 'KEY_|is_action_just_pressed|action_resolver\.resolve_window|action_executor\.execute|func _record_combo_symbol|func _execute_combo_skill' scenes/characters/player.gd scenes/components scenes/combat tests || true
Expected:
-
No
KEY_in Player production code. -
No
is_action_just_pressedfor combo actions. -
No direct
action_resolver.resolve_windoworaction_executor.executeinside Player. -
action_resolver.resolve_windowandaction_executor.executemay exist insideActionController. -
Step 3: Scan for forbidden empty-beat auto
Ø
Run:
rg -n 'beat_ticked\.connect|_on_beat|input_this_beat|record\(&"Ø"\)|append\(&"Ø"\)' scenes/components/combo_window.gd scenes/characters/player.gd scenes/components/action_controller.gd tests || true
Expected:
-
No beat-empty auto
ØinComboWindow. -
Øappears only in explicit Miss tests or explicit Miss handling. -
Step 4: Manual playtest checklist
Run the game from the editor and verify:
- Pressing
Aonce produces one combo input and one action. - Holding
Adoes not repeat through echo. - Pressing
Aduring startup/active does not interrupt the current attack immediately. - Pressing
Aduring recovery is consumed only when the cancel window opens. - Miss clears the combo window immediately.
- With enough energy,
S+SP,S+SP+SP, andS+SP+SP+SPwork. - After one
S+SP, pressingS+SPagain starts a new projectile chain. - With insufficient energy,
S+SPfails visibly and does not leave a dirty combo window.
Acceptance Criteria
The repair is complete when all of these are true:
InputComponentemits timestampedInputIntentobjects and remains the only discrete input collector.- Player no longer directly resolves or executes actions from input callbacks.
ActionControlleris the only component that judges rhythm, records combo symbols, resolves action patterns, and starts action execution.- Miss clears the combo window immediately.
- Echo key events do not produce input intents.
- One physical input event produces at most one intent.
- Action startup/active/recovery phases exist and block immediate reentry.
- One already-judged pending intent can be consumed during recovery cancel window.
S+SPcan chain into additional Space projectiles and can restart cleanly when the player pressesSagain.- Full test suite passes with no Godot
SCRIPT ERRORorERROR:.
Known Deferred Work
These are intentionally outside this repair plan:
- Full BuffContainer implementation.
- EnemyBrain beat scheduling.
- Final burst/counter/charge action resource set.
- Visual tuning for every individual animation.
The plan adds context hooks for those systems without requiring all of them to be finished in the same change set.