Refactor rhythm action architecture

This commit is contained in:
wxm
2026-07-02 09:47:52 -07:00
parent fc941cf08d
commit e62ed84518
124 changed files with 7516 additions and 2440 deletions

View File

@@ -0,0 +1,992 @@
# Chart Layer 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:** Add a thin, testable Chart Layer that turns beat-indexed battle data into upcoming and triggered chart events for UI, enemies, hazards, and future boss patterns.
**Architecture:** Keep `RhythmManager` as the only music clock and rhythm judgement service. Add chart resources plus a scene-owned `ChartRunner` that reads `RhythmManager.song_position()`, emits chart events once, and mirrors those events through `EventBus` for UI/world listeners. Do not make `ChartRunner` an autoload; a chart belongs to a song, room, or encounter, not to the whole app.
**Tech Stack:** Godot 4.6, GDScript, Resource `.tres` data model, existing `EventBus`, existing `RhythmManager`, existing headless Godot test scripts.
---
## Current Project Reading
The project already has a good player-side rhythm action foundation:
- `RhythmManager` is an autoload clock and judgement provider: `autoload/rhythm_manager.gd`.
- `EventBus` broadcasts rhythm, judgement, skill, projectile, damage, player resource, and combo UI events: `autoload/event_bus.gd`.
- `InputComponent` creates timestamped `InputIntent` objects: `scenes/components/input_component.gd`.
- `ActionController` owns intent judgement, combo recording, action phase timing, pending intent replacement, and startup/active/recovery flow: `scenes/components/action_controller.gd`.
- `ComboWindow` keeps explicit inputs and explicit `Ø` Miss placeholders; it does not auto-fill empty beats: `scenes/components/combo_window.gd`.
- `ActionData` resources and `ActionResolver` already make player actions data-driven: `resources/action_data.gd`, `resources/actions/*.tres`, `scenes/combat/action_resolver.gd`.
- `RhythmTrack` currently reacts to `beat_ticked` and `action_judged`, but it has no knowledge of future chart events: `scenes/ui/rhythm_track.gd`.
- `ActorsContainer` currently manages spawned projectiles only; there is not yet a real enemy container or enemy behavior layer: `scenes/stage/actors_container.gd`.
The Chart Layer should therefore avoid touching the player input/action pipeline. It should add a parallel world-timeline pipeline.
## The Actual Problem Chart Layer Solves
Right now the project has a clock, but not a battle timeline.
Current flow:
```text
RhythmManager
-> beat_ticked(beat_index)
-> UI / BurstComponent / future enemies listen directly
```
This works while the game is only a player combo sandbox. It becomes fragile when enemies, hazards, boss patterns, camera hits, accents, and tutorial prompts need to happen on specific beats.
Without Chart Layer, each future system will likely write its own beat math:
```gdscript
func _on_beat_ticked(beat_index: int) -> void:
if beat_index % 4 == 2:
show_warning()
elif beat_index % 4 == 3:
attack()
```
That creates four problems:
1. **Battle timing is scattered.** Enemy scripts, UI scripts, stage scripts, and boss scripts all become tiny schedule owners.
2. **UI cannot reliably preview future danger.** A `beat_ticked` signal tells listeners what just happened, not what will happen one beat from now.
3. **Warning and attack can drift apart.** If UI and enemy each compute their own beat offsets, one can show a warning while another opens hitboxes on a different beat.
4. **Encounter tuning becomes code editing.** Changing "attack at beat 16" to "warning at 15.5, attack at 16, recover at 17" should be data work, not enemy-script surgery.
Chart Layer centralizes that schedule.
New flow:
```text
RhythmManager.song_position()
-> ChartRunner.update_for_song_time(song_time)
-> BeatChart / ChartTrack / ChartEvent
-> chart_event_upcoming(event, time_to_event)
-> chart_event_triggered(event)
-> EventBus mirrors both signals
-> RhythmTrack / EnemyBeatPlanner / Hazard / Camera listen
```
Player input remains independent:
```text
InputIntent
-> RhythmManager.judge(timestamp)
-> ActionController
-> ComboWindow
-> ActionResolver
-> ActionExecutor
```
Chart Layer must not force the player to press a specific key on a specific beat.
## Scope for This Plan
This plan implements the smallest useful Chart Layer:
- Data resources:
- `ChartEvent`
- `ChartTrack`
- `BeatChart`
- Runtime node:
- `ChartRunner`
- Event bus signals:
- `chart_event_upcoming`
- `chart_event_triggered`
- `chart_reset`
- Main scene integration:
- A `ChartRunner` child under `Main`
- UI integration:
- `RhythmTrack` listens to upcoming/triggered chart events and can render simple future markers.
- Tests:
- Resource loading
- event ordering
- upcoming events fire once
- triggered events fire once
- EventBus mirrors runner events
- existing player rhythm/action tests still pass
This plan intentionally does not implement a full enemy AI system. It creates event hooks that a future `EnemyBeatPlanner` can consume.
## Event Model
First version event types:
```text
show_accent_marker
enemy_prepare_attack
enemy_attack_active
enemy_recovery
camera_pulse
```
Event payload examples:
```gdscript
{
"lane": "enemy",
"color": "ff3355",
"label": "ATK"
}
```
```gdscript
{
"shake": 0.35,
"duration_beats": 0.25
}
```
Recommended first test chart:
```text
beat 4 show_accent_marker
beat 6 enemy_prepare_attack target_id=test_enemy lead_beats=1
beat 7 enemy_attack_active target_id=test_enemy lead_beats=1
beat 8 enemy_recovery target_id=test_enemy lead_beats=0.5
beat 12 camera_pulse
beat 16 show_accent_marker
```
---
## File Structure
Create:
- `resources/chart_event.gd`
One scheduled event on a beat or subdivision.
- `resources/chart_track.gd`
A named collection of related events, such as `accent`, `enemy`, `camera`, or `hazard`.
- `resources/beat_chart.gd`
A chart resource that owns tracks and exposes all events in sorted order.
- `scenes/chart/chart_runner.gd`
Scene-owned runner that reads the current song time and emits upcoming/triggered events.
- `tests/test_chart_layer.gd`
Headless test for resources, runner timing, and EventBus mirroring.
Modify:
- `autoload/event_bus.gd`
Add chart signals.
- `scenes/main/main.tscn`
Add a `ChartRunner` node as a sibling of `Stage` and `UI`.
- `scenes/ui/rhythm_track.gd`
Listen to chart events and maintain simple future markers.
- `scenes/ui/rhythm_track.tscn`
Add a `ChartMarkerContainer` node under `RhythmTrack`.
- `tests/test_rhythm_action_architecture.gd`
Add architecture assertions that the chart resource scripts and runner load.
Optional runtime data after the scripts compile:
- `resources/charts/test_song_chart.tres`
---
## Task 1: Add Failing Chart Layer Architecture Test
**Files:**
- Create: `tests/test_chart_layer.gd`
- Modify: `tests/test_rhythm_action_architecture.gd`
- [ ] **Step 1: Create the focused chart test**
Create `tests/test_chart_layer.gd`:
```gdscript
extends SceneTree
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
await process_frame
_check_resources_load()
await _check_runner_upcoming_and_triggered_once()
await _check_event_bus_mirroring()
_finish()
func _check_resources_load() -> void:
_expect(load("res://resources/chart_event.gd") != null, "ChartEvent script should load")
_expect(load("res://resources/chart_track.gd") != null, "ChartTrack script should load")
_expect(load("res://resources/beat_chart.gd") != null, "BeatChart script should load")
_expect(load("res://scenes/chart/chart_runner.gd") != null, "ChartRunner script should load")
func _make_event(beat: int, event_type: StringName, target_id := &"", lead_beats := 1.0):
var event_script: Script = load("res://resources/chart_event.gd")
var event: Resource = event_script.new()
event.set("beat_index", beat)
event.set("event_type", event_type)
event.set("target_id", target_id)
event.set("lead_beats", lead_beats)
return event
func _make_chart() -> Resource:
var chart_script: Script = load("res://resources/beat_chart.gd")
var track_script: Script = load("res://resources/chart_track.gd")
var chart: Resource = chart_script.new()
var track: Resource = track_script.new()
chart.set("chart_id", &"test_chart")
track.set("track_id", &"enemy")
track.set("track_type", &"enemy")
track.set("events", [
_make_event(2, &"enemy_prepare_attack", &"test_enemy", 1.0),
_make_event(3, &"enemy_attack_active", &"test_enemy", 1.0),
])
chart.set("tracks", [track])
return chart
func _make_runner(chart: Resource) -> Node:
var runner_script: Script = load("res://scenes/chart/chart_runner.gd")
var runner: Node = runner_script.new()
runner.set("beat_time_override", 0.5)
runner.call("set_chart", chart)
root.add_child(runner)
return runner
func _check_runner_upcoming_and_triggered_once() -> void:
var runner := _make_runner(_make_chart())
var upcoming: Array[StringName] = []
var triggered: Array[StringName] = []
runner.connect("chart_event_upcoming", func(event: Resource, _time_to_event: float) -> void:
upcoming.append(StringName(str(event.get("event_type"))))
)
runner.connect("chart_event_triggered", func(event: Resource) -> void:
triggered.append(StringName(str(event.get("event_type"))))
)
runner.call("update_for_song_time", 0.49)
_expect(upcoming == [&"enemy_prepare_attack"], "Prepare upcoming should fire at lead window")
_expect(triggered.is_empty(), "No event should trigger before event time")
runner.call("update_for_song_time", 1.0)
runner.call("update_for_song_time", 1.1)
_expect(triggered == [&"enemy_prepare_attack"], "Prepare triggered should fire once")
runner.call("update_for_song_time", 1.49)
runner.call("update_for_song_time", 1.50)
runner.call("update_for_song_time", 1.80)
_expect(upcoming.count(&"enemy_attack_active") == 1, "Attack upcoming should fire once")
_expect(triggered.count(&"enemy_attack_active") == 1, "Attack triggered should fire once")
runner.queue_free()
await process_frame
func _check_event_bus_mirroring() -> void:
var bus_script: Script = load("res://autoload/event_bus.gd")
var bus: Node = bus_script.new()
bus.name = "EventBus"
root.add_child(bus)
var mirrored_upcoming := 0
var mirrored_triggered := 0
bus.connect("chart_event_upcoming", func(_event: Resource, _time_to_event: float) -> void:
mirrored_upcoming += 1
)
bus.connect("chart_event_triggered", func(_event: Resource) -> void:
mirrored_triggered += 1
)
var runner := _make_runner(_make_chart())
runner.call("update_for_song_time", 0.49)
runner.call("update_for_song_time", 1.0)
_expect(mirrored_upcoming == 1, "ChartRunner should mirror upcoming events to EventBus")
_expect(mirrored_triggered == 1, "ChartRunner should mirror triggered events to EventBus")
runner.queue_free()
bus.queue_free()
await process_frame
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _finish() -> void:
if failures.is_empty():
print("PASS chart layer")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)
```
- [ ] **Step 2: Run the focused test and verify it fails for missing files**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.gd
```
Expected: FAIL because `resources/chart_event.gd`, `resources/chart_track.gd`, `resources/beat_chart.gd`, and `scenes/chart/chart_runner.gd` do not exist yet.
- [ ] **Step 3: Extend architecture test**
In `tests/test_rhythm_action_architecture.gd`, add a new function `_check_chart_layer()` and call it from `_run()` after `_check_autoloads()`:
```gdscript
func _check_chart_layer() -> void:
for path: String in [
"res://resources/chart_event.gd",
"res://resources/chart_track.gd",
"res://resources/beat_chart.gd",
"res://scenes/chart/chart_runner.gd",
]:
_expect(load(path) != null, "%s should load" % path)
var bus_script: Script = load("res://autoload/event_bus.gd")
_expect(bus_script != null, "EventBus should load for chart signal checks")
if bus_script != null:
var bus: Node = bus_script.new()
_expect(bus.has_signal("chart_event_upcoming"), "EventBus should expose chart_event_upcoming")
_expect(bus.has_signal("chart_event_triggered"), "EventBus should expose chart_event_triggered")
_expect(bus.has_signal("chart_reset"), "EventBus should expose chart_reset")
bus.free()
```
Expected: architecture test fails until the scripts and EventBus signals are added.
---
## Task 2: Add Chart Resource Types
**Files:**
- Create: `resources/chart_event.gd`
- Create: `resources/chart_track.gd`
- Create: `resources/beat_chart.gd`
- Test: `tests/test_chart_layer.gd`
- [ ] **Step 1: Create `ChartEvent`**
Create `resources/chart_event.gd`:
```gdscript
class_name ChartEvent
extends Resource
@export var event_id: StringName = &""
@export var beat_index := 0
@export var subdivision := 0
@export var subdivisions_per_beat := 1
@export var event_type: StringName = &""
@export var target_id: StringName = &""
@export var payload: Dictionary = {}
@export var lead_beats := 1.0
func beat_position() -> float:
var safe_subdivisions := maxi(1, subdivisions_per_beat)
return float(beat_index) + float(subdivision) / float(safe_subdivisions)
func time_seconds(beat_time: float) -> float:
return beat_position() * maxf(0.001, beat_time)
func key() -> StringName:
if not event_id.is_empty():
return event_id
return StringName("%s:%s:%d:%d" % [event_type, target_id, beat_index, subdivision])
```
- [ ] **Step 2: Create `ChartTrack`**
Create `resources/chart_track.gd`:
```gdscript
class_name ChartTrack
extends Resource
@export var track_id: StringName = &""
@export var track_type: StringName = &""
@export var events: Array[ChartEvent] = []
func sorted_events() -> Array[ChartEvent]:
var result: Array[ChartEvent] = []
for event: ChartEvent in events:
if event != null:
result.append(event)
result.sort_custom(func(a: ChartEvent, b: ChartEvent) -> bool:
return a.beat_position() < b.beat_position()
)
return result
```
- [ ] **Step 3: Create `BeatChart`**
Create `resources/beat_chart.gd`:
```gdscript
class_name BeatChart
extends Resource
@export var chart_id: StringName = &""
@export var total_beats := 0
@export var tracks: Array[ChartTrack] = []
func all_events() -> Array[ChartEvent]:
var result: Array[ChartEvent] = []
for track: ChartTrack in tracks:
if track == null:
continue
for event: ChartEvent in track.sorted_events():
result.append(event)
result.sort_custom(func(a: ChartEvent, b: ChartEvent) -> bool:
if is_equal_approx(a.beat_position(), b.beat_position()):
return str(a.event_type) < str(b.event_type)
return a.beat_position() < b.beat_position()
)
return result
func is_empty() -> bool:
return all_events().is_empty()
```
- [ ] **Step 4: Run the focused test and verify it still fails at `ChartRunner`**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.gd
```
Expected: FAIL because `scenes/chart/chart_runner.gd` and EventBus chart signals are not implemented yet.
---
## Task 3: Add Chart Signals to EventBus
**Files:**
- Modify: `autoload/event_bus.gd`
- Test: `tests/test_chart_layer.gd`
- Test: `tests/test_rhythm_action_architecture.gd`
- [ ] **Step 1: Add chart signals**
In `autoload/event_bus.gd`, add these signals after the existing rhythm/judgement signals:
```gdscript
signal chart_event_upcoming(event: Resource, time_to_event: float)
signal chart_event_triggered(event: Resource)
signal chart_reset(chart_id: StringName)
```
The top of the file should become:
```gdscript
extends Node
signal rhythm_action_requested(action_name: StringName)
signal beat_ticked(beat_index: int)
signal judgement_made(quality: StringName, offset_ms: float)
signal action_judged(action_name: StringName, rating: Dictionary)
signal chart_event_upcoming(event: Resource, time_to_event: float)
signal chart_event_triggered(event: Resource)
signal chart_reset(chart_id: StringName)
```
- [ ] **Step 2: Run architecture test**
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: still FAIL until `ChartRunner` exists, but EventBus chart signal assertions should pass.
---
## Task 4: Implement ChartRunner
**Files:**
- Create: `scenes/chart/chart_runner.gd`
- Test: `tests/test_chart_layer.gd`
- [ ] **Step 1: Create the chart directory and runner script**
Create `scenes/chart/chart_runner.gd`:
```gdscript
class_name ChartRunner
extends Node
signal chart_event_upcoming(event: ChartEvent, time_to_event: float)
signal chart_event_triggered(event: ChartEvent)
signal chart_reset(chart_id: StringName)
signal chart_finished(chart_id: StringName)
@export var chart: BeatChart
@export var rhythm_manager_path: NodePath
@export var beat_time_override := 0.0
@export var auto_run := true
var running := true
var _upcoming_keys: Dictionary = {}
var _triggered_keys: Dictionary = {}
func _ready() -> void:
running = auto_run
func _physics_process(_delta: float) -> void:
if not running or chart == null:
return
var rhythm := _rhythm_manager()
if rhythm == null or not rhythm.has_method("song_position"):
return
update_for_song_time(float(rhythm.call("song_position")))
func set_chart(next_chart: BeatChart) -> void:
chart = next_chart
reset()
func reset() -> void:
_upcoming_keys.clear()
_triggered_keys.clear()
var chart_id := &""
if chart != null:
chart_id = chart.chart_id
chart_reset.emit(chart_id)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("chart_reset", chart_id)
func update_for_song_time(song_time: float) -> void:
if chart == null:
return
var beat_time := _beat_time()
for event: ChartEvent in chart.all_events():
var event_time := event.time_seconds(beat_time)
var time_to_event := event_time - song_time
var lead_time := maxf(0.0, event.lead_beats) * beat_time
var event_key := event.key()
if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time:
_upcoming_keys[event_key] = true
_emit_upcoming(event, time_to_event)
if not _triggered_keys.has(event_key) and song_time >= event_time:
_triggered_keys[event_key] = true
_emit_triggered(event)
func pause() -> void:
running = false
func resume() -> void:
running = true
func _emit_upcoming(event: ChartEvent, time_to_event: float) -> void:
chart_event_upcoming.emit(event, time_to_event)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("chart_event_upcoming", event, time_to_event)
func _emit_triggered(event: ChartEvent) -> void:
chart_event_triggered.emit(event)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("chart_event_triggered", event)
func _beat_time() -> float:
if beat_time_override > 0.0:
return beat_time_override
var rhythm := _rhythm_manager()
if rhythm != null:
return float(rhythm.get("beat_time"))
return 0.5
func _rhythm_manager() -> Node:
if not is_inside_tree():
return null
if not rhythm_manager_path.is_empty():
return get_node_or_null(rhythm_manager_path)
return get_tree().root.get_node_or_null("RhythmManager")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")
```
- [ ] **Step 2: Run focused chart test**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.gd
```
Expected: PASS.
- [ ] **Step 3: Run architecture test**
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 after `_check_chart_layer()` assertions are satisfied.
---
## Task 5: Add Runtime ChartRunner Node
**Files:**
- Modify: `scenes/main/main.tscn`
- Optional create after scripts compile: `resources/charts/test_song_chart.tres`
- Test: `tests/test_rhythm_scene.gd`
- [ ] **Step 1: Add `ChartRunner` to `Main` scene**
Modify `scenes/main/main.tscn`.
Add an external resource:
```ini
[ext_resource type="Script" path="res://scenes/chart/chart_runner.gd" id="6_chart_runner"]
```
Add this node between `Stage` and `UI`:
```ini
[node name="ChartRunner" type="Node" parent="."]
script = ExtResource("6_chart_runner")
```
The intended main scene tree becomes:
```text
Main
├─ Stage
├─ ChartRunner
└─ UI
```
- [ ] **Step 2: Update scene architecture test**
In `tests/test_rhythm_scene.gd`, add an assertion that main scene has `ChartRunner`:
```gdscript
var main_scene: PackedScene = load("res://scenes/main/main.tscn")
var main := main_scene.instantiate()
root.add_child(main)
await process_frame
if main.get_node_or_null("ChartRunner") == null:
failures.append("Main should include ChartRunner for scene-owned chart playback")
main.free()
```
- [ ] **Step 3: Run scene test**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_scene.gd
```
Expected: PASS.
- [ ] **Step 4: Add a sample chart resource through the Godot editor**
After `ChartEvent`, `ChartTrack`, and `BeatChart` scripts compile, create `resources/charts/test_song_chart.tres` in the editor with:
```text
BeatChart.chart_id = test_song_chart
BeatChart.total_beats = 32
Track accent:
beat 4 show_accent_marker
beat 8 show_accent_marker
beat 12 show_accent_marker
beat 16 show_accent_marker
Track enemy:
beat 6 enemy_prepare_attack target_id=test_enemy lead_beats=1.0
beat 7 enemy_attack_active target_id=test_enemy lead_beats=1.0
beat 8 enemy_recovery target_id=test_enemy lead_beats=0.5
Track camera:
beat 12 camera_pulse target_id=main_camera lead_beats=0.5
```
Assign this resource to `Main/ChartRunner.chart`.
The plan keeps runtime playable without this sample chart because tests construct chart resources in memory. The sample chart is for visual/manual verification.
---
## Task 6: Let RhythmTrack Consume Chart Events
**Files:**
- Modify: `scenes/ui/rhythm_track.tscn`
- Modify: `scenes/ui/rhythm_track.gd`
- Test: `tests/test_rhythm_ui.gd`
- Test: `tests/test_ui_animation_regression.gd`
- [ ] **Step 1: Add marker container to scene**
In `scenes/ui/rhythm_track.tscn`, add:
```ini
[node name="ChartMarkerContainer" type="Control" parent="."]
layout_mode = 0
offset_left = 0.0
offset_top = 0.0
offset_right = 1040.0
offset_bottom = 128.0
mouse_filter = 2
```
Keep it behind `JudgementLabel` in the file order so text remains readable.
- [ ] **Step 2: Extend `RhythmTrack` script**
In `scenes/ui/rhythm_track.gd`, add:
```gdscript
@onready var chart_marker_container: Control = $ChartMarkerContainer
var chart_markers: Array[Control] = []
```
Connect chart signals in `_ready()`:
```gdscript
bus.connect("chart_event_upcoming", _on_chart_event_upcoming)
bus.connect("chart_event_triggered", _on_chart_event_triggered)
```
Add these methods:
```gdscript
func _on_chart_event_upcoming(event: Resource, time_to_event: float) -> void:
var marker := Label.new()
marker.text = _chart_marker_text(event)
marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
marker.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
marker.add_theme_font_size_override("font_size", 14)
marker.add_theme_color_override("font_color", _chart_marker_color(event))
marker.custom_minimum_size = Vector2(54, 24)
marker.position = _chart_marker_position(time_to_event)
chart_marker_container.add_child(marker)
chart_markers.append(marker)
var tween := create_tween()
tween.tween_property(marker, "modulate:a", 0.25, maxf(0.1, time_to_event))
tween.tween_callback(marker.queue_free)
func _on_chart_event_triggered(event: Resource) -> void:
if StringName(str(event.get("event_type"))) == &"camera_pulse":
center_flash.modulate = Color(1.0, 0.84, 0.26, 1.0)
else:
center_flash.modulate = _chart_marker_color(event)
beat_flash = 1.0
func _chart_marker_text(event: Resource) -> String:
match StringName(str(event.get("event_type"))):
&"show_accent_marker":
return "ACC"
&"enemy_prepare_attack":
return "WARN"
&"enemy_attack_active":
return "ATK"
&"enemy_recovery":
return "REC"
&"camera_pulse":
return "CAM"
return str(event.get("event_type")).to_upper()
func _chart_marker_color(event: Resource) -> Color:
match StringName(str(event.get("event_type"))):
&"show_accent_marker":
return Color("ffd84a")
&"enemy_prepare_attack":
return Color("ff7a33")
&"enemy_attack_active":
return Color("ff3355")
&"enemy_recovery":
return Color("8aa0ff")
&"camera_pulse":
return Color("ffffff")
return Color("00f2ff")
func _chart_marker_position(time_to_event: float) -> Vector2:
var seconds_per_beat := 60.0 / maxf(1.0, bpm)
var beat_distance := clampf(time_to_event / seconds_per_beat, 0.0, 4.0)
var x := track_center.x + beat_distance * 92.0
return Vector2(x - 27.0, track_center.y + 34.0)
```
- [ ] **Step 3: Add UI tests**
In `tests/test_rhythm_ui.gd`, assert the marker container exists:
```gdscript
var track := ui.get_node_or_null("RhythmTrack")
if track == null or track.get_node_or_null("ChartMarkerContainer") == null:
failures.append("RhythmTrack should include ChartMarkerContainer")
```
In `tests/test_ui_animation_regression.gd`, emit an upcoming event and assert a marker appears:
```gdscript
var event_script: Script = load("res://resources/chart_event.gd")
var event: Resource = event_script.new()
event.set("event_type", &"enemy_attack_active")
bus.emit_signal("chart_event_upcoming", event, 0.5)
await process_frame
var marker_container := ui.get_node("RhythmTrack/ChartMarkerContainer")
_expect_bool(marker_container.get_child_count() > 0, true, "Chart upcoming event should create a rhythm marker")
```
- [ ] **Step 4: Run UI tests**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_ui.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_ui_animation_regression.gd
```
Expected: PASS.
---
## Task 7: Full Regression Run
**Files:**
- No new files.
- [ ] **Step 1: Run chart and architecture tests**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.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:
```text
PASS chart layer
PASS rhythm action architecture
```
- [ ] **Step 2: Run player input/action tests**
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
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_combo_window.gd
/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_player_combo_input.gd
```
Expected:
```text
PASS input component intents
PASS combo window
PASS action controller flow
PASS player combo input
```
- [ ] **Step 3: Run UI tests**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_ui.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_ui_layout.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_ui_animation_regression.gd
```
Expected:
```text
PASS rhythm ui
PASS rhythm ui layout
PASS ui animation regression
```
---
## Follow-Up After This Plan
After the thin Chart Layer lands, the next feature should be a chart-driven dummy enemy:
```text
chart_event_upcoming(enemy_prepare_attack)
-> EnemyWarningPresenter shows overhead warning
chart_event_triggered(enemy_attack_active)
-> EnemyActionController opens enemy hitbox
chart_event_triggered(enemy_recovery)
-> EnemyActionController closes enemy hitbox
```
That should be a separate plan because it needs enemy scene structure, hitbox/hurtbox decisions, target IDs, and warning UI. Keeping it separate prevents the Chart Layer from becoming a hidden enemy-system rewrite.
## Important Non-Goals
- Do not move player input judgement into Chart Layer.
- Do not make charts dictate required player keys.
- Do not make `ChartRunner` an autoload.
- Do not change `ComboWindow` empty-beat behavior.
- Do not add full boss AI in this pass.
- Do not use chart events to directly damage the player.
## Known Adjacent Risk
`RhythmManager.judge(input_timestamp_ms)` currently accepts an input timestamp in milliseconds and passes it to `get_rating_for_time(input_timestamp_ms / 1000.0)`. Chart Layer should not depend on this method; it should use `RhythmManager.song_position()` directly. A separate input-timing plan should verify whether `InputIntent.timestamp_ms` is being converted to song-relative time correctly.
## Self-Review
- Spec coverage: The plan covers chart data, runner behavior, EventBus mirroring, main scene integration, UI marker consumption, and regression tests.
- Placeholder scan: The plan has no placeholder sections. Every task names exact files and expected commands.
- Type consistency: Event resources are `ChartEvent`, tracks are `ChartTrack`, charts are `BeatChart`, and runner signals use `ChartEvent` locally plus `Resource` on `EventBus`.
- Scope check: Enemy AI is intentionally excluded and captured as the next separate feature.
- Current-project fit: The plan preserves the existing `RhythmManager`, `ActionController`, `ComboWindow`, `ActionResolver`, and player UI patterns.

View File

@@ -0,0 +1,116 @@
# Godot Architecture Refactor 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:** Refactor `Fighting_Rthythm_game` to match the provided architecture: EventBus communication, thin Main, componentized Player, SkillData resources, UI subscenes, Stage/ActorsContainer ownership, and named 2D layers.
**Architecture:** Keep the game runnable at each increment. Cross-system communication goes through `autoload/event_bus.gd`; parent-to-child orchestration uses typed references; child-to-parent requests use signals. Player becomes a coordinator over child components, while skill definitions move to `.tres` resources loaded by `InputResolver`.
**Tech Stack:** Godot 4.6 GDScript, `.tscn` scenes, `.tres` resources, headless SceneTree tests.
---
### Task 1: Architecture Guard Tests
**Files:**
- Create: `tests/test_architecture_refactor.gd`
- Modify: `project.godot`
- [ ] **Step 1: Write failing tests**
Add tests that assert EventBus is autoloaded, Main no longer hand-wires UI with `has_method`/`has_signal`, Player has child components, Player does not use raw `KEY_*`, skills load as `SkillData`, Stage owns `ActorsContainer`, UI exists as a separate scene, and project 2D layers are named.
- [ ] **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_architecture_refactor.gd`
Expected: FAIL because the current project lacks EventBus, component nodes, resource skills, UI subscenes, and Stage boundaries.
### Task 2: EventBus And Rhythm Decoupling
**Files:**
- Create: `autoload/event_bus.gd`
- Modify: `project.godot`
- Modify: `scenes/rhythm/rhythm_conductor.gd`
- Modify: `scenes/characters/player.gd`
- [ ] Add EventBus signals for rhythm requests, beats, judgements, skills, health, energy, charge, combo, damage, and projectile requests.
- [ ] Register EventBus as a game autoload.
- [ ] Make RhythmConductor listen for rhythm action requests and emit EventBus judgement/beat signals.
- [ ] Remove Player's `get_first_node_in_group("rhythm_conductor")` fallback and treat missing judgement as a miss.
### Task 3: Player Components
**Files:**
- Create: `scenes/components/input_component.gd`
- Create: `scenes/components/combo_tracker.gd`
- Create: `scenes/components/energy_component.gd`
- Create: `scenes/components/health_component.gd`
- Create: `scenes/components/damage_emitter.gd`
- Create: `scenes/components/damage_receiver.gd`
- Create: `scenes/components/state_machine.gd`
- Modify: `scenes/characters/player.gd`
- Modify: `scenes/characters/player.tscn`
- Modify: `project.godot`
- [ ] Move raw input handling into InputComponent using only InputMap actions.
- [ ] Move combo slot storage and clear timing into ComboTracker.
- [ ] Move energy/health state into components that emit EventBus updates.
- [ ] Add DamageEmitter and DamageReceiver Area2D components with collision-layer-based targeting.
- [ ] Keep Player as a typed coordinator over components.
### Task 4: SkillData Resources
**Files:**
- Create: `resources/skill_data.gd`
- Create: `resources/skills/*.tres`
- Modify: `scenes/combat/input_resolver.gd`
- Modify: `scenes/characters/player.gd`
- [ ] Define `SkillData extends Resource`.
- [ ] Convert each hard-coded skill pattern into a `.tres` resource.
- [ ] Make InputResolver load resources and return `SkillData`.
- [ ] Move animation, energy cost/reward, projectile flags, clear-window behavior, and displacement into resources.
### Task 5: UI Subscenes And Thin Main
**Files:**
- Create: `scenes/ui/main_ui.tscn`
- Create: `scenes/ui/main_ui.gd`
- Create: `scenes/ui/rhythm_track.tscn`
- Create: `scenes/ui/rhythm_track.gd`
- Create: `scenes/ui/combo_window_hud.tscn`
- Create: `scenes/ui/combo_window_hud.gd`
- Create: `scenes/ui/energy_bar.tscn`
- Create: `scenes/ui/energy_bar.gd`
- Modify: `scenes/main/main.gd`
- Modify: `scenes/main/main.tscn`
- [ ] Move rhythm track animation into `RhythmTrack`.
- [ ] Move combo slot rendering into `ComboWindowHud`.
- [ ] Move energy/health/charge display into UI nodes that subscribe to EventBus.
- [ ] Reduce Main to typed child references and scene setup only.
### Task 6: Stage And ActorsContainer
**Files:**
- Create: `scenes/stage/stage.tscn`
- Create: `scenes/stage/stage.gd`
- Create: `scenes/stage/actors_container.gd`
- Create: `scenes/combat/player_projectile.tscn`
- Modify: `scenes/combat/player_projectile.gd`
- Modify: `scenes/characters/player.gd`
- Modify: `scenes/main/main.tscn`
- [ ] Put ground and Player under Stage/ActorsContainer.
- [ ] Make Player emit projectile requests instead of adding children to the scene tree.
- [ ] Make ActorsContainer instantiate projectiles, own them, and group them.
### Task 7: Verification
**Files:**
- Modify: tests as needed to target public component interfaces instead of Player internals.
- [ ] Run every `tests/*.gd` with Godot headless.
- [ ] Confirm architecture guard tests cover every explicit objective item.
- [ ] Inspect key source files to ensure raw key matching, group conductor lookup, Player projectile spawning, and Main `has_method`/`has_signal` probing are gone.

