Compare commits

5 Commits
main ... main

Author SHA1 Message Date
wxm
a451041be4 Add something resource folders 2026-07-02 09:55:02 -07:00
wxm
87d7533b4a Remove tracked macOS metadata files 2026-07-02 09:53:26 -07:00
wxm
e62ed84518 Refactor rhythm action architecture 2026-07-02 09:47:52 -07:00
wxm
fc941cf08d Refine combo charge controls 2026-07-02 05:46:33 -07:00
wxm
67db812de4 Add combat combo gameplay 2026-07-02 05:11:24 -07:00
149 changed files with 9065 additions and 581 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.DS_Store
.godot/

BIN
addons/.DS_Store vendored

Binary file not shown.

Binary file not shown.

BIN
assets/.DS_Store vendored

Binary file not shown.

BIN
assets/art/.DS_Store vendored

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c3okawgkks4td"
path="res://.godot/imported/player_punch.png-14a58131e3712983546ba83347b7e913.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/characters/player_punch.png"
dest_files=["res://.godot/imported/player_punch.png-14a58131e3712983546ba83347b7e913.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bbkamgcdsw5g6"
path="res://.godot/imported/warrior_man_sheet.png-5133318205f8c6ca88007088752e7f76.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/characters/warrior_man_sheet.png"
dest_files=["res://.godot/imported/warrior_man_sheet.png-5133318205f8c6ca88007088752e7f76.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://womoel71g8ae"
path="res://.godot/imported/warrior_woman_sheet.png-fd3d48dc9f47dfc48f7d4efc61b9be9c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/characters/warrior_woman_sheet.png"
dest_files=["res://.godot/imported/warrior_woman_sheet.png-fd3d48dc9f47dfc48f7d4efc61b9be9c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://1p2uqgg1jole"
path="res://.godot/imported/effect_hp_mp_sheet.png-9328f2d19fa99f3dd1de40f54cc78b6d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/effects/effect_hp_mp_sheet.png"
dest_files=["res://.godot/imported/effect_hp_mp_sheet.png-9328f2d19fa99f3dd1de40f54cc78b6d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dgpl0g56pw1qu"
path="res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/effects/effect_sheet.png"
dest_files=["res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -0,0 +1,68 @@
extends Node
func resolve_damage(base_attack: float, action: Resource, judgement: Dictionary, buffs: Variant = null, burst: Variant = null) -> float:
var action_mult := _resource_float(action, "damage_mult", 1.0)
var judgement_mult := _judgement_mult(judgement)
var buff_mult := _provider_mult(buffs, "damage_mult", action)
var burst_mult := _provider_mult(burst, "damage_mult", action)
return base_attack * action_mult * judgement_mult * buff_mult * burst_mult
func resolve_cost(action: Resource, burst: Variant = null) -> float:
var base_cost := _resource_float(action, "base_cost", 0.0)
var cost := base_cost if base_cost > 0.0 else _resource_float(action, "energy_cost", 0.0)
var burst_mult := _provider_mult(burst, "cost_mult", action)
return maxf(0.0, cost * burst_mult)
func resolve_move(action: Resource, judgement: Dictionary, burst: Variant = null) -> Vector2:
var judgement_mult := _dict_float(judgement, "move_mult", 1.0)
var burst_mult := _provider_mult(burst, "move_mult", action)
return Vector2(
_resource_float(action, "move_mult_x", 0.0),
_resource_float(action, "move_mult_y", 0.0)
) * judgement_mult * burst_mult
func _judgement_damage_mult(label: StringName) -> float:
match label:
&"perfect":
return 1.25
&"good":
return 1.0
&"bad":
return 0.75
_:
return 0.0
func _judgement_mult(judgement: Dictionary) -> float:
if judgement.has("damage_mult"):
return float(judgement["damage_mult"])
if judgement.has("label"):
return _judgement_damage_mult(StringName(str(judgement["label"])))
return 1.0
func _resource_float(resource: Resource, property_name: String, fallback: float) -> float:
if resource == null:
return fallback
var value = resource.get(property_name)
if value == null:
return fallback
return float(value)
func _dict_float(values: Dictionary, key: String, fallback: float) -> float:
if not values.has(key):
return fallback
return float(values[key])
func _provider_mult(provider, method_name: String, action: Resource) -> float:
if provider == null:
return 1.0
if provider.has_method(method_name):
return float(provider.call(method_name, action))
return 1.0

View File

@@ -0,0 +1 @@
uid://dmeiefmd38a30

19
autoload/event_bus.gd Normal file
View File

@@ -0,0 +1,19 @@
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)
signal skill_executed(skill: Resource, judgement: StringName)
signal projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2)
signal damage_dealt(target: Node, amount: int, hit_type: StringName)
signal player_health_changed(current: int, max_value: int)
signal player_energy_changed(current: float, max_value: float)
signal player_charge_changed(current: float, max_value: float, ready: bool, active: bool)
signal combo_updated(inputs: Array[StringName])
signal combo_cleared(reason: StringName)

View File

@@ -0,0 +1 @@
uid://cpgixq8ibqhh4

167
autoload/rhythm_manager.gd Normal file
View File

@@ -0,0 +1,167 @@
extends AudioStreamPlayer
signal beat_ticked(beat_index: int)
signal judgement_made(quality: StringName, offset_ms: float)
signal action_judged(action_name: StringName, rating: Dictionary)
@export var bpm: float = 120.0:
set(value):
bpm = maxf(1.0, value)
beat_time = 60.0 / bpm
@export var measures := 4
@export var beat_offset := 0.0
@export var perfect_window := 0.060
@export var good_window := 0.120
@export var bad_window := 0.200
@export var judgement_scale := 1.0
@export var starts_on_ready := true
const DEFAULT_STREAM_PATH := "res://assets/audio/song.ogg"
var beat_time := 0.5
var beat_index := 0
var running := false
var _start_time_usec := 0
var _last_reported_beat := -1
func _ready() -> void:
if stream == null and DisplayServer.get_name() != "headless":
stream = load(DEFAULT_STREAM_PATH)
volume_db = -10.0
beat_time = 60.0 / maxf(1.0, bpm)
var bus := _event_bus_or_null()
if bus != null and not bus.is_connected("rhythm_action_requested", _on_rhythm_action_requested):
bus.connect("rhythm_action_requested", _on_rhythm_action_requested)
if starts_on_ready:
start()
func _physics_process(_delta: float) -> void:
if not running:
return
var adjusted_position := _apply_beat_offset(song_position())
beat_index = int(floor(adjusted_position / beat_time))
if _last_reported_beat < beat_index:
_last_reported_beat = beat_index
beat_ticked.emit(beat_index)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("beat_ticked", beat_index)
func configure(next_bpm: float, next_measures: int, next_beat_offset: float, next_windows := Vector3(0.060, 0.120, 0.200)) -> void:
bpm = next_bpm
measures = next_measures
beat_offset = next_beat_offset
perfect_window = next_windows.x
good_window = next_windows.y
bad_window = next_windows.z
func start() -> void:
running = true
_start_time_usec = Time.get_ticks_usec()
beat_index = 0
_last_reported_beat = -1
if stream != null and not playing:
play()
func stop_manager() -> void:
if playing:
stop()
running = false
func _exit_tree() -> void:
if playing:
stop()
stream = null
func song_position() -> float:
if running and playing:
var current_position := get_playback_position() + AudioServer.get_time_since_last_mix()
current_position -= AudioServer.get_output_latency()
return maxf(0.0, current_position)
if running:
return float(Time.get_ticks_usec() - _start_time_usec) / 1000000.0
return 0.0
func judge(input_timestamp_ms: float) -> Dictionary:
return get_rating_for_time(input_timestamp_ms / 1000.0)
func judge_action(action_name: StringName) -> Dictionary:
var rating := get_current_rating()
rating["action"] = action_name
action_judged.emit(action_name, rating)
judgement_made.emit(StringName(str(rating.get("label", "miss"))), float(rating.get("diff", INF)) * 1000.0)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("judgement_made", StringName(str(rating.get("label", "miss"))), float(rating.get("diff", INF)) * 1000.0)
bus.emit_signal("action_judged", action_name, rating)
return rating
func get_current_rating() -> Dictionary:
return get_rating_for_time(song_position())
func get_rating_for_time(time_seconds: float) -> Dictionary:
var adjusted_time := _apply_beat_offset(time_seconds)
if adjusted_time < 0.0:
return _rating_result(&"miss", Color("ff0055"), 0, 0.0, INF, INF)
var nearest_beat := int(round(adjusted_time / beat_time))
var nearest_beat_time := nearest_beat * beat_time
var diff := adjusted_time - nearest_beat_time
var abs_diff := absf(diff)
var scale := maxf(0.01, judgement_scale)
if abs_diff <= perfect_window * scale:
return _rating_result(&"perfect", Color("00f2ff"), nearest_beat, nearest_beat_time, diff, abs_diff)
if abs_diff <= good_window * scale:
return _rating_result(&"good", Color("ffffff"), nearest_beat, nearest_beat_time, diff, abs_diff)
if abs_diff <= bad_window * scale:
return _rating_result(&"bad", Color("ffaa00"), nearest_beat, nearest_beat_time, diff, abs_diff)
return _rating_result(&"miss", Color("ff0055"), nearest_beat, nearest_beat_time, diff, abs_diff)
func get_current_beat_progress() -> float:
return get_beat_progress_for_time(song_position())
func get_beat_progress_for_time(time_seconds: float) -> float:
var adjusted_time := _apply_beat_offset(time_seconds)
if adjusted_time < 0.0:
return 0.0
return fposmod(adjusted_time, beat_time) / beat_time
func _on_rhythm_action_requested(action_name: StringName) -> void:
judge_action(action_name)
func _apply_beat_offset(time_seconds: float) -> float:
return time_seconds + beat_offset
func _rating_result(label: StringName, color: Color, nearest_beat: int, nearest_beat_time: float, diff: float, abs_diff: float) -> Dictionary:
return {
"label": str(label),
"color": color,
"nearest_beat": nearest_beat,
"nearest_beat_time": nearest_beat_time,
"diff": diff,
"abs_diff": abs_diff,
}
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")

View File

@@ -0,0 +1 @@
uid://hoga4p3vm5qp

File diff suppressed because it is too large Load Diff

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.

182
docs/架构方案.md Normal file
View File

@@ -0,0 +1,182 @@
读完这份策划书,先说结论:前两轮的架构骨架(Autoload 总线 + 组件化 + Resource 数据驱动)完全适用,但策划书里有三个事实会**反过来修正**我之前的一些具体建议,先把它们摆清楚,因为整个架构都要围绕它们展开。
**第一,拍(Beat)是全游戏唯一的时间单位。** 动作持续用 ActionBeats,Buff 持续 8 拍,爆气 16 拍、冷却 4 拍,蓄力按拍分级,敌人每 N 拍行动,连段窗口是 4 拍。这意味着 RhythmManager 不只是"判定服务",它是**全局主时钟**,所有系统的计时都应该订阅它的 `beat_ticked`,而不是各自开 Timer 数秒——这样换 BPM 时整个游戏自动变速,这正是节奏游戏该有的性质。
**第二,我要收回上一轮"输入缓冲"的建议。** InputBuffer 是格斗游戏的手感工具,但在你这个设计里它是有害的:判定的本质是测量按键时刻与拍点的偏差,缓冲会把"提前按"偷偷延后消费,等于篡改了玩家的真实节奏。而且策划书的 Miss 机制天然解决了连打问题——乱按的人会大量 Miss,Miss 清空窗口、动作失败,惩罚是玩法自带的,不需要程序层再去限速。保留的只有 echo 过滤、去重和时间戳这些"让输入干净"的部分。
**第三,策划书 9.1 节其实已经替你把数据模型设计好了。** 那个统一技能字段表(inputPattern、requiredState、baseCost、各倍率、hitType、clearWindow、canChain……)就是 `ActionData extends Resource` 的字段清单,而且基础动作和技能同构——A 一段斩就是一个 cost=0、pattern=[A] 的 ActionData。全游戏 30 多个动作全部变成 `.tres` 文件,代码里没有任何一张硬编码技能表。
## 一、全局层:RhythmManager 作为节拍中枢RhythmManager(Autoload)的接口对应策划书 2.1 的全局单位表:
```gdscript
# autoload/rhythm_manager.gd
signal beat_ticked(beat_index: int)
var bpm: float
var beat_time: float # 60.0 / bpm
var beat_index: int
var judgement_scale := 1.0 # 爆气时 > 1,放宽判定窗口
func song_position() -> float:
return music.get_playback_position() \
+ AudioServer.get_time_since_last_mix() \
- AudioServer.get_output_latency()
func judge(input_timestamp_ms: float) -> Judgement:
# 换算到歌曲时间,求与最近拍点的 BeatOffset
# 与 RhythmConfig 中的阈值比较 → perfect / good / bad / miss
# 阈值乘以 judgement_scale 实现爆气放宽
```
判定阈值放进 `rhythm_config.tres` 资源(Perfect/Good/Bad 各自的偏差上限)——**策划书目前没有给这组数字**,这是要找策划补的第一个洞。爆气的"判定窗口适度放宽"通过 `judgement_scale` 一个字段实现,BurstComponent 进入爆气时改它,退出时还原。
CombatManager(Autoload)则是四条乘区公式的唯一归属地。策划书 2.3 的公式结构完全一致(基础值 × 动作乘区 × 节奏乘区 × Buff 乘区 × 爆气乘区),所以做一个统一的结算管线:
```gdscript
# autoload/combat_manager.gd
func resolve_damage(atk: float, action: ActionData, j: Judgement,
buffs: BuffContainer, burst: BurstComponent) -> float:
return atk * action.damage_mult * j.damage_mult \
* buffs.damage_mult(action) * burst.damage_mult()
# resolve_move_x / resolve_move_y / resolve_cost 同构,只换字段
```
各系统只负责"贡献自己的乘数"(比如强袭乐句 Buff 只对同方向技能生效,所以 `buffs.damage_mult(action)` 要传入动作),永远不要在 Player 或技能代码里手写 `damage * 1.25 * 1.2` 这种散落的乘法——否则第 4 条公式改一次你要全项目搜一遍。
## 二、数据层:ActionData 照抄策划书 9.1
```gdscript
# resources/action_data.gd
class_name ActionData extends Resource
@export var id: StringName
@export var display_name: String
@export var input_pattern: Array[StringName] # [&"A", &"A", &"space"]
@export_enum("ground", "air", "guarding", "any") var required_state: String
@export var base_cost := 0 # 基础动作恒为 0
@export var damage_mult := 1.0
@export var move_mult_x := 0.0
@export var move_mult_y := 0.0
@export var action_beats := 1.0 # W 为 2.0
@export_enum("melee", "projectile", "circle", "counter") var hit_type: String
@export var range := 0.0
@export_enum("single", "area") var target_type: String
@export var armor_level := 0
@export var clear_window := true # 音刃前两段为 false
@export var can_chain := false # 音刃族为 true
@export var special: StringName # 破霸体、浮空等特效钩子
```
A/D 三段、W、招架、下劈、W 派生、四个方向技能、三段音刃、反击音刃、三级蓄力——全部是这个类的 `.tres` 实例,放在 `res://resources/actions/` 下。第一版验收标准 17.4 里"A Space、AA Space、AAA Space 功能不同"这种需求,变成纯粹的资源文件差异。
## 三、玩家实体:组件划分随策划书调整
玩家场景树在上一轮基础上,按这份策划书的系统重新切分:
```
Player (CharacterBody2D)
├── Sprite / 骨骼动画
├── StateMachine # 8 个状态,照抄 3.2 节
│ └── ground / air / guarding / charging /
│ bladeChain / burstCharge / bursting / hitstun
├── InputComponent # 按下+松开事件、时间戳、长按检测
├── ComboWindow # 四槽连段窗口,只记录显式输入/Miss,不自动补空拍
├── ActionResolver # Space 优先级链 + 动作表匹配(纯逻辑)
├── MotionExecutor # 把位移乘区结果变成 ActionBeats 内的实际位移
├── EnergyComponent # 回能规则 + 空挥计数器
├── BuffContainer # 四个 Buff 的触发、拍计时、乘区供给
├── BurstComponent # 爆气条件、四态、效果开关
├── DamageEmitter (Area2D)
└── DamageReceiver (Area2D)
```
几个组件值得单独说透。
**ComboWindow:它是连段窗口的领域对象,不是输入缓冲,也不负责空拍补位。** 这里按当前设计修正:ComboWindow 不订阅 `beat_ticked` 来自动补 Ø,某一拍没有输入就什么都不记录。它只记录两类内容:通过节奏判定的显式输入(A/D/W/S/Space),以及 Miss 时由裁决层显式写入的 Ø 占位。Miss 只是正常槽位输入,不因自身触发清空;满 4 槽清空;受击清空(监听 DamageReceiver);`clear_window == true` 的动作释放后清空;bladeChain 期间按专门规则决定是否保留窗口。识别连段时对外暴露过滤掉 Ø 的有效序列,但正常玩法里 Ø 只作为 Miss 反馈,不会因为空拍自动出现:
```gdscript
# components/combo_window.gd
class_name ComboWindow extends Node
signal cleared(reason: StringName)
var slots: Array[StringName] = []
func record(action: StringName) -> void: # 判定非 Miss 后调用
slots.append(action)
if slots.size() >= 4:
clear(&"window_full")
func record_miss() -> void: # 仅 Miss 裁决显式调用
slots.append(&"Ø")
clear(&"miss")
func pattern() -> Array[StringName]: # 供 ActionResolver 匹配
return slots.filter(func(s): return s != &"Ø")
```
这样窗口的语义会更干净:连段只由玩家真实输入构成;Miss 是显式失败反馈并清空窗口;空拍不会污染连段,也不会制造需要额外解释的隐藏槽位。
**ActionResolver:Space 的六步优先级链是全项目最容易腐坏的逻辑,必须独立成模块。** 而且注意策划书 10.3 说反击音刃"优先级高于普通 S Space 音刃",但第 6 节的解析顺序里没写它——合并后的完整链条是七步:实现上把这七步写成一个有序规则数组,每条规则是"条件函数 + 产出动作",Resolver 逐条尝试、命中即返回。这样以后加 Q/E、装备技能替换(策划书 18 节的暂缓内容)只是往数组里插规则,不用动主干。
**InputComponent:长按检测暴露了一个策划书没定义的关键时序问题。** S Space 短按是音刃、长按是爆气——但按下的瞬间程序不可能知道玩家会不会长按,而策划书 4.1 又要求"按键时立刻判定"。这两条规则在 Space 上是冲突的,必须由程序定义结算时机。我建议的方案是**按下即判定、延迟结算**:
```gdscript
# InputComponent 对 Space 的处理
# 按下: 立即调用 RhythmManager.judge() 并暂存结果(pending)
# 若在 hold_threshold(建议 0.3~0.5 拍,需策划确认)内松开:
# → 按短按结算,使用按下那一刻的判定结果(节奏不失真)
# 若超过阈值仍按住:
# → 丢弃 pending,升级为 charging / burstCharge 状态
# → 松开时重新判定(策划书 11.1 / 12.4 本来就要求松开判定)
```
代价是短按的动作会比按键晚约三分之一拍才出招——对音刃这种远程投射物,可以让动画前段先演出、投射物在结算点生成来掩盖。这个点务必和策划当面对齐,它直接影响手感验收标准 17.1 的"按下后立即出动作"在 Space 上如何解释。
**StateMachine 直接照抄 3.2 节的八个状态**,每个 ActionData 的 `required_state` 字段由状态机门控(W 派生只在 air 状态可解析,招架结算只在 guarding)。charging、bladeChain、burstCharge 这三个状态的本质是"改变 Space 解析结果的模式",所以 ActionResolver 每次解析都要先问状态机当前状态——优先级链的①②③本质上就是状态查询。
**MotionExecutor 是这个游戏区别于普通横版的组件。** 玩家不能自由走路,意味着 Player 里**不存在**常规的"读方向键改 velocity"代码;所有位移都是动作的产物:接到一个动作的 `FinalMoveX/Y`(CombatManager 算好的乘区结果),在 `action_beats × beat_time` 的时长内用 tween 或速度曲线执行完,期间仍走 `move_and_slide` 保证碰撞。W 的 T0T1 上升、T1 高点、T1T2 下落也由它按拍切分,并在 T1 通知状态机开放空中派生窗口。
**资源三件套(Energy / Buff / Burst)全部是 EventBus 的订阅者。** EnergyComponent 的空挥限制需要一条此前没有的反馈链:DamageEmitter 命中时经 CombatManager 广播 `hit_confirmed`,EnergyComponent 据此区分"命中回能 100% / 有效位移未命中 50% / 连续三次空挥后归零"。BuffContainer 监听判定流水(合拍、完美律动看连击流)、连段事件(强袭乐句看 AAA 终结)、招架结果(守拍反击),持续时间订阅 `beat_ticked` 递减,并暴露一个 `ticking_paused` 开关给爆气用。BurstComponent 自己是个小状态机(off/ready/active/cooldown),每次能量或连击变化时检查 12.3 的三选一条件点亮 ready,激活时做四件事:改 CombatManager 乘区、把技能 cost 乘区归零、调 `RhythmManager.judgement_scale`、暂停 BuffContainer 计时,16 拍后统一还原并清资源。
## 四、世界层与敌人
Stage 负责装载关卡、把曲目和 BPM 交给 RhythmManager 开始播放。ActorsContainer 统一生成三种测试敌人和**音刃投射物**(音刃是远程投射物,正好落在上一轮"实体不自己 new 实体"的规则里)。敌人 = Character 基类 + DamageReceiver(带 ArmorLevel)+ 一个极简的 EnemyBrain:
```gdscript
# EnemyBrain: 敌人和玩家订阅同一个时钟,这是设计对称性所在
@export var data: EnemyData # beats_per_action、行为类型
func _on_beat(i: int) -> void:
if i % data.beats_per_action == 0:
_act() # 接近 / 攻击 / 远程射击
```
节奏型(1 拍、2 拍,以及未来的半拍、切分)就是 EnemyData 上的一个数字或节拍掩码,15.1 节的扩展方向零成本预留。破霸体走 DamageContext:W 派生的 ActionData 带 `armor_level``special = &"armor_break"`,Receiver 拿自己的 ArmorLevel 比较后决定是硬直、浮空还是只吃伤害。
UI 层强烈建议第一版就做**四槽窗口可视化**(四个格子实时显示 A/D/W/S/Space/Ø),它同时是玩家理解连段系统的核心界面和你调试 ComboWindow 的工具。这里的 Ø 只代表显式 Miss 反馈,不是空拍占位;UI 监听 ComboWindow 的信号即可。
## 五、目录结构与实施映射
```
res://
├── autoload/ # rhythm_manager.gd / event_bus.gd / combat_manager.gd
├── resources/
│ ├── actions/ # 30+ 个 ActionData .tres(基础动作与技能同构)
│ ├── buffs/ # 4 个 BuffData
│ ├── enemies/ # EnemyData(节奏型、数值)
│ └── rhythm_config.tres
├── components/ # input / combo_window / resolver / motion / energy / buff / burst / damage_*
├── scenes/
│ ├── characters/ # player.tscn、enemy 基类、三种测试敌人
│ ├── stage/ # stage.tscn、actors_container.tscn
│ ├── combat/ # 音刃投射物等
│ └── ui/ # hud.tscn、combo_slots.tscn、energy_bar.tscn
└── tests/ # 对 ComboWindow / Resolver / CombatManager 的纯逻辑单元测试
```
对应策划书 16 节的五个阶段:阶段 1 = RhythmManager + RhythmConfig + InputComponent + ComboWindow(这四个是地基,而且全部可脱离画面单元测试);阶段 2 = StateMachine + MotionExecutor + 基础动作的 ActionData;阶段 3 = ActionResolver 优先级链 + 全部技能资源 + DamageEmitter/投射物 + ActorsContainer;阶段 4 = Energy/Buff/Burst 三组件 + 乘区管线补全;阶段 5 = EnemyBrain + 三种敌人。每个阶段结束,对应的验收标准(17 节)恰好都有明确的架构归属可以逐条勾选。
## 六、需要回去和策划确认的三个洞
架构做到这里,策划书有三处未定义的行为必须补上,否则程序只能自行拍板:一是**判定阈值的具体数字**(Perfect/Good/Bad 各多少毫秒或多少比例拍长),RhythmConfig 等着填;二是上面说的**长按阈值与短按结算时机**;三是**同一拍内的第二次输入**如何处理——ComboWindow 不再按拍自动补槽,但如果玩家在同一拍内按了两次,两次输入是否都独立判定、是否都进入窗口、第二次是否直接按 Miss 处理,仍需要定义。蓄力/爆气长按期间不补 Ø;这段时间窗口应冻结或在释放后按动作规则清空。
整体看,这份策划书和这套架构咬合得相当好——因为它本质上已经是一份"以拍为时基、以数据表驱动"的设计,你要做的就是让代码结构诚实地反映这个事实:一个时钟(RhythmManager)、一张表(ActionData 资源集)、一条链(Resolver 优先级)、一条管线(乘区结算),其余全是订阅者。如果你想,下一步我可以把阶段 1 的四个地基模块(含单元测试)完整写出来。

View File

@@ -17,6 +17,9 @@ config/icon="res://icon.svg"
[autoload]
EventBus="*res://autoload/event_bus.gd"
RhythmManager="*res://autoload/rhythm_manager.gd"
CombatManager="*res://autoload/combat_manager.gd"
MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd"
MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd"
MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd"
@@ -27,11 +30,49 @@ enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg")
[input]
jump={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
move_left={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
move_right={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
combo_w={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
combo_a={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
combo_s={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
combo_space={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
combo_d={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
[layer_names]
2d_physics/layer_1="world"
2d_physics/layer_2="player_hurtbox"
2d_physics/layer_3="enemy_hurtbox"
2d_physics/layer_4="player_hitbox"
2d_physics/layer_5="enemy_hitbox"
[physics]

BIN
resource/.DS_Store vendored

Binary file not shown.

View File

View File

View File

View File

33
resources/action_data.gd Normal file
View File

@@ -0,0 +1,33 @@
class_name ActionData
extends Resource
@export var id: StringName
@export var display_name: String
@export var input_pattern: Array[StringName] = []
@export var required_state: StringName = &"any"
@export var base_cost := 0.0
@export var damage_mult := 1.0
@export var move_mult_x := 0.0
@export var move_mult_y := 0.0
@export var action_beats := 1.0
@export var hit_type: StringName = &"melee"
@export var range := 0.0
@export var target_type: StringName = &"single"
@export var armor_level := 0
@export var clear_window := true
@export var can_chain := false
@export var special: StringName = &""
@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
@export var animation: StringName
@export var energy_cost := 0.0
@export var energy_reward := 0.0
@export var spawns_projectile := false
@export var projectile_scene: PackedScene
@export var damage := 10
@export var cancel_window := Vector2(0.3, 0.6)
@export var has_super_armor := false
@export var displacement: StringName = &""

View File

@@ -0,0 +1 @@
uid://cooudhoob8dn4

View File

@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_a"
display_name = "A"
input_pattern = Array[StringName]([&"A"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a"
energy_reward = 1.0
displacement = &"left"
clear_window = false

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_a_space"
display_name = "A+SP"
input_pattern = Array[StringName]([&"A", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a_space"
displacement = &"left"
clear_window = true

View File

@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_aa"
display_name = "A+A"
input_pattern = Array[StringName]([&"A", &"A"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_aa"
energy_reward = 1.0
displacement = &"left"
clear_window = false

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_aa_space"
display_name = "A+A+SP"
input_pattern = Array[StringName]([&"A", &"A", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a_space_space"
displacement = &"left"
clear_window = true

View File

@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_aaa"
display_name = "A+A+A"
input_pattern = Array[StringName]([&"A", &"A", &"A"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_aaa"
energy_reward = 1.0
displacement = &"left"
clear_window = false

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_ad_space"
display_name = "A+D+SP"
input_pattern = Array[StringName]([&"A", &"D", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a_space_space"
displacement = &"right"
clear_window = true

View File

@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_d"
display_name = "D"
input_pattern = Array[StringName]([&"D"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a"
energy_reward = 1.0
displacement = &"right"
clear_window = false

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_d_space"
display_name = "D+SP"
input_pattern = Array[StringName]([&"D", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a_space"
displacement = &"right"
clear_window = true

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_d_space_space"
display_name = "D+SP+SP"
input_pattern = Array[StringName]([&"D", &"SP", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a_space_space"
displacement = &"right"
clear_window = true

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_da_space"
display_name = "D+A+SP"
input_pattern = Array[StringName]([&"D", &"A", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a_space_space"
displacement = &"left"
clear_window = true

View File

@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_dd"
display_name = "D+D"
input_pattern = Array[StringName]([&"D", &"D"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_aa"
energy_reward = 1.0
displacement = &"right"
clear_window = false

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_dd_space"
display_name = "D+D+SP"
input_pattern = Array[StringName]([&"D", &"D", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_a_space_space"
displacement = &"right"
clear_window = true

View File

@@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_ddd"
display_name = "D+D+D"
input_pattern = Array[StringName]([&"D", &"D", &"D"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_aaa"
energy_reward = 1.0
displacement = &"right"
clear_window = false

View File

@@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_s"
display_name = "S"
input_pattern = Array[StringName]([&"S"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_s"
clear_window = false

View File

@@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/combat/player_projectile.tscn" id="2"]
[resource]
script = ExtResource("1")
id = &"skill_s_projectile_1"
display_name = "S+SP"
input_pattern = Array[StringName]([&"S", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_s_projectile"
energy_cost = 3.0
spawns_projectile = true
projectile_scene = ExtResource("2")
clear_window = false
can_chain = true

View File

@@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/combat/player_projectile.tscn" id="2"]
[resource]
script = ExtResource("1")
id = &"skill_s_projectile_2"
display_name = "S+SP+SP"
input_pattern = Array[StringName]([&"S", &"SP", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_s_projectile"
energy_cost = 2.0
spawns_projectile = true
projectile_scene = ExtResource("2")
clear_window = false
can_chain = true

View File

@@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/combat/player_projectile.tscn" id="2"]
[resource]
script = ExtResource("1")
id = &"skill_s_projectile_3"
display_name = "S+SP+SP+SP"
input_pattern = Array[StringName]([&"S", &"SP", &"SP", &"SP"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_s_projectile"
energy_cost = 1.0
spawns_projectile = true
projectile_scene = ExtResource("2")
clear_window = false
can_chain = true

View File

@@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_s"
display_name = "S"
input_pattern = Array[StringName]([&"S", &"S"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_s"
clear_window = false

View File

@@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_s"
display_name = "S"
input_pattern = Array[StringName]([&"S", &"S", &"S"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_s"
clear_window = false

View File

@@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_s"
display_name = "S"
input_pattern = Array[StringName]([&"S", &"S", &"S", &"S"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_s"
clear_window = false

View File

@@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_w"
display_name = "W"
input_pattern = Array[StringName]([&"W"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_w"
clear_window = false

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_wa"
display_name = "W+A"
input_pattern = Array[StringName]([&"W", &"A"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_wa"
displacement = &"left"
clear_window = false

View File

@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"skill_wd"
display_name = "W+D"
input_pattern = Array[StringName]([&"W", &"D"])
startup_beats = 0.25
active_beats = 0.25
recovery_beats = 0.5
cancel_from = 0.5
animation = &"warrior_wa"
displacement = &"right"
clear_window = false

27
resources/beat_chart.gd Normal file
View File

@@ -0,0 +1,27 @@
class_name BeatChart
extends Resource
@export var chart_id: StringName = &""
@export var total_beats := 0
@export var tracks: Array = []
func all_events() -> Array:
var result: Array = []
for track in tracks:
if not track is Resource:
continue
for event in track.call("sorted_events"):
result.append(event)
result.sort_custom(func(a: Resource, b: Resource) -> bool:
var a_position := float(a.call("beat_position"))
var b_position := float(b.call("beat_position"))
if is_equal_approx(a_position, b_position):
return str(a.get("event_type")) < str(b.get("event_type"))
return a_position < b_position
)
return result
func is_empty() -> bool:
return all_events().is_empty()

View File

@@ -0,0 +1 @@
uid://b0jn7bu4w1co7

26
resources/chart_event.gd Normal file
View File

@@ -0,0 +1,26 @@
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])

View File

@@ -0,0 +1 @@
uid://bsbmwl7n6uil5

17
resources/chart_track.gd Normal file
View File

@@ -0,0 +1,17 @@
class_name ChartTrack
extends Resource
@export var track_id: StringName = &""
@export var track_type: StringName = &""
@export var events: Array = []
func sorted_events() -> Array:
var result: Array = []
for event in events:
if event is Resource:
result.append(event)
result.sort_custom(func(a: Resource, b: Resource) -> bool:
return float(a.call("beat_position")) < float(b.call("beat_position"))
)
return result

View File

@@ -0,0 +1 @@
uid://t7etuk7uw3ns

BIN
scenes/.DS_Store vendored

Binary file not shown.

View File

@@ -8,18 +8,22 @@ const GRAVITY := 1200.0
@export var attack_duration := 0.4
@export var attack_lunge_duration := 0.18
@export var attack_lunge_speed := 220.0
@export var air_attack_duration := 0.45
@export var air_attack_lunge_duration := 0.22
@export var air_attack_lunge_speed := 260.0
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var character_sprite: Sprite2D = $CharacterSprite
enum State { IDLE, WALK, JUMP, LAND, ATTACK }
enum State { IDLE, WALK, JUMP, LAND, ATTACK, AIR_ATTACK }
var anim_map := {
State.IDLE: "idle",
State.WALK: "idle",
State.JUMP: "jump",
State.LAND: "idle",
State.ATTACK: "挥砍",
State.IDLE: "warrior_idle",
State.WALK: "warrior_idle",
State.JUMP: "warrior_w",
State.LAND: "warrior_idle",
State.ATTACK: "warrior_a",
State.AIR_ATTACK: "warrior_a",
}
var attack_direction := Vector2.RIGHT
var attack_lunge_time_left := 0.0
@@ -55,7 +59,7 @@ func handle_air_time(delta: float) -> void:
height_speed -= GRAVITY * delta
func handle_attack_time(delta: float) -> void:
if state != State.ATTACK:
if state != State.ATTACK and state != State.AIR_ATTACK:
return
velocity.y = 0.0
attack_time_left -= delta
@@ -68,7 +72,7 @@ func handle_attack_time(delta: float) -> void:
velocity.x = 0.0
func handle_movement() -> void:
if state == State.JUMP or state == State.ATTACK:
if state == State.JUMP or state == State.ATTACK or state == State.AIR_ATTACK:
return
if absf(velocity.x) > 0.0:
state = State.WALK
@@ -97,6 +101,9 @@ func can_jump() -> bool:
func can_attack() -> bool:
return state == State.IDLE or state == State.WALK
func can_air_attack() -> bool:
return state == State.IDLE or state == State.WALK
func start_jump() -> void:
state = State.JUMP
height_speed = jump_intensity
@@ -112,3 +119,25 @@ func start_directional_attack(direction: Vector2) -> void:
attack_time_left = attack_duration
attack_lunge_time_left = attack_lunge_duration
velocity = Vector2(attack_x * attack_lunge_speed, 0.0)
func start_air_attack() -> void:
start_directional_air_attack(heading)
func start_directional_air_attack(direction: Vector2) -> void:
var attack_x := -1.0 if direction.x < 0.0 else 1.0
attack_direction = Vector2(attack_x, 0.0)
heading = Vector2.RIGHT if attack_x > 0.0 else Vector2.LEFT
state = State.AIR_ATTACK
attack_time_left = air_attack_duration
attack_lunge_time_left = air_attack_lunge_duration
velocity = Vector2(attack_x * air_attack_lunge_speed, 0.0)
func begin_attack_motion(duration: float, next_velocity: Vector2) -> void:
attack_lunge_time_left = maxf(0.0, duration)
velocity = next_velocity
func stop_attack_motion() -> void:
attack_lunge_time_left = 0.0
velocity = Vector2.ZERO

View File

@@ -1,23 +1,78 @@
class_name Player
extends Character
signal combo_window_changed(slots: Array)
signal combo_window_cleared(reason: String)
signal charge_changed(current: float, maximum: float, ready: bool, active: bool)
signal energy_changed(current: int, maximum: int)
signal health_changed(current: int, maximum: int)
signal skill_requested(skill_id: String)
signal projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2)
@export var combo_clear_display_time := 0.35
@export var max_health := 100
@export var current_health := 100
@export var max_energy := 10
@export var current_energy := 0
@onready var state_machine: Node = $StateMachine
@onready var input_component: Node = $InputComponent
@onready var combo_window: Node = $ComboWindow
@onready var action_controller: Node = $ActionController
@onready var motion_executor: Node = $MotionExecutor
@onready var burst_component: Node = $BurstComponent
@onready var charge_component: Node = $ChargeComponent
@onready var energy_component: Node = $EnergyComponent
@onready var health_component: Node = $HealthComponent
@onready var damage_receiver: Area2D = $DamageReceiver
var last_requested_skill_id := ""
var current_skill_animation := ""
var _held_symbols: Dictionary = {}
func _ready() -> void:
combo_window.clear_display_time = combo_clear_display_time
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)
combo_window.combo_updated.connect(_on_combo_updated)
combo_window.combo_cleared.connect(_on_combo_cleared)
charge_component.charge_changed.connect(_on_charge_component_changed)
charge_component.release_requested.connect(_execute_charge_release)
energy_component.energy_changed.connect(_on_energy_component_changed)
health_component.health_changed.connect(_on_health_component_changed)
damage_receiver.damage_received.connect(_on_damage_received)
energy_component.set_values(current_energy, max_energy)
health_component.set_values(current_health, max_health)
func _process(delta: float) -> void:
charge_component.tick(delta, state == State.IDLE)
if charge_component.is_active():
state = State.IDLE
attack_time_left = 0.0
stop_attack_motion()
func _input(event: InputEvent) -> void:
if input_component.handle_input_event(event):
_mark_input_handled()
func _unhandled_input(event: InputEvent) -> void:
if input_component.handle_input_event(event):
_mark_input_handled()
func handle_input() -> void:
if Input.is_action_just_pressed("ui_left"):
judge_rhythm_action("left")
if can_attack():
start_directional_attack(Vector2.LEFT)
if charge_component.is_active():
velocity = Vector2.ZERO
return
if Input.is_action_just_pressed("ui_right"):
judge_rhythm_action("right")
if can_attack():
start_directional_attack(Vector2.RIGHT)
return
if Input.is_action_just_pressed("jump"):
judge_rhythm_action("jump")
if can_jump():
start_jump()
if state == State.IDLE or state == State.WALK:
velocity.x = 0.0
_apply_horizontal_movement()
func set_heading() -> void:
if velocity.x > 0.0:
@@ -25,7 +80,274 @@ func set_heading() -> void:
elif velocity.x < 0.0:
heading = Vector2.LEFT
func judge_rhythm_action(action_name: String) -> void:
var conductor: Node = get_tree().get_first_node_in_group("rhythm_conductor")
if conductor != null and conductor.has_method("judge_action"):
conductor.call("judge_action", action_name)
func get_combo_slots() -> Array[StringName]:
return combo_window.get_slots()
func get_energy() -> int:
current_energy = energy_component.current
return current_energy
func get_max_energy() -> int:
max_energy = energy_component.maximum
return max_energy
func get_health() -> int:
current_health = health_component.current
return current_health
func get_max_health() -> int:
max_health = health_component.maximum
return max_health
func get_charge() -> float:
return charge_component.value
func get_max_charge() -> float:
return charge_component.maximum()
func is_charge_active() -> bool:
return charge_component.is_active()
func is_charge_ready() -> bool:
return charge_component.is_ready()
func submit_combo_input(symbol: String, forced_rating := "") -> String:
var data := _symbol_to_intent_data(symbol)
if data.is_empty():
return ""
var intent: RefCounted = load("res://scenes/components/input_intent.gd").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 {}
func flush_pending_combo_clear() -> void:
combo_window.flush_pending_clear()
func _play_skill_animation(animation_name: String, displacement_direction := Vector2.ZERO, skill: Resource = null) -> void:
current_skill_animation = animation_name
anim_map[State.ATTACK] = animation_name
state = State.ATTACK
state_machine.change_state(state)
attack_time_left = _animation_length(animation_name)
if displacement_direction == Vector2.ZERO:
stop_attack_motion()
else:
heading = displacement_direction
if skill != null:
motion_executor.execute(skill, displacement_direction, _rhythm_beat_time(), attack_lunge_speed)
attack_time_left = motion_executor.duration
begin_attack_motion(motion_executor.duration, motion_executor.velocity)
if animation_player != null and animation_player.has_animation(animation_name):
animation_player.play(animation_name)
func _skill_displacement_direction(skill: Resource) -> Vector2:
match StringName(str(skill.get("displacement"))):
&"left":
return Vector2.LEFT
&"right":
return Vector2.RIGHT
return Vector2.ZERO
func _begin_charge_hold(symbol: StringName, direction: Vector2) -> void:
charge_component.begin_hold(symbol, direction)
func _finish_charge_hold(symbol: StringName) -> void:
charge_component.finish_hold(symbol)
func _execute_charge_release(skill_id: StringName, direction: Vector2) -> void:
last_requested_skill_id = str(skill_id)
current_skill_animation = "warrior_charge_release"
skill_requested.emit(last_requested_skill_id)
_play_skill_animation(current_skill_animation, direction)
func _cancel_missed_direction_action() -> void:
stop_attack_motion()
attack_time_left = 0.0
state = State.IDLE
current_skill_animation = "warrior_idle"
if animation_player != null and animation_player.has_animation("warrior_idle"):
animation_player.play("warrior_idle")
func _apply_horizontal_movement() -> void:
if state != State.IDLE and state != State.WALK:
return
var direction: float = input_component.get_horizontal_axis()
if direction < 0.0:
heading = Vector2.LEFT
elif direction > 0.0:
heading = Vector2.RIGHT
velocity.x = direction * speed
func _animation_length(animation_name: String) -> float:
if animation_player != null and animation_player.has_animation(animation_name):
return maxf(0.1, animation_player.get_animation(animation_name).length)
return attack_duration
func _request_projectile(skill: Resource) -> void:
var spawn_position := global_position + Vector2(heading.x * 36.0, -30.0)
var projectile_scene := skill.get("projectile_scene") as PackedScene
projectile_requested.emit(projectile_scene, spawn_position, heading)
_event_bus().emit_signal("projectile_requested", projectile_scene, spawn_position, heading)
func _rhythm_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
func _on_input_intent_created(intent) -> void:
if intent.is_pressed():
_held_symbols[intent.symbol] = true
elif intent.is_released():
_held_symbols.erase(intent.symbol)
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) -> 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)
var displacement_direction := _skill_displacement_direction(action)
if displacement_direction != Vector2.ZERO:
heading = displacement_direction
_play_skill_animation(current_skill_animation)
if intent.is_pressed() and _is_symbol_held(intent.symbol) and (intent.symbol == &"A" or intent.symbol == &"D"):
_begin_charge_hold(intent.symbol, Vector2.LEFT if intent.symbol == &"A" else Vector2.RIGHT)
func _on_action_active_started(action: Resource, intent) -> void:
current_energy = energy_component.current
_event_bus().emit_signal("skill_executed", action, StringName(str(intent.judgement.get("label", "perfect"))))
_start_skill_motion(action)
if bool(action.get("spawns_projectile")):
_request_projectile(action)
func _on_action_finished(_action: Resource) -> void:
_set_idle_presentation()
func _on_action_rejected(intent, reason: StringName) -> void:
if reason == &"miss" and (intent.symbol == &"A" or intent.symbol == &"D"):
_cancel_missed_direction_action()
func _start_skill_motion(action: Resource) -> void:
var displacement_direction := _skill_displacement_direction(action)
if displacement_direction == Vector2.ZERO:
return
heading = displacement_direction
motion_executor.execute(action, displacement_direction, _rhythm_beat_time(), attack_lunge_speed)
attack_time_left = maxf(attack_time_left, motion_executor.duration)
begin_attack_motion(motion_executor.duration, motion_executor.velocity)
func _is_symbol_held(symbol: StringName) -> bool:
return bool(_held_symbols.get(symbol, false))
func _set_idle_presentation() -> void:
stop_attack_motion()
attack_time_left = 0.0
state = State.IDLE
state_machine.change_state(state)
current_skill_animation = "warrior_idle"
if animation_player != null and animation_player.has_animation("warrior_idle"):
animation_player.play("warrior_idle")
func _on_combo_updated(slots: Array[StringName]) -> void:
combo_window_changed.emit(slots)
func _on_combo_cleared(reason: StringName) -> void:
combo_window_cleared.emit(str(reason))
func _on_charge_component_changed(current: float, maximum: float, ready: bool, active: bool) -> void:
charge_changed.emit(current, maximum, ready, active)
_event_bus().emit_signal("player_charge_changed", current, maximum, ready, active)
func _on_energy_component_changed(current: int, maximum: int) -> void:
current_energy = current
max_energy = maximum
energy_changed.emit(current, maximum)
func _on_health_component_changed(current: int, maximum: int) -> void:
current_health = current
max_health = maximum
health_changed.emit(current, maximum)
func _on_damage_received(amount: int, _hit_type: StringName, _from: Vector2) -> void:
health_component.apply_damage(amount)
func _rating_result(label: StringName, offset_ms: float) -> Dictionary:
return {
"label": str(label),
"diff": offset_ms / 1000.0,
"abs_diff": absf(offset_ms / 1000.0),
}
func _mark_input_handled() -> void:
var viewport := get_viewport()
if viewport != null:
viewport.set_input_as_handled()
func _event_bus() -> Node:
var root := get_tree().root
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
class_name ChartRunner
extends Node
signal chart_event_upcoming(event: Resource, time_to_event: float)
signal chart_event_triggered(event: Resource)
signal chart_reset(chart_id: StringName)
signal chart_finished(chart_id: StringName)
const UPCOMING_TIME_EPSILON := 0.02
@export var chart: Resource
@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: Resource) -> void:
chart = next_chart
reset()
func reset() -> void:
_upcoming_keys.clear()
_triggered_keys.clear()
var chart_id := &""
if chart != null:
chart_id = StringName(str(chart.get("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: Resource in chart.call("all_events"):
var event_time := float(event.call("time_seconds", beat_time))
var time_to_event := event_time - song_time
var lead_time := maxf(0.0, float(event.get("lead_beats"))) * beat_time
var event_key: StringName = event.call("key")
if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time + UPCOMING_TIME_EPSILON:
_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: Resource, 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: Resource) -> 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")

View File

@@ -0,0 +1 @@
uid://d1st4d2h1bt1m

View File

@@ -0,0 +1,170 @@
class_name ActionResolver
extends Node
const ACTION_DIR := "res://resources/actions"
static var _loaded := false
static var _actions_by_pattern: Dictionary = {}
static var _actions_by_id: Dictionary = {}
static var _space_priority_labels: Array[StringName] = [
&"charge_release",
&"burst",
&"counter_projectile",
&"blade_chain",
&"state_specific",
&"exact_pattern",
&"fallback",
]
func resolve_window(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
return ActionResolver.resolve(window, state_machine, context)
func resolve_text_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
return ActionResolver.resolve_pattern(pattern, state_machine, context)
static func resolve(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
return resolve_pattern(window.get_contiguous_pattern(), state_machine, context)
static func resolve_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
_ensure_loaded()
var key := _normalize_pattern(pattern)
if key.ends_with("SP"):
var priority_result := _resolve_space_priority(key, state_machine, context)
if priority_result != null:
return priority_result
var candidates: Array = _actions_by_pattern.get(key, [])
var state := _state_name(state_machine, context)
for action: Resource in candidates:
if _can_use_in_state(action, state):
return action
return null
static func get_action(action_id: StringName) -> Resource:
_ensure_loaded()
return _actions_by_id.get(action_id, null) as Resource
static func space_priority_labels() -> Array[StringName]:
return _space_priority_labels.duplicate()
static func reload(action_dir := ACTION_DIR) -> void:
_loaded = true
_actions_by_pattern.clear()
_actions_by_id.clear()
_load_dir(action_dir)
static func clear_cache() -> void:
_actions_by_pattern.clear()
_actions_by_id.clear()
_loaded = false
static func _ensure_loaded() -> void:
if not _loaded:
reload()
static func _load_dir(action_dir: String) -> void:
var dir := DirAccess.open(action_dir)
if dir == null:
return
var files := dir.get_files()
files.sort()
for file_name: String in files:
if not file_name.ends_with(".tres"):
continue
var action: Resource = load("%s/%s" % [action_dir, file_name])
if action == null or not action.get("input_pattern") is Array:
continue
_register_action(action)
static func _register_action(action: Resource) -> void:
var key := _pattern_key(action.get("input_pattern"))
if key.is_empty():
return
var candidates: Array = _actions_by_pattern.get(key, [])
candidates.append(action)
_actions_by_pattern[key] = candidates
var id := StringName(str(action.get("id")))
if not id.is_empty():
_actions_by_id[id] = action
static func _resolve_space_priority(key: String, state_machine: Variant, context: Dictionary) -> Resource:
var state := _state_name(state_machine, context)
for action_id_key: String in [
"charge_release_action_id",
"burst_action_id",
"counter_action_id",
"blade_chain_action_id",
]:
if not context.has(action_id_key):
continue
if action_id_key == "counter_action_id" and not bool(context.get("counter_ready", false)):
continue
if action_id_key == "blade_chain_action_id" and not bool(context.get("blade_chain_active", false)):
continue
var explicit_action := get_action(StringName(str(context[action_id_key])))
if explicit_action != null and _can_use_in_state(explicit_action, state):
return explicit_action
var candidates: Array = _actions_by_pattern.get(key, [])
for action: Resource in candidates:
if _can_use_in_state(action, state):
return action
var suffix_action := _resolve_trailing_space_suffix(key, state)
if suffix_action != null:
return suffix_action
return null
static func _resolve_trailing_space_suffix(key: String, state: StringName) -> Resource:
var best_action: Resource = null
var best_length := 0
for candidate_key: String in _actions_by_pattern.keys():
if candidate_key == key:
continue
if not candidate_key.ends_with("SP"):
continue
if candidate_key.length() <= best_length:
continue
if not key.ends_with(candidate_key):
continue
for action: Resource in _actions_by_pattern.get(candidate_key, []):
if _can_use_in_state(action, state):
best_action = action
best_length = candidate_key.length()
break
return best_action
static func _pattern_key(pattern: Array[StringName]) -> String:
var key := ""
for symbol: StringName in pattern:
key += str(symbol)
return _normalize_pattern(key)
static func _normalize_pattern(pattern: String) -> String:
return pattern.replace(" ", "").to_upper()
static func _state_name(state_machine: Variant, context: Dictionary) -> StringName:
if context.has("state"):
return StringName(str(context["state"]))
if state_machine != null and state_machine.has_method("get_current_state_name"):
return StringName(str(state_machine.call("get_current_state_name")))
return &"any"
static func _can_use_in_state(action: Resource, state: StringName) -> bool:
var required := StringName(str(action.get("required_state")))
return required.is_empty() or required == &"any" or state == &"any" or required == state

View File

@@ -0,0 +1 @@
uid://dyfn38jkq6ld8

View File

@@ -0,0 +1,42 @@
class_name PlayerProjectile
extends Node2D
const EFFECT_TEXTURE := preload("res://assets/art/effects/effect_sheet.png")
const FRAME_COUNT := 4
const FRAME_TIME := 0.06
const LIFE_TIME := 1.2
var direction := Vector2.RIGHT
var speed := 360.0
var _age := 0.0
var _sprite: Sprite2D
func _init() -> void:
_create_sprite()
func _ready() -> void:
add_to_group("player_projectiles")
if _sprite == null:
_create_sprite()
func _create_sprite() -> void:
_sprite = Sprite2D.new()
_sprite.texture = EFFECT_TEXTURE
_sprite.hframes = 6
_sprite.vframes = 2
_sprite.centered = true
_sprite.scale = Vector2(2.0, 2.0)
add_child(_sprite)
func _process(delta: float) -> void:
_age += delta
position += direction.normalized() * speed * delta
_sprite.frame = int(_age / FRAME_TIME) % FRAME_COUNT
if direction.x < 0.0:
_sprite.flip_h = true
if _age >= LIFE_TIME:
queue_free()

View File

@@ -0,0 +1 @@
uid://bwjk27wxb6p20

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/combat/player_projectile.gd" id="1"]
[node name="PlayerProjectile" type="Node2D"]
script = ExtResource("1")

View File

@@ -0,0 +1,343 @@
class_name ActionController
extends Node
signal action_started(action: Resource, intent)
signal action_active_started(action: Resource, intent)
signal action_active_finished(action: Resource)
signal action_finished(action: Resource)
signal action_rejected(intent, 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
var pending_intent
var phase_elapsed := 0.0
var phase_duration := 0.0
func submit_intent(intent) -> 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
_emit_judgement_feedback(judged_intent)
if _judgement_label(judged_intent) == &"miss":
_interrupt_current_action_for_miss()
_record_miss(judged_intent)
action_rejected.emit(judged_intent, &"miss")
return
if _window_is_showing_pending_clear():
_store_pending_intent(judged_intent)
return
if phase == Phase.IDLE:
_consume_intent(judged_intent)
return
if _can_cancel_now():
_reset_to_idle()
_consume_intent(judged_intent)
return
_store_pending_intent(judged_intent)
func _physics_process(delta: float) -> void:
if phase == Phase.IDLE:
if pending_intent != null and not _window_is_showing_pending_clear():
var idle_intent = pending_intent
pending_intent = null
_consume_intent(idle_intent)
return
phase_elapsed += delta
if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now():
var interrupted_action := current_action
var next_intent = pending_intent
pending_intent = null
_reset_to_idle()
action_finished.emit(interrupted_action)
_consume_intent(next_intent)
return
if phase_elapsed < phase_duration:
return
var carryover := maxf(0.0, phase_elapsed - phase_duration)
match phase:
Phase.STARTUP:
_enter_phase(Phase.ACTIVE)
phase_elapsed = carryover
if not _activate_current_action():
return
Phase.ACTIVE:
action_active_finished.emit(current_action)
_enter_phase(Phase.RECOVERY)
phase_elapsed = carryover
Phase.RECOVERY:
var finished_action := current_action
var next_intent = pending_intent
pending_intent = null
_reset_to_idle()
_clear_window_after_action(finished_action)
action_finished.emit(finished_action)
if next_intent != null:
_consume_intent(next_intent)
func _consume_intent(intent) -> void:
_start_action(intent)
func _start_action(intent) -> void:
if combo_window == null or action_resolver == null:
action_rejected.emit(intent, &"missing_component")
return
if _should_restart_blade_chain(intent):
combo_window.clear(&"blade_chain_restart")
_record_intent_symbol(intent)
var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context())
if action == null:
action = _resolve_fallback_action(intent)
if action == null:
action_rejected.emit(intent, &"unresolved")
return
current_action = action
current_intent = intent
_enter_phase(Phase.STARTUP)
action_started.emit(action, intent)
func _activate_current_action() -> bool:
if current_action == null or current_intent == null:
_reset_to_idle()
return false
if action_executor == null:
action_rejected.emit(current_intent, &"missing_component")
_reset_to_idle()
return false
if not action_executor.execute(current_action, StringName(str(current_intent.judgement.get("label", "perfect"))), burst_component):
combo_window.flush_pending_clear()
combo_window.clear(&"action_failed")
action_rejected.emit(current_intent, &"execution_failed")
_reset_to_idle()
return false
action_active_started.emit(current_action, current_intent)
return true
func _record_intent_symbol(intent) -> void:
if combo_window.has_pending_clear():
combo_window.flush_pending_clear()
combo_window.record(intent.symbol)
func _record_miss(_intent) -> void:
if combo_window != null:
if combo_window.has_pending_clear():
combo_window.flush_pending_clear()
combo_window.record(&"Ø")
func _interrupt_current_action_for_miss() -> void:
pending_intent = null
if phase == Phase.IDLE:
return
var interrupted_action := current_action
_reset_to_idle()
if interrupted_action != null:
action_finished.emit(interrupted_action)
func _resolve_fallback_action(intent) -> Resource:
if action_resolver == null:
return null
if not action_resolver.has_method("resolve_text_pattern"):
return null
return action_resolver.call("resolve_text_pattern", str(intent.symbol), state_machine, _resolver_context()) as Resource
func _clear_window_after_action(action: Resource) -> void:
if combo_window == null or action == null:
return
if bool(action.get("clear_window")):
combo_window.clear(StringName("skill:%s" % action.get("id")))
func _store_pending_intent(intent) -> void:
if pending_intent != null:
action_rejected.emit(pending_intent, &"replaced")
pending_intent = intent
func _should_restart_blade_chain(intent) -> bool:
if combo_window == null:
return false
if intent.symbol != &"S":
return false
var pattern: String = combo_window.get_contiguous_pattern()
return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP"
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 _window_is_showing_pending_clear() -> bool:
return combo_window != null and combo_window.has_pending_clear()
func _ensure_judged(intent):
if not intent.judgement.is_empty():
return intent.with_judgement(_judgement_with_defaults(intent.judgement))
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(_judgement_with_defaults(rhythm.call("judge", intent.timestamp_ms)))
return intent.with_judgement(_judgement_with_defaults({"label": "perfect", "diff": 0.0, "abs_diff": 0.0}))
func _judgement_label(intent) -> StringName:
return StringName(str(intent.judgement.get("label", "miss")))
func _emit_judgement_feedback(intent) -> void:
var rating := _judgement_with_defaults(intent.judgement)
var action_name: StringName = intent.rhythm_action if not intent.rhythm_action.is_empty() else intent.symbol
rating["action"] = action_name
var label := StringName(str(rating.get("label", "miss")))
var diff_ms := float(rating.get("diff", INF)) * 1000.0
var bus := _event_bus_or_null()
if bus == null:
return
bus.emit_signal("judgement_made", label, diff_ms)
bus.emit_signal("action_judged", action_name, rating)
func _judgement_with_defaults(judgement: Dictionary) -> Dictionary:
var rating := judgement.duplicate()
var label := StringName(str(rating.get("label", "miss")))
rating["label"] = str(label)
if not rating.has("diff"):
rating["diff"] = 0.0
if not rating.has("abs_diff"):
rating["abs_diff"] = absf(float(rating.get("diff", 0.0)))
if not rating.has("color"):
rating["color"] = _judgement_color(label)
return rating
func _judgement_color(label: StringName) -> Color:
match label:
&"perfect":
return Color("00f2ff")
&"good":
return Color("ffffff")
&"bad":
return Color("ffaa00")
return Color("ff0055")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
var root := get_tree().root
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _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 &""
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

View File

@@ -0,0 +1 @@
uid://bk4dutttdieeg

View File

@@ -0,0 +1,42 @@
class_name ActionExecutor
extends Node
signal action_executed(action: Resource, judgement: StringName)
signal action_failed(action: Resource, reason: StringName)
@export var energy_component_path: NodePath
@export var damage_emitter_path: NodePath
@onready var _energy_component: Node = get_node_or_null(energy_component_path)
@onready var _damage_emitter: Node = get_node_or_null(damage_emitter_path)
func execute(action: Resource, judgement: StringName, burst_component: Variant = null) -> bool:
if action == null:
action_failed.emit(action, &"missing_action")
return false
var cost := _resolve_cost(action, burst_component)
if _energy_component != null and not _energy_component.spend(cost):
action_failed.emit(action, &"insufficient_energy")
return false
if _damage_emitter != null and _damage_emitter.has_method("configure_hit"):
_damage_emitter.configure_hit(action, {"label": str(judgement)})
var reward := int(action.get("energy_reward"))
if reward != 0 and _energy_component != null:
_energy_component.change(reward)
action_executed.emit(action, judgement)
return true
func _resolve_cost(action: Resource, burst_component: Variant) -> float:
var combat := _combat_manager_or_null()
if combat != null:
return float(combat.call("resolve_cost", action, burst_component))
var base_cost := float(action.get("base_cost"))
return base_cost if base_cost > 0.0 else float(action.get("energy_cost"))
func _combat_manager_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("CombatManager")

View File

@@ -0,0 +1 @@
uid://cxcw3mad0gewc

View File

@@ -0,0 +1,68 @@
class_name BurstComponent
extends Node
signal burst_changed(burst_ready: bool, active: bool, cooldown: int)
@export var active_beats := 16
@export var cooldown_beats := 4
var burst_ready := false
var active := false
var cooldown := 0
var _beats_left := 0
func _ready() -> void:
var rhythm := get_tree().root.get_node_or_null("RhythmManager")
if rhythm != null and not rhythm.is_connected("beat_ticked", _on_beat_ticked):
rhythm.connect("beat_ticked", _on_beat_ticked)
func set_ready(value: bool) -> void:
if active or cooldown > 0:
burst_ready = false
else:
burst_ready = value
burst_changed.emit(burst_ready, active, cooldown)
func activate() -> bool:
if not burst_ready or active or cooldown > 0:
return false
burst_ready = false
active = true
_beats_left = active_beats
_set_rhythm_scale(1.25)
burst_changed.emit(burst_ready, active, cooldown)
return true
func damage_mult(_action: Resource = null) -> float:
return 1.2 if active else 1.0
func cost_mult(_action: Resource = null) -> float:
return 0.0 if active else 1.0
func move_mult(_action: Resource = null) -> float:
return 1.0
func _on_beat_ticked(_beat_index: int) -> void:
if active:
_beats_left -= 1
if _beats_left <= 0:
active = false
cooldown = cooldown_beats
_set_rhythm_scale(1.0)
burst_changed.emit(burst_ready, active, cooldown)
elif cooldown > 0:
cooldown -= 1
burst_changed.emit(burst_ready, active, cooldown)
func _set_rhythm_scale(scale: float) -> void:
var rhythm := get_tree().root.get_node_or_null("RhythmManager")
if rhythm != null:
rhythm.set("judgement_scale", scale)

View File

@@ -0,0 +1 @@
uid://vsrv3pov77hh

View File

@@ -0,0 +1,126 @@
class_name ChargeComponent
extends Node
signal charge_changed(current: float, maximum: float, charge_ready: bool, active: bool)
signal release_requested(skill_id: StringName, direction: Vector2)
@export var charge_duration := 1.1
@export var animation_player_path: NodePath
@export var effect_sprite_path: NodePath
var value := 0.0
var charge_ready := false
var active := false
var _hold_symbol: StringName = &""
var _hold_direction := Vector2.ZERO
var _effect_time := 0.0
var _animation_time := 0.0
@onready var _animation_player: AnimationPlayer = get_node_or_null(animation_player_path) as AnimationPlayer
@onready var _effect_sprite: Sprite2D = get_node_or_null(effect_sprite_path) as Sprite2D
func begin_hold(symbol: StringName, direction: Vector2) -> void:
_hold_symbol = symbol
_hold_direction = direction
func finish_hold(symbol: StringName) -> void:
if _hold_symbol != symbol:
return
var release_ready := active and charge_ready
var release_direction := _hold_direction
var release_skill := &"skill_a_charge_release" if symbol == &"A" else &"skill_d_charge_release"
cancel()
if release_ready:
release_requested.emit(release_skill, release_direction)
func tick(delta: float, can_charge: bool) -> void:
if _hold_symbol.is_empty():
return
if not active:
if not can_charge:
return
_start()
if not active:
return
_update_charge_animation(delta)
value = minf(charge_duration, value + delta)
charge_ready = value >= charge_duration
_update_charge_effect(delta)
_emit_changed()
func cancel() -> void:
_hold_symbol = &""
_hold_direction = Vector2.ZERO
active = false
value = 0.0
charge_ready = false
_animation_time = 0.0
_set_effect_visible(false)
_emit_changed()
func is_active() -> bool:
return active
func is_ready() -> bool:
return charge_ready
func maximum() -> float:
return charge_duration
func _start() -> void:
active = true
value = 0.0
charge_ready = false
_effect_time = 0.0
_animation_time = 0.0
_play_charge_animation(&"warrior_charge_intro")
_update_charge_effect(0.0)
_emit_changed()
func _update_charge_effect(delta: float) -> void:
if _effect_sprite == null:
return
_effect_sprite.visible = active
if not active:
return
_effect_time += delta
_effect_sprite.frame = int(_effect_time * 12.0) % 5
func _update_charge_animation(delta: float) -> void:
_animation_time += delta
var intro_length := _animation_length(&"warrior_charge_intro")
if _animation_time < intro_length:
_play_charge_animation(&"warrior_charge_intro")
else:
_play_charge_animation(&"warrior_charge_loop")
func _play_charge_animation(animation_name: StringName) -> void:
if _animation_player != null and _animation_player.has_animation(animation_name) and _animation_player.current_animation != animation_name:
_animation_player.play(animation_name)
func _animation_length(animation_name: StringName) -> float:
if _animation_player != null and _animation_player.has_animation(animation_name):
return maxf(0.1, _animation_player.get_animation(animation_name).length)
return 0.1
func _set_effect_visible(is_visible: bool) -> void:
if _effect_sprite != null:
_effect_sprite.visible = is_visible
func _emit_changed() -> void:
charge_changed.emit(value, charge_duration, charge_ready, active)

View File

@@ -0,0 +1 @@
uid://417rdyr4hkco

View File

@@ -0,0 +1,108 @@
class_name ComboWindow
extends Node
signal combo_updated(inputs: Array[StringName])
signal combo_cleared(reason: StringName)
@export var size := 4
@export var clear_display_time := 0.35
var slots: Array[StringName] = []
var pending_clear_reason: StringName = &""
var _timer: Timer
func _ready() -> void:
_timer = Timer.new()
_timer.one_shot = true
_timer.timeout.connect(flush_pending_clear)
add_child(_timer)
func record(input: StringName) -> void:
if input.is_empty():
return
slots.append(input)
combo_updated.emit(get_slots())
_emit_bus_signal("combo_updated", [get_slots()])
if slots.size() >= size:
queue_clear(&"full")
func get_slots() -> Array[StringName]:
return slots.duplicate()
func has_pending_clear() -> bool:
return not pending_clear_reason.is_empty()
func consume_pending_clear_reason() -> StringName:
var reason := pending_clear_reason
pending_clear_reason = &""
return reason
func get_pattern() -> String:
var pattern := ""
for slot: StringName in slots:
if slot != &"Ø":
pattern += str(slot)
return pattern
func get_contiguous_pattern() -> String:
var pattern := ""
for index: int in range(slots.size() - 1, -1, -1):
var slot := slots[index]
if slot == &"Ø":
break
pattern = str(slot) + pattern
return pattern
func queue_clear(reason: StringName, delay := -1.0) -> void:
pending_clear_reason = reason
if _timer == null:
return
_timer.stop()
_timer.wait_time = clear_display_time if delay < 0.0 else delay
_timer.start()
func cancel_pending_clear() -> void:
pending_clear_reason = &""
if _timer != null:
_timer.stop()
func flush_pending_clear() -> void:
var reason := consume_pending_clear_reason()
if reason.is_empty():
return
if _timer != null:
_timer.stop()
clear(reason)
func clear(reason: StringName = &"") -> void:
slots.clear()
pending_clear_reason = &""
combo_cleared.emit(reason)
_emit_bus_signal("combo_cleared", [reason])
combo_updated.emit(get_slots())
_emit_bus_signal("combo_updated", [get_slots()])
func _emit_bus_signal(signal_name: StringName, args: Array) -> void:
if not is_inside_tree():
return
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal(signal_name, args[0])
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")

View File

@@ -0,0 +1 @@
uid://jgl00xkxwy2s

View File

@@ -0,0 +1,47 @@
class_name DamageEmitter
extends Area2D
@export var damage := 10
@export var hit_type: StringName = &"normal"
var action_context: Resource
var judgement_context: Dictionary = {}
func _ready() -> void:
area_entered.connect(_on_area_entered)
func configure_hit(action: Resource, judgement: Dictionary) -> void:
action_context = action
judgement_context = judgement.duplicate()
func _on_area_entered(receiver: Area2D) -> void:
if receiver.is_in_group("damage_receivers"):
var final_damage := _resolve_damage()
receiver.emit_signal("damage_received", final_damage, hit_type, global_position)
_event_bus().emit_signal("damage_dealt", receiver, final_damage, hit_type)
func _resolve_damage() -> int:
var combat := _combat_manager_or_null()
if combat == null:
return damage
return int(round(combat.call("resolve_damage", float(damage), action_context, judgement_context, null, null)))
func _event_bus() -> Node:
var root := get_tree().root
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _combat_manager_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("CombatManager")

View File

@@ -0,0 +1 @@
uid://sw8ppylne36n

View File

@@ -0,0 +1,23 @@
class_name DamageReceiver
extends Area2D
signal damage_received(amount: int, hit_type: StringName, from: Vector2)
func _ready() -> void:
add_to_group("damage_receivers")
func take_damage(amount: int, hit_type: StringName, from: Vector2) -> void:
damage_received.emit(amount, hit_type, from)
_event_bus().emit_signal("damage_dealt", self, amount, hit_type)
func _event_bus() -> Node:
var root := get_tree().root
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus

View File

@@ -0,0 +1 @@
uid://b1ljynf0b873n

View File

@@ -0,0 +1,54 @@
class_name EnergyComponent
extends Node
signal energy_changed(current: int, maximum: int)
@export var maximum := 10
@export var current := 0
func _ready() -> void:
_emit_changed()
func set_values(next_current: int, next_maximum: int) -> void:
maximum = max(1, next_maximum)
current = clampi(next_current, 0, maximum)
_emit_changed()
func set_current(next_current: int) -> void:
var clamped := clampi(next_current, 0, maximum)
if clamped == current:
return
current = clamped
_emit_changed()
func change(delta: int) -> void:
set_current(current + delta)
func spend(cost: float) -> bool:
var int_cost := int(ceil(cost))
if int_cost <= 0:
return true
if current < int_cost:
return false
set_current(current - int_cost)
return true
func _emit_changed() -> void:
energy_changed.emit(current, maximum)
_event_bus().emit_signal("player_energy_changed", float(current), float(maximum))
func _event_bus() -> Node:
var root := get_tree().root
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus

View File

@@ -0,0 +1 @@
uid://bwau6ud30k62u

View File

@@ -0,0 +1,49 @@
class_name HealthComponent
extends Node
signal health_changed(current: int, maximum: int)
signal depleted
@export var maximum := 100
@export var current := 100
func _ready() -> void:
_emit_changed()
func set_values(next_current: int, next_maximum: int) -> void:
maximum = max(1, next_maximum)
current = clampi(next_current, 0, maximum)
_emit_changed()
func apply_damage(amount: int) -> void:
if amount <= 0:
return
current = clampi(current - amount, 0, maximum)
_emit_changed()
if current == 0:
depleted.emit()
func heal(amount: int) -> void:
if amount <= 0:
return
current = clampi(current + amount, 0, maximum)
_emit_changed()
func _emit_changed() -> void:
health_changed.emit(current, maximum)
_event_bus().emit_signal("player_health_changed", current, maximum)
func _event_bus() -> Node:
var root := get_tree().root
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus

View File

@@ -0,0 +1 @@
uid://0svshg5yfjyg

View File

@@ -0,0 +1,65 @@
class_name InputComponent
extends Node
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
signal intent_created(intent)
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: RefCounted = InputIntentScript.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: RefCounted = InputIntentScript.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

View File

@@ -0,0 +1 @@
uid://c4n1p3g64qiqj

View File

@@ -0,0 +1,32 @@
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) -> RefCounted:
var script: Script = load("res://scenes/components/input_intent.gd")
var intent: RefCounted = script.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) -> RefCounted:
var copy: RefCounted = load("res://scenes/components/input_intent.gd").create(symbol, rhythm_action, event_type, timestamp_ms)
copy.judgement = next_judgement.duplicate()
return copy

View File

@@ -0,0 +1 @@
uid://yurugl8r6qre

View File

@@ -0,0 +1,40 @@
class_name MotionExecutor
extends Node
signal motion_started(action: Resource)
signal motion_finished(action: Resource)
var current_action: Resource
var velocity := Vector2.ZERO
var duration := 0.0
var elapsed := 0.0
var active := false
func execute(action: Resource, direction: Vector2, beat_time: float, speed := 220.0) -> void:
current_action = action
duration = maxf(0.01, float(action.get("action_beats")) * maxf(0.01, beat_time))
elapsed = 0.0
active = true
var move_x := float(action.get("move_mult_x"))
if move_x == 0.0:
move_x = -1.0 if StringName(str(action.get("displacement"))) == &"left" else 1.0 if StringName(str(action.get("displacement"))) == &"right" else 0.0
velocity = Vector2(direction.x if direction.x != 0.0 else move_x, direction.y).normalized() * speed if move_x != 0.0 or direction != Vector2.ZERO else Vector2.ZERO
motion_started.emit(action)
func tick(delta: float) -> Vector2:
if not active:
return Vector2.ZERO
elapsed += delta
if elapsed >= duration:
active = false
velocity = Vector2.ZERO
motion_finished.emit(current_action)
return velocity
func cancel() -> void:
active = false
velocity = Vector2.ZERO
current_action = null

View File

@@ -0,0 +1 @@
uid://cqr3o0h5abv3f

View File

@@ -0,0 +1,34 @@
class_name StateMachine
extends Node
signal state_changed(previous: int, current: int)
var current_state := 0
var state_names: Array[StringName] = [
&"ground",
&"ground",
&"air",
&"ground",
&"ground",
&"air",
&"guarding",
&"charging",
&"bladeChain",
&"burstCharge",
&"bursting",
&"hitstun",
]
func change_state(next_state: int) -> void:
if next_state == current_state:
return
var previous := current_state
current_state = next_state
state_changed.emit(previous, current_state)
func get_current_state_name() -> StringName:
if current_state >= 0 and current_state < state_names.size():
return state_names[current_state]
return &"any"

View File

@@ -0,0 +1 @@
uid://bxquc8qy20e6l

Some files were not shown because too many files have changed in this diff Show More