993 lines
30 KiB
Markdown
993 lines
30 KiB
Markdown
# 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.
|