View File

@@ -0,0 +1,202 @@
# Rhythm Action Architecture Refactor 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:** Refactor the project toward the architecture in `docs/架构方案.md`: global beat clock, four-slot combo window, ActionData resources, ActionResolver priority rules, Player execution components, and CombatManager formulas.
**Architecture:** Keep `EventBus` for decoupled broadcast, but add `RhythmManager` and `CombatManager` as first-class autoload services. Move domain logic out of `Player` into beat-aware components and data-driven resource files while preserving current gameplay tests.
**Tech Stack:** Godot 4.6 GDScript, `.tres` Resources, headless SceneTree tests.
---
### Task 1: Architecture Target Regression
**Files:**
- Create: `tests/test_rhythm_action_architecture.gd`
- [x] **Step 1: Write the failing test**
Create one test that asserts the six requested architecture artifacts exist and expose their intended behavior:
```gdscript
extends SceneTree
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
_check_autoloads()
_check_action_data()
_check_combo_window()
_check_action_resolver()
_check_player_components()
_check_combat_manager()
_finish()
```
- [x] **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 `RhythmManager`, `CombatManager`, `ActionData`, `ActionResolver`, `ComboWindow`, `MotionExecutor`, and `BurstComponent` do not exist yet.
### Task 2: RhythmManager Autoload
**Files:**
- Create: `autoload/rhythm_manager.gd`
- Modify: `project.godot`
- Modify: `scenes/rhythm/rhythm_conductor.gd`
- Modify: `tests/test_rhythm_conductor.gd`
- [x] **Step 1: Implement `RhythmManager`**
Create an Autoload node with `beat_ticked`, `judgement_made`, and `action_judged` signals. It owns BPM, beat length, beat index, judgement scale, beat offset, fallback clock timing, and `judge_action(action_name)`.
- [x] **Step 2: Register autoload**
Add:
```ini
[autoload]
RhythmManager="*res://autoload/rhythm_manager.gd"
```
- [x] **Step 3: Preserve `RhythmConductor` compatibility**
Keep `scenes/rhythm/rhythm_conductor.gd` loadable for old tests by delegating equivalent judging math to the same model or leaving it as a scene adapter.
- [x] **Step 4: Run rhythm tests**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_conductor.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: rhythm conductor still passes; architecture test advances past autoload checks.
### Task 3: Four-Slot ComboWindow
**Files:**
- Create: `scenes/components/combo_window.gd`
- Modify: `scenes/characters/player.tscn`
- Modify: `scenes/characters/player.gd`
- Modify: `tests/test_combo_window.gd`
- [x] **Step 1: Implement `ComboWindow`**
`ComboWindow` extends `Node`, stores four slots, records `StringName` inputs, keeps `&"Ø"` only as an explicit Miss placeholder, clears on miss/full/action clear, and emits `combo_updated`/`combo_cleared`. It must not auto-append `&"Ø"` just because a beat passed without input.
- [x] **Step 2: Replace Player node**
Rename or replace `ComboTracker` with `ComboWindow` in `player.tscn`. Keep compatibility methods `get_slots()`, `get_pattern()`, `get_contiguous_pattern()`, `queue_clear()`, `flush_pending_clear()` so existing tests can continue running while behavior becomes component-based.
- [x] **Step 3: Run combo tests**
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_combo_window.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: existing combo behavior remains green, and the new architecture test confirms ComboWindow does not auto-append `&"Ø"` just because a beat passed without input.
### Task 4: ActionData and ActionResolver
**Files:**
- Create: `resources/action_data.gd`
- Create: `resources/actions/*.tres`
- Create: `scenes/combat/action_resolver.gd`
- Modify: `resources/skill_data.gd`
- Modify: `scenes/combat/input_resolver.gd`
- Modify: `scenes/characters/player.gd`
- Modify: `tests/test_combo_window.gd`
- Modify: `tests/test_architecture_refactor.gd`
- [x] **Step 1: Add full `ActionData`**
Create `ActionData extends Resource` with fields from `docs/架构方案.md`: `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`, plus compatibility fields `animation`, `energy_cost`, `energy_reward`, `spawns_projectile`, `projectile_scene`, and `displacement`.
- [x] **Step 2: Convert resources**
Create `resources/actions` equivalents for the current `resources/skills` files. After all tests and scripts use `ActionData`, remove the legacy `resources/skills` data.
- [x] **Step 3: Implement `ActionResolver`**
Load `resources/actions/*.tres`, expose `resolve(window, state_machine := null, context := {})`, `resolve_pattern(pattern, state_machine := null, context := {})`, `reload()`, and `clear_cache()`. Include an ordered rule pass for Space contexts before falling back to pattern matching.
- [x] **Step 4: Remove `InputResolver` after migration**
Move tests and runtime code to `ActionResolver` directly, then remove the legacy `InputResolver` adapter.
### Task 5: MotionExecutor and BurstComponent
**Files:**
- Create: `scenes/components/motion_executor.gd`
- Create: `scenes/components/burst_component.gd`
- Modify: `scenes/characters/player.tscn`
- Modify: `scenes/characters/player.gd`
- [x] **Step 1: Add `MotionExecutor`**
Expose `execute(action, direction, beat_time)` and `tick(delta)` to own action duration, lunge timing, and velocity output. Player calls it instead of hand-writing lunge windows.
- [x] **Step 2: Add `BurstComponent`**
Expose ready/active/cooldown state, beat-based duration, cost multiplier, damage multiplier, and judgement scale. It listens to `RhythmManager.beat_ticked` for duration and cooldown.
- [x] **Step 3: Move Player logic**
Replace direct charge/burst timing in `Player` with `BurstComponent`, and replace direct action displacement with `MotionExecutor`.
### Task 6: CombatManager
**Files:**
- Create: `autoload/combat_manager.gd`
- Modify: `project.godot`
- Modify: `scenes/components/damage_emitter.gd`
- Modify: `scenes/characters/player.gd`
- [x] **Step 1: Implement formulas**
`CombatManager` exposes `resolve_damage(base_attack, action, judgement, buffs := null, burst := null)`, `resolve_cost(action, burst := null)`, and `resolve_move(action, judgement, burst := null)`.
- [x] **Step 2: Wire damage and cost**
Player uses `CombatManager.resolve_cost` before action execution. `DamageEmitter` can accept an action context and asks `CombatManager.resolve_damage` rather than using scattered multipliers.
### Task 7: Verification and Gap Audit
**Files:**
- Modify: tests as needed to cover exact behavior.
- [x] **Step 1: Run full suite**
Run:
```bash
for test in tests/*.gd; do
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s "res://$test" || exit 1
done
```
Expected: all tests exit `0`.
- [x] **Step 2: Search for architecture residue**
Run:
```bash
rg -n "Timer|charge_duration|attack_lunge|InputResolver|SkillData|ComboTracker|PlayerProjectile\\.new\\(|get_first_node_in_group|has_method|\\.call\\(" autoload resources scenes tests
```
Expected: any remaining hits are compatibility adapters, tests, or UI-only visual timing, not core gameplay architecture.