1320 lines
42 KiB
Markdown
1320 lines
42 KiB
Markdown
# 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 from `Player`.
|
|
- `player_space` has been removed from `InputMap`.
|
|
- Echo key events are filtered in `InputComponent`.
|
|
- Combo/action data now lives in `ActionData` resources.
|
|
|
|
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:
|
|
|
|
```text
|
|
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 `RefCounted` value object for one physical input event.
|
|
- Carries symbol, rhythm action, press/release type, timestamp, and rhythm judgement.
|
|
|
|
- `scenes/components/action_controller.gd`
|
|
- The central action adjudicator.
|
|
- Owns action phase: `idle`, `startup`, `active`, `recovery`.
|
|
- Receives `InputIntent`, judges rhythm, updates `ComboWindow`, resolves actions, executes allowed actions, stores at most one already-judged pending intent for cancel-window consumption.
|
|
- Builds full `ActionResolver` context 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.
|
|
|
|
Modify:
|
|
|
|
- `resources/action_data.gd`
|
|
- Add phase fields in beats: `startup_beats`, `active_beats`, `recovery_beats`, `cancel_from`.
|
|
|
|
- `resources/actions/*.tres`
|
|
- Add explicit phase defaults.
|
|
- Mark Space/projectile chain resources with `can_chain` where needed.
|
|
|
|
- `scenes/components/input_component.gd`
|
|
- Replace `combo_pressed` / `combo_released` as Player-facing API with `intent_created(intent)`.
|
|
- Keep old signals only temporarily during migration tests if needed, then remove Player usage.
|
|
|
|
- `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.
|
|
|
|
- `scenes/characters/player.tscn`
|
|
- Add `ActionController` node.
|
|
- Wire exported NodePaths to existing components.
|
|
|
|
- `scenes/components/state_machine.gd`
|
|
- Remain a thin state name provider owned by `ActionController` for this plan. Stop treating it as the action phase authority.
|
|
|
|
- `tests/test_player_combo_input.gd`
|
|
- Update tests to assert Player routes input through `ActionController`.
|
|
|
|
- `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_ms` at 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`:
|
|
|
|
```gdscript
|
|
@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`:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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`:
|
|
|
|
```gdscript
|
|
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 `InputComponent` to emit intents**
|
|
|
|
Modify `scenes/components/input_component.gd`:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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`:
|
|
|
|
```gdscript
|
|
@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:
|
|
|
|
```ini
|
|
startup_beats = 0.25
|
|
active_beats = 0.25
|
|
recovery_beats = 0.5
|
|
cancel_from = 0.5
|
|
```
|
|
|
|
For projectile chain resources:
|
|
|
|
```ini
|
|
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:
|
|
|
|
```bash
|
|
/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`:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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 `ActionController` skeleton**
|
|
|
|
Create `scenes/components/action_controller.gd`:
|
|
|
|
```gdscript
|
|
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 `ActionController` to Player scene**
|
|
|
|
Add a node to `scenes/characters/player.tscn` under `Player`:
|
|
|
|
```ini
|
|
[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:
|
|
|
|
```bash
|
|
/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()`:
|
|
|
|
```gdscript
|
|
_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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
@onready var action_controller: Node = $ActionController
|
|
```
|
|
|
|
In `_ready()`, replace direct combo press wiring with intent wiring:
|
|
|
|
```gdscript
|
|
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`:
|
|
|
|
```gdscript
|
|
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`:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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`:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
if _should_restart_blade_chain(judged_intent):
|
|
combo_window.clear(&"blade_chain_restart")
|
|
```
|
|
|
|
Add helper:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```ini
|
|
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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```gdscript
|
|
_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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```bash
|
|
/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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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_pressed` for combo actions.
|
|
- No direct `action_resolver.resolve_window` or `action_executor.execute` inside Player.
|
|
- `action_resolver.resolve_window` and `action_executor.execute` may exist inside `ActionController`.
|
|
|
|
- [ ] **Step 3: Scan for forbidden empty-beat auto `Ø`**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
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 `Ø` in `ComboWindow`.
|
|
- `Ø` appears only in explicit Miss tests or explicit Miss handling.
|
|
|
|
- [ ] **Step 4: Manual playtest checklist**
|
|
|
|
Run the game from the editor and verify:
|
|
|
|
- Pressing `A` once produces one combo input and one action.
|
|
- Holding `A` does not repeat through echo.
|
|
- Pressing `A` during startup/active does not interrupt the current attack immediately.
|
|
- Pressing `A` during recovery is consumed only when the cancel window opens.
|
|
- Miss clears the combo window immediately.
|
|
- With enough energy, `S+SP`, `S+SP+SP`, and `S+SP+SP+SP` work.
|
|
- After one `S+SP`, pressing `S+SP` again starts a new projectile chain.
|
|
- With insufficient energy, `S+SP` fails visibly and does not leave a dirty combo window.
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
The repair is complete when all of these are true:
|
|
|
|
- `InputComponent` emits timestamped `InputIntent` objects and remains the only discrete input collector.
|
|
- Player no longer directly resolves or executes actions from input callbacks.
|
|
- `ActionController` is 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+SP` can chain into additional Space projectiles and can restart cleanly when the player presses `S` again.
|
|
- Full test suite passes with no Godot `SCRIPT ERROR` or `ERROR:`.
|
|
|
|
## 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.
|