diff --git a/autoload/combat_manager.gd b/autoload/combat_manager.gd new file mode 100644 index 0000000..4aa1863 --- /dev/null +++ b/autoload/combat_manager.gd @@ -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 diff --git a/autoload/combat_manager.gd.uid b/autoload/combat_manager.gd.uid new file mode 100644 index 0000000..603311c --- /dev/null +++ b/autoload/combat_manager.gd.uid @@ -0,0 +1 @@ +uid://dmeiefmd38a30 diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd new file mode 100644 index 0000000..b880b82 --- /dev/null +++ b/autoload/event_bus.gd @@ -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) diff --git a/autoload/event_bus.gd.uid b/autoload/event_bus.gd.uid new file mode 100644 index 0000000..b0b9a86 --- /dev/null +++ b/autoload/event_bus.gd.uid @@ -0,0 +1 @@ +uid://cpgixq8ibqhh4 diff --git a/autoload/rhythm_manager.gd b/autoload/rhythm_manager.gd new file mode 100644 index 0000000..26c4e11 --- /dev/null +++ b/autoload/rhythm_manager.gd @@ -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") diff --git a/autoload/rhythm_manager.gd.uid b/autoload/rhythm_manager.gd.uid new file mode 100644 index 0000000..eb47fa0 --- /dev/null +++ b/autoload/rhythm_manager.gd.uid @@ -0,0 +1 @@ +uid://hoga4p3vm5qp diff --git a/docs/2026-07-02-action-intent-phase-fix-plan.md b/docs/2026-07-02-action-intent-phase-fix-plan.md new file mode 100644 index 0000000..189a526 --- /dev/null +++ b/docs/2026-07-02-action-intent-phase-fix-plan.md @@ -0,0 +1,1319 @@ +# Input Intent and Action Phase Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix duplicate/missed inputs, silent action failures, repeated Space-chain failures, and missing startup/active/recovery timing by inserting a timestamped intent and action adjudication layer between input collection and action execution. + +**Architecture:** Keep the existing `RhythmManager`, `ComboWindow`, `ActionResolver`, `ActionExecutor`, `MotionExecutor`, and `CombatManager` architecture. Add one central `ActionController` as the only component allowed to turn input intent into actions. Do not add a traditional fighting-game `InputBuffer`; store already-judged intents only so rhythm timing remains tied to the original key press. + +**Tech Stack:** Godot 4.6 GDScript, SceneTree headless tests, `.tres` `ActionData` resources, existing EventBus/Autoload services. + +--- + +## Current Problem Summary + +The current implementation has some input cleanup already: + +- Raw `KEY_*` matching has been removed from `Player`. +- `player_space` has been removed from `InputMap`. +- Echo key events are filtered in `InputComponent`. +- Combo/action data now lives in `ActionData` resources. + +The remaining architecture problem is that `Player` still receives a key event and immediately calls `submit_combo_input()`, `ActionResolver`, and `ActionExecutor`. This keeps the old "press key -> do action now" behavior alive inside a componentized shell. + +The fix is to make this the only legal path: + +```text +InputComponent + -> InputIntent(symbol, rhythm_action, event_type, timestamp_ms) + -> ActionController.submit_intent(intent) + -> RhythmManager.judge(intent.timestamp_ms) + -> ComboWindow record/clear + -> ActionResolver with full context + -> ActionController phase/state adjudication + -> ActionExecutor / MotionExecutor / Player visual hooks +``` + +Important project rule: `ComboWindow` must not auto-fill `Ø` on empty beats. `Ø` remains an explicit Miss placeholder only. + +## File Responsibility Map + +Create: + +- `scenes/components/input_intent.gd` + - A small `RefCounted` value object for one physical input event. + - Carries symbol, rhythm action, press/release type, timestamp, and rhythm judgement. + +- `scenes/components/action_controller.gd` + - The central action adjudicator. + - Owns action phase: `idle`, `startup`, `active`, `recovery`. + - Receives `InputIntent`, judges rhythm, updates `ComboWindow`, resolves actions, executes allowed actions, stores at most one already-judged pending intent for cancel-window consumption. + - Builds full `ActionResolver` context for Space priority rules. + +- `tests/test_input_component_intents.gd` + - Covers echo filtering, exact action matching, timestamped intent emission, and no duplicate intent for one event. + +- `tests/test_action_controller_flow.gd` + - Covers Miss clearing, repeated `S+SP`, energy failure cleanup, action phase lockout, and cancel-window consumption. + +Modify: + +- `resources/action_data.gd` + - Add phase fields in beats: `startup_beats`, `active_beats`, `recovery_beats`, `cancel_from`. + +- `resources/actions/*.tres` + - Add explicit phase defaults. + - Mark Space/projectile chain resources with `can_chain` where needed. + +- `scenes/components/input_component.gd` + - Replace `combo_pressed` / `combo_released` as Player-facing API with `intent_created(intent)`. + - Keep old signals only temporarily during migration tests if needed, then remove Player usage. + +- `scenes/characters/player.gd` + - Stop calling `submit_combo_input()` directly from `_on_combo_pressed`. + - Delegate all discrete input to `ActionController`. + - Keep visual orchestration hooks: animation playback, projectile request, charge UI events. + +- `scenes/characters/player.tscn` + - Add `ActionController` node. + - Wire exported NodePaths to existing components. + +- `scenes/components/state_machine.gd` + - Remain a thin state name provider owned by `ActionController` for this plan. Stop treating it as the action phase authority. + +- `tests/test_player_combo_input.gd` + - Update tests to assert Player routes input through `ActionController`. + +- `tests/test_rhythm_action_architecture.gd` + - Add architecture assertions that Player no longer performs direct action resolution/execution from input callbacks. + +## Design Decisions + +### No Traditional InputBuffer + +Do not implement "press early, consume after delay, then judge at consumption time." That would change rhythm timing. + +Allowed: + +- Capture original `timestamp_ms` at input event time. +- Judge immediately or before any possible delay using that timestamp. +- Store the already-judged intent briefly if action phase cannot consume it yet. +- Consume it during a cancel window without changing its judgement. + +Not allowed: + +- Re-judging a buffered input at consumption time. +- Moving a player's early input onto a different beat. + +### One Pending Intent, Not a Queue + +The first implementation should store at most one pending intent. + +Reason: + +- This is enough to remove common "pressed during recovery and got ignored" feel. +- It avoids building a general-purpose combo buffer that fights the rhythm design. +- It keeps testing simple. + +Pending intent replacement rule: + +- If a new valid intent arrives while one is pending, replace the older pending intent. +- Emit `intent_rejected(old_intent, &"replaced")` for observability. + +### Action Phases Use Beats + +Add these fields to `ActionData`: + +```gdscript +@export var startup_beats := 0.25 +@export var active_beats := 0.25 +@export var recovery_beats := 0.5 +@export var cancel_from := 0.5 +``` + +Interpretation: + +- `startup_beats`: action has begun visually, but hitbox/projectile has not fired. +- `active_beats`: action is allowed to apply damage/spawn projectile. +- `recovery_beats`: action is cooling down. +- `cancel_from`: fraction of recovery after which one pending intent may be consumed. + +Default total is one beat. Individual action resources can override these values in the same resource-normalization pass. + +### ActionController Owns Adjudication, Player Owns Presentation + +`ActionController` decides if an action starts. + +`Player` responds to signals: + +- `action_started(action, judgement)` +- `action_active_started(action)` +- `action_active_finished(action)` +- `action_finished(action)` +- `action_rejected(intent, reason)` + +Player may play animation, request projectile, or update visual state in response. Player must not resolve actions directly. + +--- + +## Task 1: Input Intent Value Object + +**Files:** +- Create: `scenes/components/input_intent.gd` +- Create: `tests/test_input_component_intents.gd` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_input_component_intents.gd`: + +```gdscript +extends SceneTree + +var failures: Array[String] = [] +var intents: Array = [] + +func _init() -> void: + _run.call_deferred() + +func _run() -> void: + var component: Node = load("res://scenes/components/input_component.gd").new() + root.add_child(component) + await process_frame + if not component.has_signal("intent_created"): + failures.append("InputComponent should expose intent_created") + else: + component.connect("intent_created", _on_intent_created) + + var normal := InputEventKey.new() + normal.pressed = true + normal.physical_keycode = KEY_A + var handled := component.call("handle_input_event", normal) + _expect_bool(handled, true, "A press should be handled") + _expect_int(intents.size(), 1, "A press should emit one intent") + if intents.size() == 1: + _expect_string(str(intents[0].get("symbol")), "A", "intent symbol") + _expect_string(str(intents[0].get("rhythm_action")), "a", "intent rhythm action") + _expect_string(str(intents[0].get("event_type")), "pressed", "intent event type") + _expect_bool(float(intents[0].get("timestamp_ms")) > 0.0, true, "intent timestamp") + + var echo := InputEventKey.new() + echo.pressed = true + echo.echo = true + echo.physical_keycode = KEY_A + handled = component.call("handle_input_event", echo) + _expect_bool(handled, false, "echo press should not be handled") + _expect_int(intents.size(), 1, "echo press should not emit another intent") + + var release := InputEventKey.new() + release.pressed = false + release.physical_keycode = KEY_A + handled = component.call("handle_input_event", release) + _expect_bool(handled, true, "A release should be handled") + _expect_int(intents.size(), 2, "A release should emit one release intent") + if intents.size() == 2: + _expect_string(str(intents[1].get("event_type")), "released", "release event type") + + component.free() + _finish() + +func _on_intent_created(intent) -> void: + intents.append(intent) + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + +func _finish() -> void: + if failures.is_empty(): + print("PASS input component intents") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_input_component_intents.gd +``` + +Expected: FAIL because `intent_created` and `InputIntent` do not exist yet. + +- [ ] **Step 3: Create `InputIntent`** + +Create `scenes/components/input_intent.gd`: + +```gdscript +class_name InputIntent +extends RefCounted + +var symbol: StringName +var rhythm_action: StringName +var event_type: StringName +var timestamp_ms := 0.0 +var judgement: Dictionary = {} + +static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> InputIntent: + var intent := InputIntent.new() + intent.symbol = next_symbol + intent.rhythm_action = next_rhythm_action + intent.event_type = next_event_type + intent.timestamp_ms = next_timestamp_ms + return intent + +func is_pressed() -> bool: + return event_type == &"pressed" + +func is_released() -> bool: + return event_type == &"released" + +func with_judgement(next_judgement: Dictionary) -> InputIntent: + var copy := InputIntent.create(symbol, rhythm_action, event_type, timestamp_ms) + copy.judgement = next_judgement.duplicate() + return copy +``` + +- [ ] **Step 4: Update `InputComponent` to emit intents** + +Modify `scenes/components/input_component.gd`: + +```gdscript +class_name InputComponent +extends Node + +signal intent_created(intent: InputIntent) +signal combo_pressed(symbol: StringName, rhythm_action: StringName) +signal combo_released(symbol: StringName) + +const COMBO_ACTIONS: Dictionary = { + &"combo_w": [&"W", &"w"], + &"combo_a": [&"A", &"a"], + &"combo_d": [&"D", &"d"], + &"combo_s": [&"S", &"s"], + &"combo_space": [&"SP", &"space"], +} + +const COMBO_ACTION_ORDER: Array[StringName] = [ + &"combo_w", + &"combo_a", + &"combo_d", + &"combo_s", + &"combo_space", +] + +var _suppressed_movement: Dictionary = { + &"move_left": false, + &"move_right": false, +} + +func handle_input_event(event: InputEvent) -> bool: + var key_event := event as InputEventKey + if key_event != null and key_event.echo: + return false + for action_name: StringName in COMBO_ACTION_ORDER: + if event.is_action_pressed(action_name, false, true): + var data: Array = COMBO_ACTIONS[action_name] + var intent := InputIntent.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec())) + intent_created.emit(intent) + combo_pressed.emit(data[0], data[1]) + return true + if event.is_action_released(action_name, true): + var data: Array = COMBO_ACTIONS[action_name] + var intent := InputIntent.create(data[0], data[1], &"released", float(Time.get_ticks_msec())) + intent_created.emit(intent) + combo_released.emit(data[0]) + return true + return false + +func set_direction_suppressed(symbol: StringName, suppressed: bool) -> void: + if symbol == &"A": + _suppressed_movement[&"move_left"] = suppressed + elif symbol == &"D": + _suppressed_movement[&"move_right"] = suppressed + +func get_horizontal_axis() -> float: + var axis := 0.0 + if Input.is_action_pressed(&"move_left") and not bool(_suppressed_movement.get(&"move_left", false)): + axis -= 1.0 + if Input.is_action_pressed(&"move_right") and not bool(_suppressed_movement.get(&"move_right", false)): + axis += 1.0 + return axis +``` + +This step keeps old signals temporarily so existing Player tests can still pass before Task 5 migrates Player. + +- [ ] **Step 5: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_input_component_intents.gd +``` + +Expected: PASS. + +--- + +## Task 2: ActionData Phase Fields + +**Files:** +- Modify: `resources/action_data.gd` +- Modify: `resources/actions/*.tres` +- Modify: `tests/test_rhythm_action_architecture.gd` + +- [ ] **Step 1: Add failing architecture assertions** + +In `tests/test_rhythm_action_architecture.gd`, extend `_check_action_data()` so it expects these fields: + +```gdscript +for property_name: String in [ + "id", + "display_name", + "input_pattern", + "required_state", + "base_cost", + "damage_mult", + "move_mult_x", + "move_mult_y", + "action_beats", + "hit_type", + "range", + "target_type", + "armor_level", + "clear_window", + "can_chain", + "special", + "startup_beats", + "active_beats", + "recovery_beats", + "cancel_from", +]: + _expect(_has_property(action, property_name), "ActionData should expose %s" % property_name) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd +``` + +Expected: FAIL because the phase fields are absent. + +- [ ] **Step 3: Add fields to `ActionData`** + +Modify `resources/action_data.gd`: + +```gdscript +@export var startup_beats := 0.25 +@export var active_beats := 0.25 +@export var recovery_beats := 0.5 +@export_range(0.0, 1.0, 0.05) var cancel_from := 0.5 +``` + +Keep the existing `cancel_window` compatibility field until all code and tests stop relying on it. Do not remove existing fields in this task. + +- [ ] **Step 4: Normalize resource defaults** + +For each file in `resources/actions/*.tres`, add explicit values when absent: + +```ini +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +cancel_from = 0.5 +``` + +For projectile chain resources: + +```ini +can_chain = true +``` + +Apply that to: + +- `resources/actions/skill_s_projectile_1.tres` +- `resources/actions/skill_s_projectile_2.tres` +- `resources/actions/skill_s_projectile_3.tres` + +- [ ] **Step 5: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd +``` + +Expected: PASS. + +--- + +## Task 3: ActionController Skeleton and Miss Cleanup + +**Files:** +- Create: `scenes/components/action_controller.gd` +- Modify: `scenes/characters/player.tscn` +- Create: `tests/test_action_controller_flow.gd` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_action_controller_flow.gd`: + +```gdscript +extends SceneTree + +var failures: Array[String] = [] +var rejected: Array[StringName] = [] +var started: Array[StringName] = [] + +func _init() -> void: + _run.call_deferred() + +func _run() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + var player := scene.instantiate() + root.add_child(player) + await process_frame + + var controller := player.get_node_or_null("ActionController") + _expect_bool(controller != null, true, "Player should have ActionController") + if controller == null: + player.free() + _finish() + return + + controller.connect("action_rejected", _on_action_rejected) + controller.connect("action_started", _on_action_started) + + player.get_node("ComboWindow").record(&"S") + var miss := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) + miss.judgement = {"label": "miss", "diff": 0.0} + controller.call("submit_intent", miss) + _expect_array(player.call("get_combo_slots"), [], "Miss intent should clear ComboWindow immediately") + _expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss intent rejection reason") + + player.free() + _finish() + +func _on_action_rejected(_intent: InputIntent, reason: StringName) -> void: + rejected.append(reason) + +func _on_action_started(action: Resource, _intent: InputIntent) -> void: + started.append(StringName(str(action.get("id")))) + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + +func _finish() -> void: + if failures.is_empty(): + print("PASS action controller flow") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +``` + +Expected: FAIL because `ActionController` does not exist on Player. + +- [ ] **Step 3: Create `ActionController` skeleton** + +Create `scenes/components/action_controller.gd`: + +```gdscript +class_name ActionController +extends Node + +signal action_started(action: Resource, intent: InputIntent) +signal action_active_started(action: Resource) +signal action_active_finished(action: Resource) +signal action_finished(action: Resource) +signal action_rejected(intent: InputIntent, reason: StringName) + +enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY } + +@export var combo_window_path: NodePath +@export var action_resolver_path: NodePath +@export var action_executor_path: NodePath +@export var state_machine_path: NodePath +@export var burst_component_path: NodePath + +@onready var combo_window: Node = get_node_or_null(combo_window_path) +@onready var action_resolver: Node = get_node_or_null(action_resolver_path) +@onready var action_executor: Node = get_node_or_null(action_executor_path) +@onready var state_machine: Node = get_node_or_null(state_machine_path) +@onready var burst_component: Node = get_node_or_null(burst_component_path) + +var phase := Phase.IDLE +var current_action: Resource +var current_intent: InputIntent +var pending_intent: InputIntent +var phase_elapsed := 0.0 +var phase_duration := 0.0 + +func submit_intent(intent: InputIntent) -> void: + if intent == null: + return + var judged_intent := _ensure_judged(intent) + if judged_intent.is_released(): + action_rejected.emit(judged_intent, &"release_not_action") + return + if _judgement_label(judged_intent) == &"miss": + _record_miss_and_clear(judged_intent) + action_rejected.emit(judged_intent, &"miss") + return + if phase == Phase.IDLE: + _consume_intent(judged_intent) + return + if _can_cancel_now(): + _consume_intent(judged_intent) + return + _store_pending_intent(judged_intent) + +func _physics_process(delta: float) -> void: + if phase == Phase.IDLE: + return + phase_elapsed += delta + if phase_elapsed < phase_duration: + return + match phase: + Phase.STARTUP: + _enter_phase(Phase.ACTIVE) + action_active_started.emit(current_action) + Phase.ACTIVE: + action_active_finished.emit(current_action) + _enter_phase(Phase.RECOVERY) + Phase.RECOVERY: + var finished_action := current_action + _reset_to_idle() + action_finished.emit(finished_action) + if pending_intent != null: + var next_intent := pending_intent + pending_intent = null + submit_intent(next_intent) + +func _consume_intent(intent: InputIntent) -> void: + if combo_window == null or action_resolver == null or action_executor == null: + action_rejected.emit(intent, &"missing_component") + return + _record_intent_symbol(intent) + var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context()) + if action == null: + action_rejected.emit(intent, &"unresolved") + return + if not action_executor.execute(action, StringName(str(intent.judgement.get("label", "perfect"))), burst_component): + combo_window.flush_pending_clear() + combo_window.clear(&"action_failed") + action_rejected.emit(intent, &"execution_failed") + return + current_action = action + current_intent = intent + _enter_phase(Phase.STARTUP) + action_started.emit(action, intent) + if bool(action.get("clear_window")): + combo_window.queue_clear(StringName("skill:%s" % action.get("id"))) + +func _record_intent_symbol(intent: InputIntent) -> void: + if combo_window.has_pending_clear(): + if str(combo_window.pending_clear_reason).begins_with("skill:"): + combo_window.cancel_pending_clear() + else: + combo_window.flush_pending_clear() + combo_window.record(intent.symbol) + +func _record_miss_and_clear(intent: InputIntent) -> void: + if combo_window != null: + combo_window.record(&"Ø") + combo_window.flush_pending_clear() + +func _store_pending_intent(intent: InputIntent) -> void: + if pending_intent != null: + action_rejected.emit(pending_intent, &"replaced") + pending_intent = intent + +func _enter_phase(next_phase: Phase) -> void: + phase = next_phase + phase_elapsed = 0.0 + phase_duration = _phase_duration_seconds(next_phase) + +func _phase_duration_seconds(next_phase: Phase) -> float: + if current_action == null: + return 0.0 + var beat_time := _beat_time() + match next_phase: + Phase.STARTUP: + return maxf(0.01, float(current_action.get("startup_beats")) * beat_time) + Phase.ACTIVE: + return maxf(0.01, float(current_action.get("active_beats")) * beat_time) + Phase.RECOVERY: + return maxf(0.01, float(current_action.get("recovery_beats")) * beat_time) + return 0.0 + +func _can_cancel_now() -> bool: + if phase != Phase.RECOVERY or current_action == null: + return false + var duration := maxf(0.01, phase_duration) + var progress := clampf(phase_elapsed / duration, 0.0, 1.0) + return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0) + +func _reset_to_idle() -> void: + phase = Phase.IDLE + current_action = null + current_intent = null + phase_elapsed = 0.0 + phase_duration = 0.0 + +func _ensure_judged(intent: InputIntent) -> InputIntent: + if not intent.judgement.is_empty(): + return intent + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null and rhythm.has_method("judge"): + return intent.with_judgement(rhythm.call("judge", intent.timestamp_ms)) + return intent.with_judgement({"label": "perfect", "diff": 0.0, "abs_diff": 0.0}) + +func _judgement_label(intent: InputIntent) -> StringName: + return StringName(str(intent.judgement.get("label", "miss"))) + +func _resolver_context() -> Dictionary: + var state := &"any" + if state_machine != null and state_machine.has_method("get_current_state_name"): + state = StringName(str(state_machine.call("get_current_state_name"))) + return { + "state": state, + "blade_chain_active": _blade_chain_active(), + } + +func _blade_chain_active() -> bool: + if current_action == null: + return false + return bool(current_action.get("can_chain")) + +func _beat_time() -> float: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null: + return float(rhythm.get("beat_time")) + return 0.5 +``` + +- [ ] **Step 4: Add `ActionController` to Player scene** + +Add a node to `scenes/characters/player.tscn` under `Player`: + +```ini +[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="18_action_controller"] + +[node name="ActionController" type="Node" parent="."] +script = ExtResource("18_action_controller") +combo_window_path = NodePath("../ComboWindow") +action_resolver_path = NodePath("../ActionResolver") +action_executor_path = NodePath("../ActionExecutor") +state_machine_path = NodePath("../StateMachine") +burst_component_path = NodePath("../BurstComponent") +``` + +Use the next available ext_resource id if `18_action_controller` is already taken. + +- [ ] **Step 5: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +``` + +Expected: PASS. + +--- + +## Task 4: Route Player Input Through ActionController + +**Files:** +- Modify: `scenes/characters/player.gd` +- Modify: `tests/test_player_combo_input.gd` +- Modify: `tests/test_rhythm_action_architecture.gd` + +- [ ] **Step 1: Write failing architecture assertion** + +In `tests/test_rhythm_action_architecture.gd`, add checks to `_check_no_legacy_runtime_architecture()`: + +```gdscript +_expect(player_source.contains("intent_created.connect"), "Player should connect InputComponent intents to ActionController") +_expect(not player_source.contains("action_resolver.resolve_window"), "Player should not resolve actions directly") +_expect(not player_source.contains("action_executor.execute"), "Player should not execute actions directly") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd +``` + +Expected: FAIL because Player still calls resolver/executor directly. + +- [ ] **Step 3: Modify Player references and signal wiring** + +In `scenes/characters/player.gd`, add: + +```gdscript +@onready var action_controller: Node = $ActionController +``` + +In `_ready()`, replace direct combo press wiring with intent wiring: + +```gdscript +input_component.intent_created.connect(_on_input_intent_created) +action_controller.action_started.connect(_on_action_started) +action_controller.action_active_started.connect(_on_action_active_started) +action_controller.action_finished.connect(_on_action_finished) +action_controller.action_rejected.connect(_on_action_rejected) +``` + +Leave release handling for charge in place only until Task 7 moves charge mode into controller context. + +- [ ] **Step 4: Add Player handlers** + +Add to `scenes/characters/player.gd`: + +```gdscript +func _on_input_intent_created(intent: InputIntent) -> void: + if intent.symbol == &"A": + heading = Vector2.LEFT + elif intent.symbol == &"D": + heading = Vector2.RIGHT + action_controller.submit_intent(intent) + if intent.is_pressed() and (intent.symbol == &"A" or intent.symbol == &"D"): + input_component.set_direction_suppressed(intent.symbol, true) + if intent.is_released() and (intent.symbol == &"A" or intent.symbol == &"D"): + input_component.set_direction_suppressed(intent.symbol, false) + _finish_charge_hold(intent.symbol) + +func _on_action_started(action: Resource, intent: InputIntent) -> void: + current_energy = energy_component.current + last_requested_skill_id = str(action.get("id")) + current_skill_animation = str(action.get("animation")) + skill_requested.emit(last_requested_skill_id) + _event_bus().emit_signal("skill_executed", action, StringName(str(intent.judgement.get("label", "perfect")))) + _play_skill_animation(current_skill_animation, _skill_displacement_direction(action), action) + +func _on_action_active_started(action: Resource) -> void: + if bool(action.get("spawns_projectile")): + _request_projectile(action) + +func _on_action_finished(_action: Resource) -> void: + pass + +func _on_action_rejected(_intent: InputIntent, _reason: StringName) -> void: + pass +``` + +- [ ] **Step 5: Replace `submit_combo_input()` implementation for tests** + +Keep `submit_combo_input(symbol, forced_rating)` as a test helper, but route it through `ActionController`: + +```gdscript +func submit_combo_input(symbol: String, forced_rating := "") -> String: + var data := _symbol_to_intent_data(symbol) + if data.is_empty(): + return "" + var intent := InputIntent.create(data["symbol"], data["rhythm_action"], &"pressed", float(Time.get_ticks_msec())) + if not forced_rating.is_empty(): + intent.judgement = _rating_result(StringName(forced_rating), 0.0) + action_controller.submit_intent(intent) + return last_requested_skill_id + +func _symbol_to_intent_data(symbol: String) -> Dictionary: + match symbol: + "W": + return {"symbol": &"W", "rhythm_action": &"w"} + "A": + return {"symbol": &"A", "rhythm_action": &"a"} + "D": + return {"symbol": &"D", "rhythm_action": &"d"} + "S": + return {"symbol": &"S", "rhythm_action": &"s"} + "SP": + return {"symbol": &"SP", "rhythm_action": &"space"} + return {} +``` + +- [ ] **Step 6: Remove direct input execution** + +Remove or stop using: + +- `_on_combo_pressed` +- `_record_combo_symbol` +- `_record_rated_combo_input` +- `_record_combo_input` +- `_execute_combo_skill` + +Keep helper methods used by presentation: + +- `_play_skill_animation` +- `_skill_displacement_direction` +- `_request_projectile` +- `_rating_result` + +- [ ] **Step 7: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd +``` + +Expected: both PASS. + +--- + +## Task 5: Phase Lockout and Cancel Window + +**Files:** +- Modify: `tests/test_action_controller_flow.gd` +- Modify: `scenes/components/action_controller.gd` + +- [ ] **Step 1: Add failing test for no immediate reentry** + +Append to `tests/test_action_controller_flow.gd` inside `_run()` after the Miss check: + +```gdscript +started.clear() +player.get_node("EnergyComponent").set_current(10) +var first := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) +first.judgement = {"label": "perfect", "diff": 0.0} +controller.call("submit_intent", first) +var second := InputIntent.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) +second.judgement = {"label": "perfect", "diff": 0.0} +controller.call("submit_intent", second) +_expect_int(started.size(), 1, "Second input during startup should not start a second action immediately") +``` + +Add helper: + +```gdscript +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) +``` + +- [ ] **Step 2: Run test to verify it fails or exposes current behavior** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +``` + +Expected before controller phase is complete: FAIL if two actions start immediately, or PASS if Task 3 already blocks this path. + +- [ ] **Step 3: Add test for cancel-window consumption** + +Append: + +```gdscript +await create_timer(0.45).timeout +_expect_bool(started.size() >= 2, true, "Pending input should start after phase completes or cancel window opens") +``` + +This uses the default one-beat timing at 120 BPM. If project BPM differs in test setup, instantiate a test `RhythmManager` with `beat_time = 0.5`. + +- [ ] **Step 4: Implement pending intent consumption during recovery** + +Update `ActionController._physics_process(delta)` so recovery can consume pending intent as soon as cancel window opens: + +```gdscript +if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now(): + var next_intent := pending_intent + pending_intent = null + _reset_to_idle() + submit_intent(next_intent) + return +``` + +Place this before the `phase_elapsed < phase_duration` return. + +- [ ] **Step 5: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +``` + +Expected: PASS. + +--- + +## Task 6: Repeated `S+SP` and Blade Chain Restart + +**Files:** +- Modify: `tests/test_action_controller_flow.gd` +- Modify: `scenes/components/action_controller.gd` +- Modify: `resources/actions/skill_s_projectile_1.tres` +- Modify: `resources/actions/skill_s_projectile_2.tres` +- Modify: `resources/actions/skill_s_projectile_3.tres` + +- [ ] **Step 1: Add failing test for repeated `S+SP`** + +Append to `tests/test_action_controller_flow.gd`: + +```gdscript +player.get_node("ComboWindow").clear(&"test-reset") +player.get_node("EnergyComponent").set_current(10) +started.clear() +controller.call("submit_intent", _perfect_intent(&"S", &"s")) +controller.call("submit_intent", _perfect_intent(&"SP", &"space")) +await process_frame +controller.call("submit_intent", _perfect_intent(&"S", &"s")) +controller.call("submit_intent", _perfect_intent(&"SP", &"space")) +await process_frame +_expect_bool(started.has(&"skill_s_projectile_1"), true, "First S+SP should start projectile") +_expect_bool(started.count(&"skill_s_projectile_1") >= 2, true, "Second S+SP should restart projectile chain") +``` + +Add helper: + +```gdscript +func _perfect_intent(symbol: StringName, rhythm_action: StringName) -> InputIntent: + var intent := InputIntent.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec())) + intent.judgement = {"label": "perfect", "diff": 0.0} + return intent +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +``` + +Expected: FAIL because `[S, SP, S, SP]` does not resolve. + +- [ ] **Step 3: Implement chain restart rule** + +Add to `ActionController.submit_intent()` before normal phase handling: + +```gdscript +if _should_restart_blade_chain(judged_intent): + combo_window.clear(&"blade_chain_restart") +``` + +Add helper: + +```gdscript +func _should_restart_blade_chain(intent: InputIntent) -> bool: + if combo_window == null: + return false + if intent.symbol != &"S": + return false + var pattern := combo_window.get_contiguous_pattern() + return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP" +``` + +This preserves `S+SP+SP` chaining while allowing a new `S` to begin a fresh chain. + +- [ ] **Step 4: Verify chain resources** + +Ensure projectile chain resources contain: + +```ini +can_chain = true +clear_window = false +``` + +for: + +- `skill_s_projectile_1.tres` +- `skill_s_projectile_2.tres` +- `skill_s_projectile_3.tres` + +- [ ] **Step 5: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_combo_window.gd +``` + +Expected: both PASS. + +--- + +## Task 7: Space Priority Context + +**Files:** +- Modify: `scenes/components/action_controller.gd` +- Modify: `tests/test_action_controller_flow.gd` +- Create: action resources only if the referenced action id does not exist. + +- [ ] **Step 1: Add resolver-context test** + +In `tests/test_action_controller_flow.gd`, add a direct context assertion: + +```gdscript +var context: Dictionary = controller.call("_resolver_context") +_expect_bool(context.has("state"), true, "Resolver context should include state") +_expect_bool(context.has("blade_chain_active"), true, "Resolver context should include blade_chain_active") +_expect_bool(context.has("counter_ready"), true, "Resolver context should include counter_ready") +_expect_bool(context.has("burst_action_id"), true, "Resolver context should include burst_action_id") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +``` + +Expected: FAIL because context is incomplete. + +- [ ] **Step 3: Expand `_resolver_context()`** + +Use this implementation: + +```gdscript +func _resolver_context() -> Dictionary: + var state := &"any" + if state_machine != null and state_machine.has_method("get_current_state_name"): + state = StringName(str(state_machine.call("get_current_state_name"))) + return { + "state": state, + "charge_release_action_id": _charge_release_action_id(), + "burst_action_id": _burst_action_id(), + "counter_action_id": _counter_action_id(), + "counter_ready": _counter_ready(), + "blade_chain_action_id": _blade_chain_action_id(), + "blade_chain_active": _blade_chain_active(), + } + +func _charge_release_action_id() -> StringName: + return &"" + +func _burst_action_id() -> StringName: + if burst_component != null and bool(burst_component.get("burst_ready")): + return &"skill_burst_activate" + return &"" + +func _counter_action_id() -> StringName: + return &"skill_s_counter_projectile" + +func _counter_ready() -> bool: + return false + +func _blade_chain_action_id() -> StringName: + if _blade_chain_active(): + return &"skill_s_projectile_2" + return &"" +``` + +The empty charge release id is intentional until Task 8 moves charge release into the same intent path. + +- [ ] **Step 4: Verify missing action ids** + +Run: + +```bash +rg -n 'skill_burst_activate|skill_s_counter_projectile' resources/actions scenes tests +``` + +Expected: If no resources exist for those ids yet, tests must not expect them to resolve. They are context keys reserved for future states. + +- [ ] **Step 5: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd +``` + +Expected: PASS. + +--- + +## Task 8: Player Presentation Cleanup + +**Files:** +- Modify: `scenes/characters/player.gd` +- Modify: `tests/test_rhythm_action_architecture.gd` + +- [ ] **Step 1: Add source hygiene assertions** + +In `tests/test_rhythm_action_architecture.gd`, add: + +```gdscript +_expect(not player_source.contains("func _record_combo_symbol"), "Player should not own combo symbol adjudication") +_expect(not player_source.contains("func _execute_combo_skill"), "Player should not own action execution") +_expect(player_source.contains("func _on_action_started"), "Player should present actions from ActionController") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd +``` + +Expected: FAIL while old Player methods remain. + +- [ ] **Step 3: Remove direct adjudication methods from Player** + +Delete these methods after tests have moved to `ActionController`: + +- `_record_combo_symbol` +- `_record_rated_combo_input` +- `_record_combo_input` +- `_execute_combo_skill` +- `_resolve_full_window_fallback` +- `_is_projectile_space_chain` + +Keep: + +- `submit_combo_input` +- `_symbol_to_intent_data` +- `_play_skill_animation` +- `_skill_displacement_direction` +- `_request_projectile` + +`submit_combo_input` remains only as a test/helper API and must route through `ActionController`. + +- [ ] **Step 4: Verify** + +Run: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd +``` + +Expected: both PASS. + +--- + +## Task 9: Full Regression and Residue Scan + +**Files:** +- Modify tests only if assertions need to follow the new architecture. + +- [ ] **Step 1: Run full test suite with strict error scan** + +Run: + +```bash +for test in tests/*.gd; do + output=$(/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s "res://$test" 2>&1) + rc=$? + printf '## %s exit=%s\n' "$test" "$rc" + printf '%s\n' "$output" | rg 'PASS|FAIL|SCRIPT ERROR|ERROR:' || true + if [ "$rc" -ne 0 ] || printf '%s\n' "$output" | rg -q 'SCRIPT ERROR|ERROR:'; then + printf '%s\n' "$output" + exit 1 + fi +done +``` + +Expected: every test exits `0`, every test prints `PASS`, and no `SCRIPT ERROR` or `ERROR:` appears. + +- [ ] **Step 2: Scan for direct input/action coupling** + +Run: + +```bash +rg -n 'KEY_|is_action_just_pressed|action_resolver\.resolve_window|action_executor\.execute|func _record_combo_symbol|func _execute_combo_skill' scenes/characters/player.gd scenes/components scenes/combat tests || true +``` + +Expected: + +- No `KEY_` in Player production code. +- No `is_action_just_pressed` for combo actions. +- No direct `action_resolver.resolve_window` or `action_executor.execute` inside Player. +- `action_resolver.resolve_window` and `action_executor.execute` may exist inside `ActionController`. + +- [ ] **Step 3: Scan for forbidden empty-beat auto `Ø`** + +Run: + +```bash +rg -n 'beat_ticked\.connect|_on_beat|input_this_beat|record\(&"Ø"\)|append\(&"Ø"\)' scenes/components/combo_window.gd scenes/characters/player.gd scenes/components/action_controller.gd tests || true +``` + +Expected: + +- No beat-empty auto `Ø` in `ComboWindow`. +- `Ø` appears only in explicit Miss tests or explicit Miss handling. + +- [ ] **Step 4: Manual playtest checklist** + +Run the game from the editor and verify: + +- Pressing `A` once produces one combo input and one action. +- Holding `A` does not repeat through echo. +- Pressing `A` during startup/active does not interrupt the current attack immediately. +- Pressing `A` during recovery is consumed only when the cancel window opens. +- Miss clears the combo window immediately. +- With enough energy, `S+SP`, `S+SP+SP`, and `S+SP+SP+SP` work. +- After one `S+SP`, pressing `S+SP` again starts a new projectile chain. +- With insufficient energy, `S+SP` fails visibly and does not leave a dirty combo window. + +--- + +## Acceptance Criteria + +The repair is complete when all of these are true: + +- `InputComponent` emits timestamped `InputIntent` objects and remains the only discrete input collector. +- Player no longer directly resolves or executes actions from input callbacks. +- `ActionController` is the only component that judges rhythm, records combo symbols, resolves action patterns, and starts action execution. +- Miss clears the combo window immediately. +- Echo key events do not produce input intents. +- One physical input event produces at most one intent. +- Action startup/active/recovery phases exist and block immediate reentry. +- One already-judged pending intent can be consumed during recovery cancel window. +- `S+SP` can chain into additional Space projectiles and can restart cleanly when the player presses `S` again. +- Full test suite passes with no Godot `SCRIPT ERROR` or `ERROR:`. + +## Known Deferred Work + +These are intentionally outside this repair plan: + +- Full BuffContainer implementation. +- EnemyBrain beat scheduling. +- Final burst/counter/charge action resource set. +- Visual tuning for every individual animation. + +The plan adds context hooks for those systems without requiring all of them to be finished in the same change set. diff --git a/docs/superpowers/plans/2026-07-02-chart-layer.md b/docs/superpowers/plans/2026-07-02-chart-layer.md new file mode 100644 index 0000000..33fb15f --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-chart-layer.md @@ -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. diff --git a/docs/superpowers/plans/2026-07-02-godot-architecture-refactor.md b/docs/superpowers/plans/2026-07-02-godot-architecture-refactor.md new file mode 100644 index 0000000..294aab2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-godot-architecture-refactor.md @@ -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. diff --git a/docs/superpowers/plans/2026-07-02-rhythm-action-architecture-refactor.md b/docs/superpowers/plans/2026-07-02-rhythm-action-architecture-refactor.md new file mode 100644 index 0000000..1dc7f36 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-rhythm-action-architecture-refactor.md @@ -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. diff --git a/docs/架构方案.md b/docs/架构方案.md new file mode 100644 index 0000000..f043487 --- /dev/null +++ b/docs/架构方案.md @@ -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 的 T0–T1 上升、T1 高点、T1–T2 下落也由它按拍切分,并在 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 的四个地基模块(含单元测试)完整写出来。 diff --git a/project.godot b/project.godot index 84931c9..c0dba8d 100644 --- a/project.godot +++ b/project.godot @@ -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,36 +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) -] -} -player_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) -]) -} -player_a={ +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) ]) } -player_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) -]) -} -player_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) -]) -} -player_d={ +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] diff --git a/resource/.gitkeep b/resource/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/resource/ncs/.gitkeep b/resource/ncs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/resource/sqf/.gitkeep b/resource/sqf/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/resource/wxm/.gitkeep b/resource/wxm/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/resources/action_data.gd b/resources/action_data.gd new file mode 100644 index 0000000..645c4e4 --- /dev/null +++ b/resources/action_data.gd @@ -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 = &"" diff --git a/resources/action_data.gd.uid b/resources/action_data.gd.uid new file mode 100644 index 0000000..6226c4d --- /dev/null +++ b/resources/action_data.gd.uid @@ -0,0 +1 @@ +uid://cooudhoob8dn4 diff --git a/resources/actions/skill_a.tres b/resources/actions/skill_a.tres new file mode 100644 index 0000000..60ec9bd --- /dev/null +++ b/resources/actions/skill_a.tres @@ -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 diff --git a/resources/actions/skill_a_space.tres b/resources/actions/skill_a_space.tres new file mode 100644 index 0000000..e4635b6 --- /dev/null +++ b/resources/actions/skill_a_space.tres @@ -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 diff --git a/resources/actions/skill_aa.tres b/resources/actions/skill_aa.tres new file mode 100644 index 0000000..7cdf436 --- /dev/null +++ b/resources/actions/skill_aa.tres @@ -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 diff --git a/resources/actions/skill_aa_space.tres b/resources/actions/skill_aa_space.tres new file mode 100644 index 0000000..27e24f6 --- /dev/null +++ b/resources/actions/skill_aa_space.tres @@ -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 diff --git a/resources/actions/skill_aaa.tres b/resources/actions/skill_aaa.tres new file mode 100644 index 0000000..712656f --- /dev/null +++ b/resources/actions/skill_aaa.tres @@ -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 diff --git a/resources/actions/skill_ad_space.tres b/resources/actions/skill_ad_space.tres new file mode 100644 index 0000000..2a79bb5 --- /dev/null +++ b/resources/actions/skill_ad_space.tres @@ -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 diff --git a/resources/actions/skill_d.tres b/resources/actions/skill_d.tres new file mode 100644 index 0000000..f95a741 --- /dev/null +++ b/resources/actions/skill_d.tres @@ -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 diff --git a/resources/actions/skill_d_space.tres b/resources/actions/skill_d_space.tres new file mode 100644 index 0000000..6904958 --- /dev/null +++ b/resources/actions/skill_d_space.tres @@ -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 diff --git a/resources/actions/skill_d_space_space.tres b/resources/actions/skill_d_space_space.tres new file mode 100644 index 0000000..0141ade --- /dev/null +++ b/resources/actions/skill_d_space_space.tres @@ -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 diff --git a/resources/actions/skill_da_space.tres b/resources/actions/skill_da_space.tres new file mode 100644 index 0000000..7cd695f --- /dev/null +++ b/resources/actions/skill_da_space.tres @@ -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 diff --git a/resources/actions/skill_dd.tres b/resources/actions/skill_dd.tres new file mode 100644 index 0000000..2eb0864 --- /dev/null +++ b/resources/actions/skill_dd.tres @@ -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 diff --git a/resources/actions/skill_dd_space.tres b/resources/actions/skill_dd_space.tres new file mode 100644 index 0000000..8b411c1 --- /dev/null +++ b/resources/actions/skill_dd_space.tres @@ -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 diff --git a/resources/actions/skill_ddd.tres b/resources/actions/skill_ddd.tres new file mode 100644 index 0000000..6fa1946 --- /dev/null +++ b/resources/actions/skill_ddd.tres @@ -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 diff --git a/resources/actions/skill_s.tres b/resources/actions/skill_s.tres new file mode 100644 index 0000000..ecc1b4b --- /dev/null +++ b/resources/actions/skill_s.tres @@ -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 diff --git a/resources/actions/skill_s_projectile_1.tres b/resources/actions/skill_s_projectile_1.tres new file mode 100644 index 0000000..4914d25 --- /dev/null +++ b/resources/actions/skill_s_projectile_1.tres @@ -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 diff --git a/resources/actions/skill_s_projectile_2.tres b/resources/actions/skill_s_projectile_2.tres new file mode 100644 index 0000000..e4303bd --- /dev/null +++ b/resources/actions/skill_s_projectile_2.tres @@ -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 diff --git a/resources/actions/skill_s_projectile_3.tres b/resources/actions/skill_s_projectile_3.tres new file mode 100644 index 0000000..278f439 --- /dev/null +++ b/resources/actions/skill_s_projectile_3.tres @@ -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 diff --git a/resources/actions/skill_ss.tres b/resources/actions/skill_ss.tres new file mode 100644 index 0000000..a31a522 --- /dev/null +++ b/resources/actions/skill_ss.tres @@ -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 diff --git a/resources/actions/skill_sss.tres b/resources/actions/skill_sss.tres new file mode 100644 index 0000000..e6143a3 --- /dev/null +++ b/resources/actions/skill_sss.tres @@ -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 diff --git a/resources/actions/skill_ssss.tres b/resources/actions/skill_ssss.tres new file mode 100644 index 0000000..a93f02b --- /dev/null +++ b/resources/actions/skill_ssss.tres @@ -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 diff --git a/resources/actions/skill_w.tres b/resources/actions/skill_w.tres new file mode 100644 index 0000000..59a3e9c --- /dev/null +++ b/resources/actions/skill_w.tres @@ -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 diff --git a/resources/actions/skill_wa.tres b/resources/actions/skill_wa.tres new file mode 100644 index 0000000..ed466ba --- /dev/null +++ b/resources/actions/skill_wa.tres @@ -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 diff --git a/resources/actions/skill_wd.tres b/resources/actions/skill_wd.tres new file mode 100644 index 0000000..289f614 --- /dev/null +++ b/resources/actions/skill_wd.tres @@ -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 diff --git a/resources/beat_chart.gd b/resources/beat_chart.gd new file mode 100644 index 0000000..0a694b1 --- /dev/null +++ b/resources/beat_chart.gd @@ -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() diff --git a/resources/beat_chart.gd.uid b/resources/beat_chart.gd.uid new file mode 100644 index 0000000..bf07e3e --- /dev/null +++ b/resources/beat_chart.gd.uid @@ -0,0 +1 @@ +uid://b0jn7bu4w1co7 diff --git a/resources/chart_event.gd b/resources/chart_event.gd new file mode 100644 index 0000000..451ff8f --- /dev/null +++ b/resources/chart_event.gd @@ -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]) diff --git a/resources/chart_event.gd.uid b/resources/chart_event.gd.uid new file mode 100644 index 0000000..b5a9985 --- /dev/null +++ b/resources/chart_event.gd.uid @@ -0,0 +1 @@ +uid://bsbmwl7n6uil5 diff --git a/resources/chart_track.gd b/resources/chart_track.gd new file mode 100644 index 0000000..f1ea844 --- /dev/null +++ b/resources/chart_track.gd @@ -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 diff --git a/resources/chart_track.gd.uid b/resources/chart_track.gd.uid new file mode 100644 index 0000000..ad50df2 --- /dev/null +++ b/resources/chart_track.gd.uid @@ -0,0 +1 @@ +uid://t7etuk7uw3ns diff --git a/scenes/characters/character.gd b/scenes/characters/character.gd index 713ec9d..4fc6d00 100644 --- a/scenes/characters/character.gd +++ b/scenes/characters/character.gd @@ -131,3 +131,13 @@ func start_directional_air_attack(direction: Vector2) -> void: 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 diff --git a/scenes/characters/player.gd b/scenes/characters/player.gd index 2b1b969..4b57142 100644 --- a/scenes/characters/player.gd +++ b/scenes/characters/player.gd @@ -7,109 +7,71 @@ 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 charge_duration := 1.1 @export var max_health := 100 @export var current_health := 100 @export var max_energy := 10 @export var current_energy := 0 -var combo_window := ComboWindow.new() +@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 combo_clear_timer: Timer -var charge_value := 0.0 -var charge_ready := false -var charge_active := false -var _pending_combo_clear_reason := "" -var _charge_effect_time := 0.0 -var _charge_animation_time := 0.0 -var _charge_hold_symbol := "" -var _charge_hold_direction := Vector2.ZERO -var _suppressed_movement_actions := { - "player_a": false, - "player_d": false, -} -var _last_combo_input_accepted := false +var _held_symbols: Dictionary = {} func _ready() -> void: - combo_clear_timer = Timer.new() - combo_clear_timer.one_shot = true - combo_clear_timer.wait_time = combo_clear_display_time - combo_clear_timer.timeout.connect(flush_pending_combo_clear) - add_child(combo_clear_timer) - combo_window.window_cleared.connect(_on_combo_window_cleared) - _emit_combo_window_changed() - _emit_charge_changed() - _emit_health_changed() - _emit_energy_changed() + 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: - _update_charge(delta) + 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 _handle_combo_key_event(event): + if input_component.handle_input_event(event): _mark_input_handled() func _unhandled_input(event: InputEvent) -> void: - if _handle_combo_key_event(event): + if input_component.handle_input_event(event): _mark_input_handled() -func _handle_combo_key_event(event: InputEvent) -> bool: - var key_event := event as InputEventKey - if key_event == null or key_event.echo: - return false - if not key_event.pressed: - if _event_matches_key(key_event, KEY_A): - _set_movement_action_suppressed("player_a", false) - _finish_charge_hold("A") - return true - elif _event_matches_key(key_event, KEY_D): - _set_movement_action_suppressed("player_d", false) - _finish_charge_hold("D") - return true - return false - if _event_matches_key(key_event, KEY_W): - _submit_combo_input_from_event("W") - return true - elif _event_matches_key(key_event, KEY_A): - _set_movement_action_suppressed("player_a", true) - heading = Vector2.LEFT - _submit_combo_input_from_event("A") - if _last_combo_input_accepted: - _begin_charge_hold("A", Vector2.LEFT) - return true - elif _event_matches_key(key_event, KEY_D): - _set_movement_action_suppressed("player_d", true) - heading = Vector2.RIGHT - _submit_combo_input_from_event("D") - if _last_combo_input_accepted: - _begin_charge_hold("D", Vector2.RIGHT) - return true - elif _event_matches_key(key_event, KEY_S): - _submit_combo_input_from_event("S") - return true - elif _event_matches_key(key_event, KEY_SPACE): - _submit_combo_input_from_event("SP") - return true - return false - - func handle_input() -> void: - if charge_active: + if charge_component.is_active(): velocity = Vector2.ZERO return _apply_horizontal_movement() - if Input.is_action_just_pressed("jump") and not Input.is_action_just_pressed("player_space"): - judge_rhythm_action("jump") - if can_jump(): - start_jump() func set_heading() -> void: @@ -119,90 +81,260 @@ func set_heading() -> void: heading = Vector2.LEFT -func get_combo_slots() -> Array[String]: +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_value + return charge_component.value func get_max_charge() -> float: - return charge_duration + return charge_component.maximum() func is_charge_active() -> bool: - return charge_active + return charge_component.is_active() func is_charge_ready() -> bool: - return charge_ready + 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 _record_combo_symbol("W", "w", forced_rating) + return {"symbol": &"W", "rhythm_action": &"w"} "A": - return _record_combo_symbol("A", "a", forced_rating) + return {"symbol": &"A", "rhythm_action": &"a"} "D": - return _record_combo_symbol("D", "d", forced_rating) + return {"symbol": &"D", "rhythm_action": &"d"} "S": - return _record_combo_symbol("S", "s", forced_rating) + return {"symbol": &"S", "rhythm_action": &"s"} "SP": - return _record_combo_symbol("SP", "space", forced_rating) - return "" + return {"symbol": &"SP", "rhythm_action": &"space"} + return {} -func _record_combo_symbol(symbol: String, rhythm_action: String, forced_rating := "") -> String: - _last_combo_input_accepted = false - var rating := _rating_or_forced(judge_rhythm_action(rhythm_action), forced_rating) - if not _record_rated_combo_input(symbol, rating): - if symbol == "A" or symbol == "D": - _cancel_missed_direction_action() - return "" - _last_combo_input_accepted = true - var resolved := InputResolver.resolve(combo_window) - if resolved.is_empty() and _pending_combo_clear_reason == "full": - resolved = _resolve_full_window_fallback(symbol) - if not resolved.is_empty(): - if not _execute_combo_skill(resolved): - return "" - _apply_skill_energy_reward(last_requested_skill_id) - if symbol == "SP" and not _is_projectile_space_chain() and _pending_combo_clear_reason.is_empty(): - _schedule_combo_clear("space") - return last_requested_skill_id if not resolved.is_empty() else "" +func flush_pending_combo_clear() -> void: + combo_window.flush_pending_clear() -func _submit_combo_input_from_event(symbol: String) -> String: - return submit_combo_input(symbol) +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 _event_matches_key(event: InputEventKey, key: Key) -> bool: - return event.physical_keycode == key or event.keycode == key +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 _event_matches_any_key(event: InputEventKey, keys: Array[Key]) -> bool: - for key: Key in keys: - if _event_matches_key(event, key): - return true - return false +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: @@ -211,338 +343,11 @@ func _mark_input_handled() -> void: viewport.set_input_as_handled() -func _record_combo_direction(symbol: String, direction: Vector2, rhythm_action: String, forced_rating := "") -> void: - heading = direction - var rating := _rating_or_forced(judge_rhythm_action(rhythm_action), forced_rating) - _record_rated_combo_input(symbol, rating) - if state == State.IDLE or state == State.WALK: - velocity.x = 0.0 - - -func _record_rated_combo_input(symbol: String, rating: Dictionary) -> bool: - if str(rating.get("label", "perfect")) == "miss": - _record_combo_input("Ø") - return false - _record_combo_input(symbol) - return true - - -func _record_combo_input(symbol: String) -> void: - if combo_window.has_pending_clear() or not _pending_combo_clear_reason.is_empty(): - if _pending_combo_clear_reason.begins_with("skill:") and not combo_window.has_pending_clear(): - _cancel_pending_combo_clear() - else: - flush_pending_combo_clear() - combo_window.record(symbol) - _emit_combo_window_changed() - var reason := combo_window.consume_pending_clear_reason() - if not reason.is_empty(): - _schedule_combo_clear(reason) - - -func _rating_or_forced(rating: Dictionary, forced_rating: String) -> Dictionary: - if forced_rating.is_empty(): - return rating - var forced := rating.duplicate() - forced["label"] = forced_rating - return forced - - -func _execute_combo_skill(skill: Dictionary) -> bool: - if not _spend_skill_energy(skill): - return false - last_requested_skill_id = str(skill.get("id", "")) - current_skill_animation = str(skill.get("animation", "warrior_idle")) - skill_requested.emit(last_requested_skill_id) - judge_rhythm_action(last_requested_skill_id) - _play_skill_animation(current_skill_animation, _skill_displacement_direction(skill)) - if bool(skill.get("projectile", false)): - _fire_projectile() - _emit_combo_window_changed() - if bool(skill.get("clear_window", false)): - _schedule_combo_clear("skill:%s" % last_requested_skill_id) - return true - - -func _play_skill_animation(animation_name: String, displacement_direction := Vector2.ZERO) -> void: - var player_animation := _get_animation_player() - anim_map[State.ATTACK] = animation_name - state = State.ATTACK - attack_time_left = _animation_length(animation_name) - if displacement_direction == Vector2.ZERO: - attack_lunge_time_left = 0.0 - velocity = Vector2.ZERO - else: - heading = displacement_direction - attack_lunge_time_left = attack_lunge_duration - velocity = Vector2(displacement_direction.x * attack_lunge_speed, 0.0) - if player_animation != null and player_animation.has_animation(animation_name): - player_animation.play(animation_name) - - -func _skill_displacement_direction(skill: Dictionary) -> Vector2: - match str(skill.get("displacement", "")): - "left": - return Vector2.LEFT - "right": - return Vector2.RIGHT - return Vector2.ZERO - - -func _apply_skill_energy_reward(skill_id: String) -> void: - match skill_id: - "skill_a", "skill_aa", "skill_aaa", "skill_d", "skill_dd", "skill_ddd": - _change_energy(1) - - -func _spend_skill_energy(skill: Dictionary) -> bool: - var energy_cost := int(skill.get("energy_cost", 0)) - if energy_cost <= 0: - return true - if current_energy < energy_cost: - return false - _change_energy(-energy_cost) - return true - - -func _change_energy(delta: int) -> void: - var next_energy := clampi(current_energy + delta, 0, max_energy) - if next_energy == current_energy: - return - current_energy = next_energy - _emit_energy_changed() - - -func _begin_charge_hold(symbol: String, direction: Vector2) -> void: - _charge_hold_symbol = symbol - _charge_hold_direction = direction - - -func _finish_charge_hold(symbol: String) -> void: - if _charge_hold_symbol != symbol: - return - var release_ready := charge_active and charge_ready - var release_direction := _charge_hold_direction - var release_skill := "skill_a_charge_release" if symbol == "A" else "skill_d_charge_release" - _cancel_charge() - if release_ready: - _execute_charge_release(release_skill, release_direction) - - -func _update_charge(delta: float) -> void: - if _charge_hold_symbol.is_empty(): - return - if not charge_active: - if state != State.IDLE: - return - _start_charge() - if not charge_active: - return - state = State.IDLE - attack_time_left = 0.0 - attack_lunge_time_left = 0.0 - velocity = Vector2.ZERO - _update_charge_animation(delta) - charge_value = minf(charge_duration, charge_value + delta) - charge_ready = charge_value >= charge_duration - _update_charge_effect(delta) - _emit_charge_changed() - - -func _start_charge() -> void: - charge_active = true - charge_value = 0.0 - charge_ready = false - _charge_effect_time = 0.0 - _charge_animation_time = 0.0 - _play_charge_animation("warrior_charge_intro") - _update_charge_effect(0.0) - _emit_charge_changed() - - -func _cancel_charge() -> void: - _charge_hold_symbol = "" - _charge_hold_direction = Vector2.ZERO - charge_active = false - charge_value = 0.0 - charge_ready = false - _charge_animation_time = 0.0 - _set_charge_effect_visible(false) - _emit_charge_changed() - - -func _execute_charge_release(skill_id: String, direction: Vector2) -> void: - last_requested_skill_id = skill_id - current_skill_animation = "warrior_charge_release" - skill_requested.emit(last_requested_skill_id) - _play_skill_animation(current_skill_animation, direction) - - -func _update_charge_effect(delta: float) -> void: - var sprite := _get_charge_effect_sprite() - if sprite == null: - return - sprite.visible = charge_active - if not charge_active: - return - _charge_effect_time += delta - sprite.frame = int(_charge_effect_time * 12.0) % 5 - - -func _update_charge_animation(delta: float) -> void: - _charge_animation_time += delta - var intro_length := _animation_length("warrior_charge_intro") - if _charge_animation_time < intro_length: - _play_charge_animation("warrior_charge_intro") - else: - _play_charge_animation("warrior_charge_loop") - - -func _play_charge_animation(animation_name: String) -> void: - var player_animation := _get_animation_player() - if player_animation != null and player_animation.has_animation(animation_name) and player_animation.current_animation != animation_name: - player_animation.play(animation_name) - - -func _set_charge_effect_visible(is_visible: bool) -> void: - var sprite := _get_charge_effect_sprite() - if sprite != null: - sprite.visible = is_visible - - -func _get_charge_effect_sprite() -> Sprite2D: - if has_node("ChargeEffectSprite"): - return get_node("ChargeEffectSprite") as Sprite2D - return null - - -func _resolve_full_window_fallback(symbol: String) -> Dictionary: - if symbol != "A" and symbol != "D": - return {} - return InputResolver.resolve_pattern(symbol) - - -func _cancel_missed_direction_action() -> void: - velocity = Vector2.ZERO - attack_lunge_time_left = 0.0 - attack_time_left = 0.0 - state = State.IDLE - anim_map[State.ATTACK] = "warrior_a" - var player_animation := _get_animation_player() - if player_animation != null and player_animation.has_animation("warrior_idle"): - player_animation.play("warrior_idle") - - -func _is_projectile_space_chain() -> bool: - var pattern := combo_window.get_contiguous_pattern() - return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP" - - -func _apply_horizontal_movement() -> void: - if charge_active: - velocity.x = 0.0 - return - if state != State.IDLE and state != State.WALK: - return - var direction := 0.0 - if _is_movement_action_pressed("player_a"): - direction -= 1.0 - if _is_movement_action_pressed("player_d"): - direction += 1.0 - if direction < 0.0: - heading = Vector2.LEFT - elif direction > 0.0: - heading = Vector2.RIGHT - velocity.x = direction * speed - - -func _set_movement_action_suppressed(action_name: String, suppressed: bool) -> void: - _suppressed_movement_actions[action_name] = suppressed - - -func _is_movement_action_pressed(action_name: String) -> bool: - return Input.is_action_pressed(action_name) and not bool(_suppressed_movement_actions.get(action_name, false)) - - -func _animation_length(animation_name: String) -> float: - var player_animation := _get_animation_player() - if player_animation != null and player_animation.has_animation(animation_name): - return maxf(0.1, player_animation.get_animation(animation_name).length) - return attack_duration - - -func _get_animation_player() -> AnimationPlayer: - if animation_player == null and has_node("AnimationPlayer"): - animation_player = get_node("AnimationPlayer") as AnimationPlayer - return animation_player - - -func _fire_projectile() -> void: - var projectile := PlayerProjectile.new() - projectile.direction = heading - projectile.global_position = global_position + Vector2(heading.x * 36.0, -30.0) - var parent := get_parent() - if parent != null: - parent.add_child(projectile) - else: - add_child(projectile) - projectile.add_to_group("player_projectiles") - - -func _cancel_pending_combo_clear() -> void: - _pending_combo_clear_reason = "" - if combo_clear_timer != null: - combo_clear_timer.stop() - - -func _schedule_combo_clear(reason: String) -> void: - _pending_combo_clear_reason = reason - if combo_clear_timer == null: - return - combo_clear_timer.stop() - combo_clear_timer.wait_time = combo_clear_display_time - combo_clear_timer.start() - - -func flush_pending_combo_clear() -> void: - var reason := _pending_combo_clear_reason - if reason.is_empty(): - reason = combo_window.consume_pending_clear_reason() - else: - combo_window.consume_pending_clear_reason() - if reason.is_empty(): - return - _pending_combo_clear_reason = "" - if combo_clear_timer != null: - combo_clear_timer.stop() - combo_window.clear(reason) - - -func _on_combo_window_cleared(reason: String) -> void: - combo_window_cleared.emit(reason) - _emit_combo_window_changed() - - -func _emit_combo_window_changed() -> void: - combo_window_changed.emit(combo_window.get_slots()) - - -func _emit_charge_changed() -> void: - charge_changed.emit(charge_value, charge_duration, charge_ready, charge_active) - - -func _emit_energy_changed() -> void: - energy_changed.emit(current_energy, max_energy) - - -func _emit_health_changed() -> void: - health_changed.emit(current_health, max_health) - - -func judge_rhythm_action(action_name: String) -> Dictionary: - if not is_inside_tree(): - return {"label": "perfect"} - var conductor: Node = get_tree().get_first_node_in_group("rhythm_conductor") - if conductor != null and conductor.has_method("judge_action"): - return conductor.call("judge_action", action_name) as Dictionary - return {"label": "perfect"} +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 diff --git a/scenes/characters/player.tscn b/scenes/characters/player.tscn index 6ffc18b..c56e4f1 100644 --- a/scenes/characters/player.tscn +++ b/scenes/characters/player.tscn @@ -4,6 +4,19 @@ [ext_resource type="Texture2D" uid="uid://bbkamgcdsw5g6" path="res://assets/art/characters/warrior_man_sheet.png" id="2_yewv4"] [ext_resource type="Texture2D" uid="uid://womoel71g8ae" path="res://assets/art/characters/warrior_woman_sheet.png" id="3_dyp2m"] [ext_resource type="Texture2D" uid="uid://1p2uqgg1jole" path="res://assets/art/effects/effect_hp_mp_sheet.png" id="4_atpat"] +[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="5_state_machine"] +[ext_resource type="Script" path="res://scenes/components/input_component.gd" id="6_input_component"] +[ext_resource type="Script" path="res://scenes/components/energy_component.gd" id="8_energy_component"] +[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="9_health_component"] +[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="10_damage_receiver"] +[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="11_damage_emitter"] +[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="12_combo_window"] +[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="13_action_resolver"] +[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="14_motion_executor"] +[ext_resource type="Script" path="res://scenes/components/burst_component.gd" id="15_burst_component"] +[ext_resource type="Script" path="res://scenes/components/charge_component.gd" id="16_charge_component"] +[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="17_action_executor"] +[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="18_action_controller"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_player"] size = Vector2(16, 36) @@ -596,7 +609,7 @@ tracks/4/keys = { [sub_resource type="Animation" id="Animation_6eyoc"] resource_name = "warrior_s" -length = 0.71428573 +length = 0.21428573 step = 0.071428575 tracks/0/type = "value" tracks/0/imported = false @@ -653,10 +666,10 @@ tracks/4/path = NodePath("CharacterSprite:frame") tracks/4/interp = 1 tracks/4/loop_wrap = true tracks/4/keys = { -"times": PackedFloat32Array(0, 0.071428575, 0.14285715, 0.21428572, 0.2857143, 0.35714287, 0.42857143, 0.5, 0.5714286, 0.64285713), -"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1), +"times": PackedFloat32Array(0, 0.071428575, 0.14285715), +"transitions": PackedFloat32Array(1, 1, 1), "update": 1, -"values": [128, 129, 130, 131, 132, 133, 134, 135, 136, 137] +"values": [128, 129, 130] } [sub_resource type="Animation" id="Animation_eojnx"] @@ -905,3 +918,64 @@ position = Vector2(0, -8) texture = ExtResource("4_atpat") hframes = 5 vframes = 2 + +[node name="StateMachine" type="Node" parent="."] +script = ExtResource("5_state_machine") + +[node name="InputComponent" type="Node" parent="."] +script = ExtResource("6_input_component") + +[node name="ComboWindow" type="Node" parent="."] +script = ExtResource("12_combo_window") + +[node name="ActionResolver" type="Node" parent="."] +script = ExtResource("13_action_resolver") + +[node name="ActionExecutor" type="Node" parent="."] +script = ExtResource("17_action_executor") +energy_component_path = NodePath("../EnergyComponent") +damage_emitter_path = NodePath("../DamageEmitter") + +[node name="ActionController" type="Node" parent="."] +script = ExtResource("18_action_controller") +combo_window_path = NodePath("../ComboWindow") +action_resolver_path = NodePath("../ActionResolver") +action_executor_path = NodePath("../ActionExecutor") +state_machine_path = NodePath("../StateMachine") +burst_component_path = NodePath("../BurstComponent") + +[node name="MotionExecutor" type="Node" parent="."] +script = ExtResource("14_motion_executor") + +[node name="BurstComponent" type="Node" parent="."] +script = ExtResource("15_burst_component") + +[node name="ChargeComponent" type="Node" parent="."] +script = ExtResource("16_charge_component") +animation_player_path = NodePath("../AnimationPlayer") +effect_sprite_path = NodePath("../ChargeEffectSprite") + +[node name="EnergyComponent" type="Node" parent="."] +script = ExtResource("8_energy_component") + +[node name="HealthComponent" type="Node" parent="."] +script = ExtResource("9_health_component") + +[node name="DamageReceiver" type="Area2D" parent="."] +collision_layer = 2 +collision_mask = 0 +script = ExtResource("10_damage_receiver") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver"] +position = Vector2(0, -18) +shape = SubResource("RectangleShape2D_player") + +[node name="DamageEmitter" type="Area2D" parent="."] +collision_layer = 8 +collision_mask = 4 +monitoring = false +script = ExtResource("11_damage_emitter") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"] +position = Vector2(22, -18) +shape = SubResource("RectangleShape2D_player") diff --git a/scenes/chart/chart_runner.gd b/scenes/chart/chart_runner.gd new file mode 100644 index 0000000..ab94d4c --- /dev/null +++ b/scenes/chart/chart_runner.gd @@ -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") diff --git a/scenes/chart/chart_runner.gd.uid b/scenes/chart/chart_runner.gd.uid new file mode 100644 index 0000000..5623755 --- /dev/null +++ b/scenes/chart/chart_runner.gd.uid @@ -0,0 +1 @@ +uid://d1st4d2h1bt1m diff --git a/scenes/combat/action_resolver.gd b/scenes/combat/action_resolver.gd new file mode 100644 index 0000000..fa003fe --- /dev/null +++ b/scenes/combat/action_resolver.gd @@ -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 diff --git a/scenes/combat/action_resolver.gd.uid b/scenes/combat/action_resolver.gd.uid new file mode 100644 index 0000000..09d1f60 --- /dev/null +++ b/scenes/combat/action_resolver.gd.uid @@ -0,0 +1 @@ +uid://dyfn38jkq6ld8 diff --git a/scenes/combat/combo_window.gd b/scenes/combat/combo_window.gd deleted file mode 100644 index dae5b1c..0000000 --- a/scenes/combat/combo_window.gd +++ /dev/null @@ -1,55 +0,0 @@ -class_name ComboWindow -extends RefCounted - -signal window_cleared(reason: String) - -const SIZE := 4 - -var slots: Array[String] = [] -var pending_clear_reason := "" - - -func record(input: String) -> void: - if input.is_empty(): - return - slots.append(input) - if slots.size() >= SIZE: - pending_clear_reason = "full" - - -func get_slots() -> Array[String]: - return slots.duplicate() - - -func has_pending_clear() -> bool: - return not pending_clear_reason.is_empty() - - -func consume_pending_clear_reason() -> String: - var reason := pending_clear_reason - pending_clear_reason = "" - return reason - - -func get_pattern() -> String: - var pattern := "" - for slot: String in slots: - if slot != "Ø": - pattern += 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 = slot + pattern - return pattern - - -func clear(reason := "") -> void: - slots.clear() - pending_clear_reason = "" - window_cleared.emit(reason) diff --git a/scenes/combat/combo_window.gd.uid b/scenes/combat/combo_window.gd.uid deleted file mode 100644 index 6087c7a..0000000 --- a/scenes/combat/combo_window.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dtguxwnh02f6g diff --git a/scenes/combat/input_resolver.gd b/scenes/combat/input_resolver.gd deleted file mode 100644 index f9832d8..0000000 --- a/scenes/combat/input_resolver.gd +++ /dev/null @@ -1,157 +0,0 @@ -class_name InputResolver -extends RefCounted - -const SKILLS := { - "W": { - "type": "skill", - "id": "skill_w", - "animation": "warrior_w", - "clear_window": false, - }, - "A": { - "type": "skill", - "id": "skill_a", - "animation": "warrior_a", - "displacement": "left", - "clear_window": false, - }, - "D": { - "type": "skill", - "id": "skill_d", - "animation": "warrior_a", - "displacement": "right", - "clear_window": false, - }, - "WA": { - "type": "skill", - "id": "skill_wa", - "animation": "warrior_wa", - "displacement": "left", - "clear_window": false, - }, - "WD": { - "type": "skill", - "id": "skill_wd", - "animation": "warrior_wa", - "displacement": "right", - "clear_window": false, - }, - "AA": { - "type": "skill", - "id": "skill_aa", - "animation": "warrior_aa", - "displacement": "left", - "clear_window": false, - }, - "DD": { - "type": "skill", - "id": "skill_dd", - "animation": "warrior_aa", - "displacement": "right", - "clear_window": false, - }, - "AAA": { - "type": "skill", - "id": "skill_aaa", - "animation": "warrior_aaa", - "displacement": "left", - "clear_window": false, - }, - "DDD": { - "type": "skill", - "id": "skill_ddd", - "animation": "warrior_aaa", - "displacement": "right", - "clear_window": false, - }, - "ASP": { - "type": "skill", - "id": "skill_a_space", - "animation": "warrior_a_space", - "displacement": "left", - "clear_window": true, - }, - "DSP": { - "type": "skill", - "id": "skill_d_space", - "animation": "warrior_a_space", - "displacement": "right", - "clear_window": true, - }, - "ASPSP": { - "type": "skill", - "id": "skill_a_space_space", - "animation": "warrior_a_space_space", - "displacement": "left", - "clear_window": true, - }, - "DSPSP": { - "type": "skill", - "id": "skill_d_space_space", - "animation": "warrior_a_space_space", - "displacement": "right", - "clear_window": true, - }, - "AASP": { - "type": "skill", - "id": "skill_aa_space", - "animation": "warrior_a_space_space", - "displacement": "left", - "clear_window": true, - }, - "ADSP": { - "type": "skill", - "id": "skill_ad_space", - "animation": "warrior_a_space_space", - "displacement": "right", - "clear_window": true, - }, - "DASP": { - "type": "skill", - "id": "skill_da_space", - "animation": "warrior_a_space_space", - "displacement": "left", - "clear_window": true, - }, - "DDSP": { - "type": "skill", - "id": "skill_dd_space", - "animation": "warrior_a_space_space", - "displacement": "right", - "clear_window": true, - }, - "SSP": { - "type": "skill", - "id": "skill_s_projectile_1", - "animation": "warrior_s_projectile", - "projectile": true, - "energy_cost": 3, - "clear_window": false, - }, - "SSPSP": { - "type": "skill", - "id": "skill_s_projectile_2", - "animation": "warrior_s_projectile", - "projectile": true, - "energy_cost": 2, - "clear_window": false, - }, - "SSPSPSP": { - "type": "skill", - "id": "skill_s_projectile_3", - "animation": "warrior_s_projectile", - "projectile": true, - "energy_cost": 1, - "clear_window": false, - }, -} - - -static func resolve(window: ComboWindow) -> Dictionary: - return resolve_pattern(window.get_contiguous_pattern()) - - -static func resolve_pattern(pattern: String) -> Dictionary: - if not SKILLS.has(pattern): - return {} - return SKILLS[pattern].duplicate() diff --git a/scenes/combat/input_resolver.gd.uid b/scenes/combat/input_resolver.gd.uid deleted file mode 100644 index 54d5078..0000000 --- a/scenes/combat/input_resolver.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cyhq381jiyo42 diff --git a/scenes/combat/player_projectile.tscn b/scenes/combat/player_projectile.tscn new file mode 100644 index 0000000..26ec621 --- /dev/null +++ b/scenes/combat/player_projectile.tscn @@ -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") diff --git a/scenes/components/action_controller.gd b/scenes/components/action_controller.gd new file mode 100644 index 0000000..ab180a3 --- /dev/null +++ b/scenes/components/action_controller.gd @@ -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 diff --git a/scenes/components/action_controller.gd.uid b/scenes/components/action_controller.gd.uid new file mode 100644 index 0000000..a684156 --- /dev/null +++ b/scenes/components/action_controller.gd.uid @@ -0,0 +1 @@ +uid://bk4dutttdieeg diff --git a/scenes/components/action_executor.gd b/scenes/components/action_executor.gd new file mode 100644 index 0000000..496e7bf --- /dev/null +++ b/scenes/components/action_executor.gd @@ -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") diff --git a/scenes/components/action_executor.gd.uid b/scenes/components/action_executor.gd.uid new file mode 100644 index 0000000..79d0210 --- /dev/null +++ b/scenes/components/action_executor.gd.uid @@ -0,0 +1 @@ +uid://cxcw3mad0gewc diff --git a/scenes/components/burst_component.gd b/scenes/components/burst_component.gd new file mode 100644 index 0000000..75e0adc --- /dev/null +++ b/scenes/components/burst_component.gd @@ -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) diff --git a/scenes/components/burst_component.gd.uid b/scenes/components/burst_component.gd.uid new file mode 100644 index 0000000..c48ed13 --- /dev/null +++ b/scenes/components/burst_component.gd.uid @@ -0,0 +1 @@ +uid://vsrv3pov77hh diff --git a/scenes/components/charge_component.gd b/scenes/components/charge_component.gd new file mode 100644 index 0000000..cc2810d --- /dev/null +++ b/scenes/components/charge_component.gd @@ -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) diff --git a/scenes/components/charge_component.gd.uid b/scenes/components/charge_component.gd.uid new file mode 100644 index 0000000..630c9de --- /dev/null +++ b/scenes/components/charge_component.gd.uid @@ -0,0 +1 @@ +uid://417rdyr4hkco diff --git a/scenes/components/combo_window.gd b/scenes/components/combo_window.gd new file mode 100644 index 0000000..903476a --- /dev/null +++ b/scenes/components/combo_window.gd @@ -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") diff --git a/scenes/components/combo_window.gd.uid b/scenes/components/combo_window.gd.uid new file mode 100644 index 0000000..27c5cea --- /dev/null +++ b/scenes/components/combo_window.gd.uid @@ -0,0 +1 @@ +uid://jgl00xkxwy2s diff --git a/scenes/components/damage_emitter.gd b/scenes/components/damage_emitter.gd new file mode 100644 index 0000000..e928994 --- /dev/null +++ b/scenes/components/damage_emitter.gd @@ -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") diff --git a/scenes/components/damage_emitter.gd.uid b/scenes/components/damage_emitter.gd.uid new file mode 100644 index 0000000..824f972 --- /dev/null +++ b/scenes/components/damage_emitter.gd.uid @@ -0,0 +1 @@ +uid://sw8ppylne36n diff --git a/scenes/components/damage_receiver.gd b/scenes/components/damage_receiver.gd new file mode 100644 index 0000000..ab3e31d --- /dev/null +++ b/scenes/components/damage_receiver.gd @@ -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 diff --git a/scenes/components/damage_receiver.gd.uid b/scenes/components/damage_receiver.gd.uid new file mode 100644 index 0000000..7a7dea3 --- /dev/null +++ b/scenes/components/damage_receiver.gd.uid @@ -0,0 +1 @@ +uid://b1ljynf0b873n diff --git a/scenes/components/energy_component.gd b/scenes/components/energy_component.gd new file mode 100644 index 0000000..bb75815 --- /dev/null +++ b/scenes/components/energy_component.gd @@ -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 diff --git a/scenes/components/energy_component.gd.uid b/scenes/components/energy_component.gd.uid new file mode 100644 index 0000000..864dfc8 --- /dev/null +++ b/scenes/components/energy_component.gd.uid @@ -0,0 +1 @@ +uid://bwau6ud30k62u diff --git a/scenes/components/health_component.gd b/scenes/components/health_component.gd new file mode 100644 index 0000000..e02145a --- /dev/null +++ b/scenes/components/health_component.gd @@ -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 diff --git a/scenes/components/health_component.gd.uid b/scenes/components/health_component.gd.uid new file mode 100644 index 0000000..0422d17 --- /dev/null +++ b/scenes/components/health_component.gd.uid @@ -0,0 +1 @@ +uid://0svshg5yfjyg diff --git a/scenes/components/input_component.gd b/scenes/components/input_component.gd new file mode 100644 index 0000000..7609c93 --- /dev/null +++ b/scenes/components/input_component.gd @@ -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 diff --git a/scenes/components/input_component.gd.uid b/scenes/components/input_component.gd.uid new file mode 100644 index 0000000..5402b96 --- /dev/null +++ b/scenes/components/input_component.gd.uid @@ -0,0 +1 @@ +uid://c4n1p3g64qiqj diff --git a/scenes/components/input_intent.gd b/scenes/components/input_intent.gd new file mode 100644 index 0000000..855bedc --- /dev/null +++ b/scenes/components/input_intent.gd @@ -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 diff --git a/scenes/components/input_intent.gd.uid b/scenes/components/input_intent.gd.uid new file mode 100644 index 0000000..cc12e60 --- /dev/null +++ b/scenes/components/input_intent.gd.uid @@ -0,0 +1 @@ +uid://yurugl8r6qre diff --git a/scenes/components/motion_executor.gd b/scenes/components/motion_executor.gd new file mode 100644 index 0000000..09bd8b4 --- /dev/null +++ b/scenes/components/motion_executor.gd @@ -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 diff --git a/scenes/components/motion_executor.gd.uid b/scenes/components/motion_executor.gd.uid new file mode 100644 index 0000000..1272383 --- /dev/null +++ b/scenes/components/motion_executor.gd.uid @@ -0,0 +1 @@ +uid://cqr3o0h5abv3f diff --git a/scenes/components/state_machine.gd b/scenes/components/state_machine.gd new file mode 100644 index 0000000..82bcae9 --- /dev/null +++ b/scenes/components/state_machine.gd @@ -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" diff --git a/scenes/components/state_machine.gd.uid b/scenes/components/state_machine.gd.uid new file mode 100644 index 0000000..2d5c8e8 --- /dev/null +++ b/scenes/components/state_machine.gd.uid @@ -0,0 +1 @@ +uid://bxquc8qy20e6l diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 034a5d5..7bf3c7a 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -1,338 +1,8 @@ extends Node2D -@onready var rhythm_conductor: Node = $RhythmConductor -@onready var rhythm_track: Control = $RhythmFeedback/RhythmTrack -@onready var rhythm_feedback_label: Label = $RhythmFeedback/JudgementLabel -@onready var player: Node = $Player -@onready var center_base: TextureRect = $RhythmFeedback/RhythmTrack/CenterBase -@onready var center_flash: TextureRect = $RhythmFeedback/RhythmTrack/CenterFlash -@onready var left_mover: TextureRect = $RhythmFeedback/RhythmTrack/LeftMover -@onready var right_mover: TextureRect = $RhythmFeedback/RhythmTrack/RightMover -@onready var combo_skill_label: Label = $RhythmFeedback/ComboSkillLabel -@onready var health_bar: ProgressBar = $RhythmFeedback/StatusBars/HealthBar -@onready var charge_bar: ProgressBar = $RhythmFeedback/StatusBars/ChargeBar -@onready var energy_segments: Array[Panel] = [ - $RhythmFeedback/StatusBars/EnergyBar/Segment0, - $RhythmFeedback/StatusBars/EnergyBar/Segment1, - $RhythmFeedback/StatusBars/EnergyBar/Segment2, - $RhythmFeedback/StatusBars/EnergyBar/Segment3, - $RhythmFeedback/StatusBars/EnergyBar/Segment4, - $RhythmFeedback/StatusBars/EnergyBar/Segment5, - $RhythmFeedback/StatusBars/EnergyBar/Segment6, - $RhythmFeedback/StatusBars/EnergyBar/Segment7, - $RhythmFeedback/StatusBars/EnergyBar/Segment8, - $RhythmFeedback/StatusBars/EnergyBar/Segment9, -] -@onready var combo_slot_panels: Array[PanelContainer] = [ - $RhythmFeedback/ComboWindow/Slot0, - $RhythmFeedback/ComboWindow/Slot1, - $RhythmFeedback/ComboWindow/Slot2, - $RhythmFeedback/ComboWindow/Slot3, -] -@onready var combo_key_labels: Array[Label] = [ - $RhythmFeedback/ComboWindow/Slot0/Key, - $RhythmFeedback/ComboWindow/Slot1/Key, - $RhythmFeedback/ComboWindow/Slot2/Key, - $RhythmFeedback/ComboWindow/Slot3/Key, -] - -var combo_clear_tween: Tween -var combo_clear_flash := 0.0 -var charge_bar_ready := false -var charge_flash := 0.0 - -var track_center := Vector2.ZERO -var left_mover_start := Vector2.ZERO -var right_mover_start := Vector2.ZERO -var mover_size := Vector2.ZERO -var center_flash_size := Vector2.ZERO -var feedback_flash := 0.0 -var beat_flash := 0.0 +@onready var stage: Node = $Stage +@onready var ui: CanvasLayer = $UI -func _ready() -> void: - _cache_rhythm_track_layout() - rhythm_conductor.action_judged.connect(_on_rhythm_action_judged) - rhythm_conductor.beat.connect(_on_rhythm_beat) - if player.has_signal("combo_window_changed"): - player.connect("combo_window_changed", _on_combo_window_changed) - if player.has_signal("combo_window_cleared"): - player.connect("combo_window_cleared", _on_combo_window_cleared) - if player.has_signal("skill_requested"): - player.connect("skill_requested", _on_skill_requested) - if player.has_signal("energy_changed"): - player.connect("energy_changed", _on_energy_changed) - if player.has_signal("health_changed"): - player.connect("health_changed", _on_health_changed) - if player.has_signal("charge_changed"): - player.connect("charge_changed", _on_charge_changed) - rhythm_feedback_label.text = "READY" - _on_combo_window_changed([]) - if player.has_method("get_energy") and player.has_method("get_max_energy"): - _on_energy_changed(player.call("get_energy"), player.call("get_max_energy")) - if player.has_method("get_health") and player.has_method("get_max_health"): - _on_health_changed(player.call("get_health"), player.call("get_max_health")) - if player.has_method("get_charge") and player.has_method("get_max_charge") and player.has_method("is_charge_ready") and player.has_method("is_charge_active"): - _on_charge_changed(player.call("get_charge"), player.call("get_max_charge"), player.call("is_charge_ready"), player.call("is_charge_active")) - _update_rhythm_track(0.0) - - -func _process(delta: float) -> void: - _update_rhythm_track(delta) - _update_combo_clear_animation(delta) - _update_charge_bar_flash(delta) - if feedback_flash > 0.0: - feedback_flash = maxf(0.0, feedback_flash - delta * 4.0) - rhythm_feedback_label.scale = Vector2.ONE * (1.0 + feedback_flash * 0.18) - - -func _on_rhythm_action_judged(action_name: String, rating: Dictionary) -> void: - var rating_name: String = str(rating.get("label", "miss")) - var color: Color = rating.get("color", Color("ff0055")) as Color - var diff: float = float(rating.get("diff", INF)) - - rhythm_feedback_label.text = "%s %s %s" % [ - _format_action_name(action_name), - rating_name.to_upper(), - _format_signed_ms(diff), - ] - rhythm_feedback_label.modulate = color - feedback_flash = 1.0 - - -func _on_rhythm_beat(_position: int) -> void: - beat_flash = 1.0 - - -func _on_combo_window_changed(slots: Array) -> void: - for index: int in range(combo_key_labels.size()): - var filled := index < slots.size() - var label := combo_key_labels[index] - var panel := combo_slot_panels[index] - label.text = str(slots[index]) if filled else "·" - label.modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.32) - panel.modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.48) - if filled: - _pulse_combo_slot(panel) - - -func _on_combo_window_cleared(_reason: String) -> void: - _play_combo_clear_animation() - - -func _on_skill_requested(skill_id: String) -> void: - combo_skill_label.text = _format_skill_name(skill_id) - - -func _on_energy_changed(current: int, maximum: int) -> void: - var filled_segments := clampi(current, 0, min(maximum, energy_segments.size())) - for index: int in range(energy_segments.size()): - var filled := index < filled_segments - var panel := energy_segments[index] - panel.modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.38) - - -func _on_health_changed(current: int, maximum: int) -> void: - health_bar.max_value = max(1, maximum) - health_bar.value = clampi(current, 0, maximum) - - -func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void: - charge_bar.max_value = maxf(0.01, maximum) - charge_bar.value = clampf(current, 0.0, maximum) - charge_bar_ready = ready and active - if charge_bar_ready: - return - charge_bar.modulate = Color(1.0, 1.0, 1.0, 1.0 if active or current > 0.0 else 0.45) - - -func _update_charge_bar_flash(delta: float) -> void: - if not charge_bar_ready: - charge_flash = 0.0 - return - charge_flash = fmod(charge_flash + delta * 7.0, TAU) - var alpha := 0.62 + 0.38 * absf(sin(charge_flash)) - charge_bar.modulate = Color(1.0, 1.0, 1.0, alpha) - - -func _play_combo_clear_animation() -> void: - if combo_clear_tween != null and combo_clear_tween.is_valid(): - combo_clear_tween.kill() - combo_clear_flash = 1.0 - for panel: PanelContainer in combo_slot_panels: - panel.scale = Vector2(1.16, 1.16) - panel.modulate = Color(1.0, 1.0, 1.0, 1.0) - - -func _update_combo_clear_animation(delta: float) -> void: - if combo_clear_flash <= 0.0: - return - combo_clear_flash = maxf(0.0, combo_clear_flash - delta * 5.0) - var eased := combo_clear_flash * combo_clear_flash - for panel: PanelContainer in combo_slot_panels: - panel.scale = Vector2.ONE * (1.0 + 0.16 * eased) - panel.modulate = Color(1.0, 1.0, 1.0, 0.48 + 0.52 * eased) - if combo_clear_flash <= 0.0: - _restore_empty_combo_slots() - - -func _pulse_combo_slot(panel: PanelContainer) -> void: - var tween := create_tween() - panel.scale = Vector2(1.08, 1.08) - tween.tween_property(panel, "scale", Vector2.ONE, 0.09) - - -func _restore_empty_combo_slots() -> void: - for index: int in range(combo_slot_panels.size()): - combo_slot_panels[index].modulate = Color(1.0, 1.0, 1.0, 0.48) - combo_slot_panels[index].scale = Vector2.ONE - combo_key_labels[index].text = "·" - combo_key_labels[index].modulate = Color(1.0, 1.0, 1.0, 0.32) - - -func _update_rhythm_track(delta: float) -> void: - beat_flash = maxf(0.0, beat_flash - delta * 8.0) - var progress := 0.0 - if rhythm_conductor.has_method("get_current_beat_progress"): - progress = float(rhythm_conductor.call("get_current_beat_progress")) - if beat_flash > 0.15: - progress = 1.0 - - _set_control_center(left_mover, left_mover_start.lerp(track_center, progress), mover_size) - _set_control_center(right_mover, right_mover_start.lerp(track_center, progress), mover_size) - _set_control_center(center_flash, track_center, center_flash_size) - center_flash.modulate = Color(1.0, 1.0, 1.0, beat_flash) - - -func _cache_rhythm_track_layout() -> void: - track_center = _control_center(center_base) - left_mover_start = _control_center(left_mover) - right_mover_start = _control_center(right_mover) - mover_size = left_mover.size - center_flash_size = center_flash.size - - -func _control_center(control: Control) -> Vector2: - return Vector2( - (control.offset_left + control.offset_right) * 0.5, - (control.offset_top + control.offset_bottom) * 0.5 - ) - - -func _set_control_center(control: Control, center: Vector2, size: Vector2) -> void: - control.offset_left = center.x - size.x * 0.5 - control.offset_top = center.y - size.y * 0.5 - control.offset_right = center.x + size.x * 0.5 - control.offset_bottom = center.y + size.y * 0.5 - - -func _format_action_name(action_name: String) -> String: - match action_name: - "w": - return "W" - "a": - return "A" - "d": - return "D" - "s": - return "S" - "space": - return "SP" - "skill_w": - return "W" - "skill_wa": - return "W+A" - "skill_wd": - return "W+D" - "skill_s": - return "S" - "skill_a": - return "A" - "skill_d": - return "D" - "skill_aa": - return "A+A" - "skill_dd": - return "D+D" - "skill_aaa": - return "A+A+A" - "skill_ddd": - return "D+D+D" - "skill_a_space": - return "A+SP" - "skill_d_space": - return "D+SP" - "skill_a_space_space": - return "A+SP+SP" - "skill_d_space_space": - return "D+SP+SP" - "skill_aa_space": - return "A+A+SP" - "skill_ad_space": - return "A+D+SP" - "skill_da_space": - return "D+A+SP" - "skill_dd_space": - return "D+D+SP" - "skill_s_projectile_1": - return "S+SP" - "skill_s_projectile_2": - return "S+SP+SP" - "skill_s_projectile_3": - return "S+SP+SP+SP" - _: - return action_name.to_upper() - - -func _format_skill_name(skill_id: String) -> String: - match skill_id: - "skill_w": - return "W" - "skill_wa": - return "W+A" - "skill_wd": - return "W+D" - "skill_s": - return "S" - "skill_a": - return "A" - "skill_d": - return "D" - "skill_aa": - return "A+A" - "skill_dd": - return "D+D" - "skill_aaa": - return "A+A+A" - "skill_ddd": - return "D+D+D" - "skill_a_space": - return "A+SP" - "skill_d_space": - return "D+SP" - "skill_a_space_space": - return "A+SP+SP" - "skill_d_space_space": - return "D+SP+SP" - "skill_aa_space": - return "A+A+SP" - "skill_ad_space": - return "A+D+SP" - "skill_da_space": - return "D+A+SP" - "skill_dd_space": - return "D+D+SP" - "skill_s_projectile_1": - return "S+SP" - "skill_s_projectile_2": - return "S+SP+SP" - "skill_s_projectile_3": - return "S+SP+SP+SP" - _: - return skill_id.to_upper() - - -func _format_signed_ms(seconds: float) -> String: - if is_inf(seconds): - return "-- ms" - return "%+.0f ms" % (seconds * 1000.0) +func get_player() -> Node: + return stage.get_node("ActorsContainer/Player") diff --git a/scenes/main/main.tscn b/scenes/main/main.tscn index 930c335..c371eaf 100644 --- a/scenes/main/main.tscn +++ b/scenes/main/main.tscn @@ -1,550 +1,16 @@ [gd_scene format=3 uid="uid://brx0c2va3831p"] -[ext_resource type="PackedScene" uid="uid://cs0rhloanh2u4" path="res://scenes/ground/ground.tscn" id="1_ground"] -[ext_resource type="PackedScene" uid="uid://cs3s5wy1melul" path="res://scenes/characters/player.tscn" id="2_player"] -[ext_resource type="Script" uid="uid://3n4nkejauoim" path="res://scenes/main/main.gd" id="3_main_script"] -[ext_resource type="Script" uid="uid://brh83qp8flq5u" path="res://scenes/rhythm/rhythm_conductor.gd" id="4_rhythm_script"] -[ext_resource type="AudioStream" uid="uid://di5ceecn088rk" path="res://assets/audio/song.ogg" id="5_song"] -[ext_resource type="Texture2D" uid="uid://brqr1gyyxth8p" path="res://assets/ui/rhythm/center.png" id="6_center"] -[ext_resource type="Texture2D" uid="uid://bkqec7mh5yfrd" path="res://assets/ui/rhythm/center_flash.png" id="7_center_flash"] -[ext_resource type="Texture2D" uid="uid://cj5pa4c3arevn" path="res://assets/ui/rhythm/rod.png" id="8_rod"] -[ext_resource type="Texture2D" uid="uid://dbmdivnpjf48l" path="res://assets/ui/rhythm/blue_ball.png" id="9_blue_ball"] -[ext_resource type="Texture2D" uid="uid://ewr8k3lwpcna" path="res://assets/ui/rhythm/yellow_ball.png" id="10_yellow_ball"] - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a8run"] -content_margin_left = 6.0 -content_margin_top = 4.0 -content_margin_right = 6.0 -content_margin_bottom = 4.0 -bg_color = Color(0.04, 0.07, 0.09, 0.82) -border_width_left = 2 -border_width_top = 2 -border_width_right = 2 -border_width_bottom = 2 -border_color = Color(0.43, 0.78, 0.88, 0.95) -corner_radius_top_left = 6 -corner_radius_top_right = 6 -corner_radius_bottom_right = 6 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ouso4"] -content_margin_left = 6.0 -content_margin_top = 4.0 -content_margin_right = 6.0 -content_margin_bottom = 4.0 -bg_color = Color(0.04, 0.07, 0.09, 0.82) -border_width_left = 2 -border_width_top = 2 -border_width_right = 2 -border_width_bottom = 2 -border_color = Color(0.43, 0.78, 0.88, 0.95) -corner_radius_top_left = 6 -corner_radius_top_right = 6 -corner_radius_bottom_right = 6 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_blune"] -content_margin_left = 6.0 -content_margin_top = 4.0 -content_margin_right = 6.0 -content_margin_bottom = 4.0 -bg_color = Color(0.04, 0.07, 0.09, 0.82) -border_width_left = 2 -border_width_top = 2 -border_width_right = 2 -border_width_bottom = 2 -border_color = Color(0.43, 0.78, 0.88, 0.95) -corner_radius_top_left = 6 -corner_radius_top_right = 6 -corner_radius_bottom_right = 6 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_th5th"] -content_margin_left = 6.0 -content_margin_top = 4.0 -content_margin_right = 6.0 -content_margin_bottom = 4.0 -bg_color = Color(0.04, 0.07, 0.09, 0.82) -border_width_left = 2 -border_width_top = 2 -border_width_right = 2 -border_width_bottom = 2 -border_color = Color(0.43, 0.78, 0.88, 0.95) -corner_radius_top_left = 6 -corner_radius_top_right = 6 -corner_radius_bottom_right = 6 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7smn1"] -bg_color = Color(0.12, 0.08, 0.08, 0.86) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.6, 0.12, 0.16, 0.95) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_raeie"] -bg_color = Color(0.86, 0.11, 0.18, 1) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hxu8e"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nvumn"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ou6is"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_necax"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_r4lks"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pg34l"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m4h2d"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p8abn"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_s17dp"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_37hlw"] -bg_color = Color(0.18, 0.66, 0.95, 1) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.66, 0.92, 1, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_charge_bg"] -bg_color = Color(0.08, 0.07, 0.12, 0.86) -border_width_left = 1 -border_width_top = 1 -border_width_right = 1 -border_width_bottom = 1 -border_color = Color(0.42, 0.36, 0.75, 0.9) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_charge_fill"] -bg_color = Color(0.92, 0.72, 0.25, 1) +[ext_resource type="Script" uid="uid://3n4nkejauoim" path="res://scenes/main/main.gd" id="1_main"] +[ext_resource type="PackedScene" path="res://scenes/stage/stage.tscn" id="4_stage"] +[ext_resource type="PackedScene" path="res://scenes/ui/main_ui.tscn" id="5_ui"] +[ext_resource type="Script" path="res://scenes/chart/chart_runner.gd" id="6_chart_runner"] [node name="Main" type="Node2D" unique_id=596674982] -script = ExtResource("3_main_script") +script = ExtResource("1_main") -[node name="RhythmConductor" type="AudioStreamPlayer" parent="." unique_id=147408036] -stream = ExtResource("5_song") -volume_db = -10.0 -script = ExtResource("4_rhythm_script") +[node name="Stage" parent="." instance=ExtResource("4_stage")] -[node name="ground" parent="." unique_id=235100600 instance=ExtResource("1_ground")] +[node name="ChartRunner" type="Node" parent="."] +script = ExtResource("6_chart_runner") -[node name="Player" parent="." unique_id=1027194058 instance=ExtResource("2_player")] -position = Vector2(2047, 370) - -[node name="RhythmFeedback" type="CanvasLayer" parent="." unique_id=979375765] - -[node name="RhythmTrack" type="Control" parent="RhythmFeedback" unique_id=529739298] -layout_mode = 3 -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -520.0 -offset_top = 28.0 -offset_right = 520.0 -offset_bottom = 172.0 -grow_horizontal = 2 - -[node name="LeftRod" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=569576128] -layout_mode = 0 -offset_left = 64.0 -offset_top = 60.0 -offset_right = 464.0 -offset_bottom = 84.0 -texture = ExtResource("8_rod") -expand_mode = 1 -stretch_mode = 5 - -[node name="LeftRod" type="TextureRect" parent="RhythmFeedback/RhythmTrack/LeftRod" unique_id=1074213105] -layout_mode = 0 -offset_left = 127.0 -offset_top = 1.0 -offset_right = 527.0 -offset_bottom = 25.0 -texture = ExtResource("8_rod") -expand_mode = 1 -stretch_mode = 5 - -[node name="RightRod" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=112177250] -layout_mode = 0 -offset_left = 446.0 -offset_top = 62.0 -offset_right = 846.0 -offset_bottom = 86.0 -texture = ExtResource("8_rod") -expand_mode = 1 -stretch_mode = 5 - -[node name="RightRod" type="TextureRect" parent="RhythmFeedback/RhythmTrack/RightRod" unique_id=1431511936] -layout_mode = 0 -offset_left = 127.0 -offset_right = 527.0 -offset_bottom = 24.0 -texture = ExtResource("8_rod") -expand_mode = 1 -stretch_mode = 5 - -[node name="BlueBallLeft1" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=649449082] -layout_mode = 0 -offset_left = 184.0 -offset_top = 49.0 -offset_right = 228.0 -offset_bottom = 93.0 -texture = ExtResource("9_blue_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="BlueBallLeft2" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=1327939368] -layout_mode = 0 -offset_left = 309.0 -offset_top = 50.0 -offset_right = 353.0 -offset_bottom = 94.0 -texture = ExtResource("9_blue_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="BlueBallLeft3" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=1352623059] -layout_mode = 0 -offset_left = 427.0 -offset_top = 51.0 -offset_right = 471.0 -offset_bottom = 95.0 -texture = ExtResource("9_blue_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="BlueBallRight1" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=75338529] -layout_mode = 0 -offset_left = 567.0 -offset_top = 52.0 -offset_right = 611.0 -offset_bottom = 96.0 -texture = ExtResource("9_blue_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="BlueBallRight2" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=484948530] -layout_mode = 0 -offset_left = 687.0 -offset_top = 52.0 -offset_right = 731.0 -offset_bottom = 96.0 -texture = ExtResource("9_blue_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="BlueBallRight3" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=587810490] -layout_mode = 0 -offset_left = 813.0 -offset_top = 52.0 -offset_right = 857.0 -offset_bottom = 96.0 -texture = ExtResource("9_blue_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="LeftMover" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=2070100389] -layout_mode = 0 -offset_left = 183.0 -offset_top = 47.0 -offset_right = 227.0 -offset_bottom = 91.0 -texture = ExtResource("10_yellow_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="RightMover" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=1028576547] -layout_mode = 0 -offset_left = 815.0 -offset_top = 52.0 -offset_right = 859.0 -offset_bottom = 96.0 -texture = ExtResource("10_yellow_ball") -expand_mode = 1 -stretch_mode = 5 - -[node name="CenterBase" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=1816341281] -layout_mode = 0 -offset_left = 464.0 -offset_top = 16.0 -offset_right = 576.0 -offset_bottom = 128.0 -texture = ExtResource("6_center") -expand_mode = 1 -stretch_mode = 5 - -[node name="CenterFlash" type="TextureRect" parent="RhythmFeedback/RhythmTrack" unique_id=1764975684] -modulate = Color(1, 1, 1, 0) -layout_mode = 0 -offset_left = 440.0 -offset_top = -8.0 -offset_right = 600.0 -offset_bottom = 152.0 -texture = ExtResource("7_center_flash") -expand_mode = 1 -stretch_mode = 5 - -[node name="JudgementLabel" type="Label" parent="RhythmFeedback" unique_id=776378947] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -240.0 -offset_top = 174.0 -offset_right = 240.0 -offset_bottom = 222.0 -grow_horizontal = 2 -theme_override_font_sizes/font_size = 24 -text = "READY" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="ComboWindow" type="HBoxContainer" parent="RhythmFeedback" unique_id=1940360666] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -148.0 -offset_top = 222.0 -offset_right = 148.0 -offset_bottom = 282.0 -pivot_offset = Vector2(148, 30) -theme_override_constants/separation = 10 - -[node name="Slot0" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=181099068] -modulate = Color(1, 1, 1, 0.45) -custom_minimum_size = Vector2(64, 56) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_a8run") - -[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot0" unique_id=1605416584] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 -theme_override_colors/font_color = Color(0.94, 0.98, 1, 1) -theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) -theme_override_constants/shadow_offset_x = 2 -theme_override_constants/shadow_offset_y = 2 -theme_override_font_sizes/font_size = 26 -text = "·" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="Slot1" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=1398681506] -modulate = Color(1, 1, 1, 0.45) -custom_minimum_size = Vector2(64, 56) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_ouso4") - -[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot1" unique_id=1841250488] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 -theme_override_colors/font_color = Color(0.94, 0.98, 1, 1) -theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) -theme_override_constants/shadow_offset_x = 2 -theme_override_constants/shadow_offset_y = 2 -theme_override_font_sizes/font_size = 26 -text = "·" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="Slot2" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=22762864] -modulate = Color(1, 1, 1, 0.45) -custom_minimum_size = Vector2(64, 56) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_blune") - -[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot2" unique_id=470444619] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 -theme_override_colors/font_color = Color(0.94, 0.98, 1, 1) -theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) -theme_override_constants/shadow_offset_x = 2 -theme_override_constants/shadow_offset_y = 2 -theme_override_font_sizes/font_size = 26 -text = "·" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="Slot3" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=669931458] -modulate = Color(1, 1, 1, 0.45) -custom_minimum_size = Vector2(64, 56) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_th5th") - -[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot3" unique_id=1939775423] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 -theme_override_colors/font_color = Color(0.94, 0.98, 1, 1) -theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) -theme_override_constants/shadow_offset_x = 2 -theme_override_constants/shadow_offset_y = 2 -theme_override_font_sizes/font_size = 26 -text = "·" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="ComboSkillLabel" type="Label" parent="RhythmFeedback" unique_id=246366341] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -240.0 -offset_top = 286.0 -offset_right = 240.0 -offset_bottom = 322.0 -theme_override_colors/font_color = Color(1, 0.84, 0.26, 1) -theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) -theme_override_constants/shadow_offset_x = 2 -theme_override_constants/shadow_offset_y = 2 -theme_override_font_sizes/font_size = 18 -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="StatusBars" type="VBoxContainer" parent="RhythmFeedback" unique_id=1850079775] -offset_left = 24.0 -offset_top = 9.0 -offset_right = 294.0 -offset_bottom = 69.0 -theme_override_constants/separation = 8 - -[node name="HealthBar" type="ProgressBar" parent="RhythmFeedback/StatusBars" unique_id=562194184] -custom_minimum_size = Vector2(270, 18) -layout_mode = 2 -theme_override_styles/background = SubResource("StyleBoxFlat_7smn1") -theme_override_styles/fill = SubResource("StyleBoxFlat_raeie") -value = 100.0 -show_percentage = false - -[node name="EnergyBar" type="HBoxContainer" parent="RhythmFeedback/StatusBars" unique_id=353280285] -custom_minimum_size = Vector2(270, 16) -layout_mode = 2 -theme_override_constants/separation = 4 - -[node name="Segment0" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1721101704] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_hxu8e") - -[node name="Segment1" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=2071238510] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_nvumn") - -[node name="Segment2" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=820288176] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_ou6is") - -[node name="Segment3" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1809879636] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_necax") - -[node name="Segment4" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=205364545] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_r4lks") - -[node name="Segment5" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1414251865] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_pg34l") - -[node name="Segment6" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1626363537] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_m4h2d") - -[node name="Segment7" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1577127808] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_p8abn") - -[node name="Segment8" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1597873707] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_s17dp") - -[node name="Segment9" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1260417702] -modulate = Color(1, 1, 1, 0.38) -custom_minimum_size = Vector2(23, 16) -layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_37hlw") - -[node name="ChargeBar" type="ProgressBar" parent="RhythmFeedback/StatusBars" unique_id=674131167] -modulate = Color(1, 1, 1, 0.45) -custom_minimum_size = Vector2(270, 10) -layout_mode = 2 -theme_override_styles/background = SubResource("StyleBoxFlat_charge_bg") -theme_override_styles/fill = SubResource("StyleBoxFlat_charge_fill") -max_value = 1.1 -show_percentage = false +[node name="UI" parent="." instance=ExtResource("5_ui")] diff --git a/scenes/rhythm/rhythm_conductor.gd b/scenes/rhythm/rhythm_conductor.gd index cb4d07d..0c984a8 100644 --- a/scenes/rhythm/rhythm_conductor.gd +++ b/scenes/rhythm/rhythm_conductor.gd @@ -27,6 +27,8 @@ signal action_judged(action_name: String, rating: Dictionary) func _ready() -> void: add_to_group("rhythm_conductor") sec_per_beat = 60.0 / bpm + if is_inside_tree(): + _event_bus().connect("rhythm_action_requested", _on_rhythm_action_requested) if starts_on_ready: start() @@ -72,11 +74,9 @@ func judge_action(action_name: String) -> Dictionary: var rating := get_current_rating() rating["action"] = action_name emit_signal("action_judged", action_name, rating) - print("Rhythm %s: %s (%s ms)" % [ - action_name, - str(rating.get("label", "miss")), - _format_signed_ms(float(rating.get("diff", INF))) - ]) + var bus := _event_bus() + bus.emit_signal("judgement_made", StringName(str(rating.get("label", "miss"))), float(rating.get("diff", INF)) * 1000.0) + bus.emit_signal("action_judged", StringName(action_name), rating) return rating @@ -128,10 +128,25 @@ func _report_beat() -> void: current_measure = 1 emit_signal("beat", song_position_in_beats) emit_signal("measure", current_measure) + _event_bus().emit_signal("beat_ticked", song_position_in_beats) last_reported_beat = song_position_in_beats current_measure += 1 +func _on_rhythm_action_requested(action_name: StringName) -> void: + judge_action(str(action_name)) + + +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 _apply_beat_offset(time_seconds: float) -> float: return time_seconds + beat_offset diff --git a/scenes/stage/actors_container.gd b/scenes/stage/actors_container.gd new file mode 100644 index 0000000..6196462 --- /dev/null +++ b/scenes/stage/actors_container.gd @@ -0,0 +1,28 @@ +class_name ActorsContainer +extends Node2D + +const DEFAULT_PROJECTILE_SCENE := preload("res://scenes/combat/player_projectile.tscn") + + +func _ready() -> void: + _event_bus().connect("projectile_requested", _on_projectile_requested) + $Player.connect("projectile_requested", _on_projectile_requested) + + +func _on_projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2) -> void: + var scene := projectile_scene if projectile_scene != null else DEFAULT_PROJECTILE_SCENE + var projectile := scene.instantiate() + projectile.global_position = spawn_position + projectile.set("direction", direction) + projectile.add_to_group("player_projectiles") + add_child(projectile) + + +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 diff --git a/scenes/stage/actors_container.gd.uid b/scenes/stage/actors_container.gd.uid new file mode 100644 index 0000000..edad610 --- /dev/null +++ b/scenes/stage/actors_container.gd.uid @@ -0,0 +1 @@ +uid://yluokxmdkwbd diff --git a/scenes/stage/stage.gd b/scenes/stage/stage.gd new file mode 100644 index 0000000..c151063 --- /dev/null +++ b/scenes/stage/stage.gd @@ -0,0 +1,8 @@ +class_name Stage +extends Node2D + +@onready var actors_container: Node2D = $ActorsContainer + + +func get_player() -> Node: + return $ActorsContainer/Player diff --git a/scenes/stage/stage.gd.uid b/scenes/stage/stage.gd.uid new file mode 100644 index 0000000..9e50dfb --- /dev/null +++ b/scenes/stage/stage.gd.uid @@ -0,0 +1 @@ +uid://dpcpwxh4jwtwa diff --git a/scenes/stage/stage.tscn b/scenes/stage/stage.tscn new file mode 100644 index 0000000..b1320a7 --- /dev/null +++ b/scenes/stage/stage.tscn @@ -0,0 +1,18 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scenes/stage/stage.gd" id="1_stage"] +[ext_resource type="PackedScene" path="res://scenes/ground/ground.tscn" id="2_ground"] +[ext_resource type="Script" path="res://scenes/stage/actors_container.gd" id="3_actors"] +[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="4_player"] + +[node name="Stage" type="Node2D"] +script = ExtResource("1_stage") + +[node name="Ground" parent="." instance=ExtResource("2_ground")] + +[node name="ActorsContainer" type="Node2D" parent="."] +y_sort_enabled = true +script = ExtResource("3_actors") + +[node name="Player" parent="ActorsContainer" instance=ExtResource("4_player")] +position = Vector2(2047, 370) diff --git a/scenes/ui/combo_window_hud.gd b/scenes/ui/combo_window_hud.gd new file mode 100644 index 0000000..0cbfcb2 --- /dev/null +++ b/scenes/ui/combo_window_hud.gd @@ -0,0 +1,105 @@ +class_name ComboWindowHud +extends HBoxContainer + +@export var slot_count := 4 + +var panels: Array[PanelContainer] = [] +var labels: Array[Label] = [] +var clear_tween: Tween + + +func _ready() -> void: + _build_slots() + var bus := _event_bus() + bus.connect("combo_updated", refresh) + bus.connect("combo_cleared", _on_combo_cleared) + + +func refresh(inputs: Array) -> void: + if labels.is_empty(): + _build_slots() + for index: int in range(labels.size()): + var filled := index < inputs.size() + labels[index].text = str(inputs[index]) if filled else "." + labels[index].modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.35) + panels[index].modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.48) + if filled: + _pulse_slot(panels[index]) + + +func _on_combo_cleared(_reason: StringName) -> void: + refresh([]) + _flash_clear() + + +func _build_slots() -> void: + if not labels.is_empty(): + return + for index: int in range(slot_count): + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(64, 56) + panel.pivot_offset = Vector2(32, 28) + panel.modulate = Color(1.0, 1.0, 1.0, 0.48) + panel.add_theme_stylebox_override("panel", _make_slot_style()) + var label := Label.new() + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.text = "." + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_vertical = Control.SIZE_EXPAND_FILL + label.add_theme_color_override("font_color", Color(0.94, 0.98, 1.0, 1.0)) + label.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.9)) + label.add_theme_constant_override("shadow_offset_x", 2) + label.add_theme_constant_override("shadow_offset_y", 2) + label.add_theme_font_size_override("font_size", 26) + panel.add_child(label) + add_child(panel) + panels.append(panel) + labels.append(label) + + +func _pulse_slot(panel: PanelContainer) -> void: + var tween := create_tween() + panel.scale = Vector2(1.08, 1.08) + tween.tween_property(panel, "scale", Vector2.ONE, 0.09) + + +func _flash_clear() -> void: + if clear_tween != null and clear_tween.is_valid(): + clear_tween.kill() + clear_tween = create_tween() + clear_tween.set_parallel(true) + for panel: PanelContainer in panels: + panel.scale = Vector2(1.16, 1.16) + panel.modulate = Color(1.0, 1.0, 1.0, 1.0) + clear_tween.tween_property(panel, "scale", Vector2.ONE, 0.20) + clear_tween.tween_property(panel, "modulate", Color(1.0, 1.0, 1.0, 0.48), 0.20) + + +func _make_slot_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.content_margin_left = 6.0 + style.content_margin_top = 4.0 + style.content_margin_right = 6.0 + style.content_margin_bottom = 4.0 + style.bg_color = Color(0.04, 0.07, 0.09, 0.82) + style.border_width_left = 2 + style.border_width_top = 2 + style.border_width_right = 2 + style.border_width_bottom = 2 + style.border_color = Color(0.43, 0.78, 0.88, 0.95) + style.corner_radius_top_left = 6 + style.corner_radius_top_right = 6 + style.corner_radius_bottom_right = 6 + style.corner_radius_bottom_left = 6 + return style + + +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 diff --git a/scenes/ui/combo_window_hud.gd.uid b/scenes/ui/combo_window_hud.gd.uid new file mode 100644 index 0000000..c4711d6 --- /dev/null +++ b/scenes/ui/combo_window_hud.gd.uid @@ -0,0 +1 @@ +uid://byb77wwjd1a11 diff --git a/scenes/ui/combo_window_hud.tscn b/scenes/ui/combo_window_hud.tscn new file mode 100644 index 0000000..159acf2 --- /dev/null +++ b/scenes/ui/combo_window_hud.tscn @@ -0,0 +1,7 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scenes/ui/combo_window_hud.gd" id="1"] + +[node name="ComboWindowHud" type="HBoxContainer"] +theme_override_constants/separation = 10 +script = ExtResource("1") diff --git a/scenes/ui/energy_bar.gd b/scenes/ui/energy_bar.gd new file mode 100644 index 0000000..2b68ca1 --- /dev/null +++ b/scenes/ui/energy_bar.gd @@ -0,0 +1,58 @@ +class_name EnergyBar +extends HBoxContainer + +@export var segment_count := 10 + +var segments: Array[Panel] = [] + + +func _ready() -> void: + _build_segments() + _event_bus().connect("player_energy_changed", refresh) + + +func refresh(current: float, maximum: float) -> void: + if segments.is_empty(): + _build_segments() + var filled := clampi(int(round(current)), 0, min(segment_count, int(maximum))) + for index: int in range(segments.size()): + segments[index].modulate = Color(1.0, 1.0, 1.0, 1.0 if index < filled else 0.35) + + +func _build_segments() -> void: + if not segments.is_empty(): + return + for child: Node in get_children(): + var panel := child as Panel + if panel != null: + segments.append(panel) + if segments.size() >= segment_count: + return + for index: int in range(segments.size(), segment_count): + var panel := Panel.new() + panel.custom_minimum_size = Vector2(23, 16) + panel.modulate = Color(1.0, 1.0, 1.0, 0.35) + panel.add_theme_stylebox_override("panel", _make_segment_style()) + add_child(panel) + segments.append(panel) + + +func _make_segment_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.18, 0.66, 0.95, 1.0) + style.border_width_left = 1 + style.border_width_top = 1 + style.border_width_right = 1 + style.border_width_bottom = 1 + style.border_color = Color(0.66, 0.92, 1.0, 0.9) + return style + + +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 diff --git a/scenes/ui/energy_bar.gd.uid b/scenes/ui/energy_bar.gd.uid new file mode 100644 index 0000000..2a62393 --- /dev/null +++ b/scenes/ui/energy_bar.gd.uid @@ -0,0 +1 @@ +uid://djef7asrd1q7e diff --git a/scenes/ui/energy_bar.tscn b/scenes/ui/energy_bar.tscn new file mode 100644 index 0000000..11eadb4 --- /dev/null +++ b/scenes/ui/energy_bar.tscn @@ -0,0 +1,75 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/energy_bar.gd" id="1"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_segment"] +bg_color = Color(0.18, 0.66, 0.95, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.66, 0.92, 1, 0.9) + +[node name="EnergyBar" type="HBoxContainer"] +theme_override_constants/separation = 4 +script = ExtResource("1") + +[node name="Segment01" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment02" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment03" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment04" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment05" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment06" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment07" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment08" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment09" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") + +[node name="Segment10" type="Panel" parent="."] +modulate = Color(1, 1, 1, 0.35) +custom_minimum_size = Vector2(23, 16) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_segment") diff --git a/scenes/ui/main_ui.gd b/scenes/ui/main_ui.gd new file mode 100644 index 0000000..ecc0123 --- /dev/null +++ b/scenes/ui/main_ui.gd @@ -0,0 +1,95 @@ +class_name MainUI +extends CanvasLayer + +@onready var health_bar: ProgressBar = $StatusBars/HealthBar +@onready var charge_bar: ProgressBar = $StatusBars/ChargeBar +@onready var combo_skill_label: Label = $ComboSkillLabel + +var charge_bar_ready := false +var charge_flash := 0.0 + + +func _ready() -> void: + _apply_bar_styles() + var bus := _event_bus() + bus.connect("player_health_changed", _on_health_changed) + bus.connect("player_charge_changed", _on_charge_changed) + bus.connect("skill_executed", _on_skill_executed) + + +func _process(delta: float) -> void: + _update_charge_bar_flash(delta) + + +func _on_health_changed(current: int, maximum: int) -> void: + health_bar.max_value = max(1, maximum) + health_bar.value = clampi(current, 0, maximum) + + +func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void: + charge_bar.max_value = maxf(0.01, maximum) + charge_bar.value = clampf(current, 0.0, maximum) + charge_bar_ready = ready and active + if charge_bar_ready: + return + charge_bar.modulate = Color(1.0, 1.0, 1.0, 1.0 if active or ready else 0.45) + + +func _on_skill_executed(skill: Resource, _judgement: StringName) -> void: + var display_name := str(skill.get("display_name")) + if display_name.is_empty(): + display_name = str(skill.get("id")).to_upper() + combo_skill_label.text = display_name + var tween := create_tween() + combo_skill_label.scale = Vector2(1.12, 1.12) + tween.tween_property(combo_skill_label, "scale", Vector2.ONE, 0.12) + + +func _update_charge_bar_flash(delta: float) -> void: + if not charge_bar_ready: + charge_flash = 0.0 + return + charge_flash = fmod(charge_flash + delta * 7.0, TAU) + var alpha := 0.62 + 0.38 * absf(sin(charge_flash)) + charge_bar.modulate = Color(1.0, 1.0, 1.0, alpha) + + +func _apply_bar_styles() -> void: + health_bar.add_theme_stylebox_override( + "background", + _make_style(Color(0.12, 0.08, 0.08, 0.86), Color(0.6, 0.12, 0.16, 0.95)) + ) + health_bar.add_theme_stylebox_override( + "fill", + _make_style(Color(0.86, 0.11, 0.18, 1.0), Color.TRANSPARENT, false) + ) + charge_bar.add_theme_stylebox_override( + "background", + _make_style(Color(0.08, 0.07, 0.12, 0.86), Color(0.42, 0.36, 0.75, 0.9)) + ) + charge_bar.add_theme_stylebox_override( + "fill", + _make_style(Color(0.92, 0.72, 0.25, 1.0), Color.TRANSPARENT, false) + ) + + +func _make_style(bg_color: Color, border_color: Color, has_border := true) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = bg_color + if has_border: + style.border_width_left = 1 + style.border_width_top = 1 + style.border_width_right = 1 + style.border_width_bottom = 1 + style.border_color = border_color + return style + + +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 diff --git a/scenes/ui/main_ui.gd.uid b/scenes/ui/main_ui.gd.uid new file mode 100644 index 0000000..5a8d31f --- /dev/null +++ b/scenes/ui/main_ui.gd.uid @@ -0,0 +1 @@ +uid://b3jgnvla0u0u4 diff --git a/scenes/ui/main_ui.tscn b/scenes/ui/main_ui.tscn new file mode 100644 index 0000000..d2a9ca9 --- /dev/null +++ b/scenes/ui/main_ui.tscn @@ -0,0 +1,57 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scenes/ui/main_ui.gd" id="1"] +[ext_resource type="PackedScene" path="res://scenes/ui/rhythm_track.tscn" id="2"] +[ext_resource type="PackedScene" path="res://scenes/ui/combo_window_hud.tscn" id="3"] +[ext_resource type="PackedScene" path="res://scenes/ui/energy_bar.tscn" id="4"] + +[node name="UI" type="CanvasLayer"] +script = ExtResource("1") + +[node name="RhythmTrack" parent="." instance=ExtResource("2")] + +[node name="ComboWindow" parent="." instance=ExtResource("3")] +offset_left = 492.0 +offset_top = 222.0 +offset_right = 788.0 +offset_bottom = 282.0 +pivot_offset = Vector2(148, 30) + +[node name="ComboSkillLabel" type="Label" parent="."] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -240.0 +offset_top = 286.0 +offset_right = 240.0 +offset_bottom = 322.0 +theme_override_colors/font_color = Color(1, 0.84, 0.26, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="StatusBars" type="VBoxContainer" parent="."] +offset_left = 24.0 +offset_top = 9.0 +offset_right = 294.0 +offset_bottom = 69.0 +theme_override_constants/separation = 8 + +[node name="HealthBar" type="ProgressBar" parent="StatusBars"] +custom_minimum_size = Vector2(270, 18) +layout_mode = 2 +value = 100.0 +show_percentage = false + +[node name="EnergyBar" parent="StatusBars" instance=ExtResource("4")] +custom_minimum_size = Vector2(270, 16) +layout_mode = 2 + +[node name="ChargeBar" type="ProgressBar" parent="StatusBars"] +custom_minimum_size = Vector2(270, 10) +layout_mode = 2 +max_value = 1.1 +show_percentage = false diff --git a/scenes/ui/rhythm_track.gd b/scenes/ui/rhythm_track.gd new file mode 100644 index 0000000..5f16e72 --- /dev/null +++ b/scenes/ui/rhythm_track.gd @@ -0,0 +1,174 @@ +class_name RhythmTrack +extends Control + +@onready var judgement_label: Label = $JudgementLabel +@onready var center_base: TextureRect = $CenterBase +@onready var center_flash: TextureRect = $CenterFlash +@onready var left_mover: TextureRect = $LeftMover +@onready var right_mover: TextureRect = $RightMover +@onready var chart_marker_container: Control = $ChartMarkerContainer + +@export var bpm := 80.0 + +var chart_markers: Array[Control] = [] +var track_center := Vector2.ZERO +var left_mover_start := Vector2.ZERO +var right_mover_start := Vector2.ZERO +var mover_size := Vector2.ZERO +var center_flash_size := Vector2.ZERO +var center_flash_color := Color.WHITE +var beat_flash := 0.0 +var beat_age := 0.0 +var feedback_flash := 0.0 + + +func _ready() -> void: + _cache_rhythm_track_layout() + center_flash.modulate = Color(1.0, 1.0, 1.0, 0.0) + var bus := _event_bus() + bus.connect("beat_ticked", _on_beat_ticked) + bus.connect("action_judged", _on_action_judged) + bus.connect("chart_event_upcoming", _on_chart_event_upcoming) + bus.connect("chart_event_triggered", _on_chart_event_triggered) + + +func _process(delta: float) -> void: + var visual_delta := minf(delta, 1.0 / 30.0) + beat_age += delta + beat_flash = maxf(0.0, beat_flash - visual_delta * 8.0) + _update_movers() + if feedback_flash > 0.0: + feedback_flash = maxf(0.0, feedback_flash - visual_delta * 4.0) + judgement_label.scale = Vector2.ONE * (1.0 + feedback_flash * 0.18) + + +func _on_beat_ticked(_beat_index: int) -> void: + center_flash_color = Color.WHITE + beat_flash = 1.0 + beat_age = 0.0 + _update_movers() + + +func _on_action_judged(action_name: StringName, rating: Dictionary) -> void: + var diff := float(rating.get("diff", INF)) + var color: Color = rating.get("color", Color("ff0055")) as Color + judgement_label.text = "%s %s %s" % [ + str(action_name).to_upper(), + str(rating.get("label", "miss")).to_upper(), + _format_signed_ms(diff), + ] + judgement_label.modulate = color + judgement_label.scale = Vector2(1.18, 1.18) + feedback_flash = 1.0 + + +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_color = Color(1.0, 0.84, 0.26, 1.0) + else: + center_flash_color = _chart_marker_color(event) + beat_flash = 1.0 + _update_movers() + + +func _update_movers() -> void: + var seconds_per_beat := 60.0 / maxf(1.0, bpm) + var progress := clampf(beat_age / seconds_per_beat, 0.0, 1.0) + if beat_flash > 0.15: + progress = 1.0 + + _set_control_center(left_mover, left_mover_start.lerp(track_center, progress), mover_size) + _set_control_center(right_mover, right_mover_start.lerp(track_center, progress), mover_size) + _set_control_center(center_flash, track_center, center_flash_size) + center_flash.modulate = Color(center_flash_color.r, center_flash_color.g, center_flash_color.b, beat_flash) + + +func _cache_rhythm_track_layout() -> void: + track_center = _control_center(center_base) + left_mover_start = _control_center(left_mover) + right_mover_start = _control_center(right_mover) + mover_size = left_mover.size + center_flash_size = center_flash.size + + +func _control_center(control: Control) -> Vector2: + return Vector2( + (control.offset_left + control.offset_right) * 0.5, + (control.offset_top + control.offset_bottom) * 0.5 + ) + + +func _set_control_center(control: Control, center: Vector2, size: Vector2) -> void: + control.offset_left = center.x - size.x * 0.5 + control.offset_top = center.y - size.y * 0.5 + control.offset_right = center.x + size.x * 0.5 + control.offset_bottom = center.y + size.y * 0.5 + + +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) + + +func _format_signed_ms(seconds: float) -> String: + if is_inf(seconds): + return "-- ms" + return "%+.0f ms" % (seconds * 1000.0) + + +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 diff --git a/scenes/ui/rhythm_track.gd.uid b/scenes/ui/rhythm_track.gd.uid new file mode 100644 index 0000000..5b5f61c --- /dev/null +++ b/scenes/ui/rhythm_track.gd.uid @@ -0,0 +1 @@ +uid://c43c882iifnbi diff --git a/scenes/ui/rhythm_track.tscn b/scenes/ui/rhythm_track.tscn new file mode 100644 index 0000000..1e3b5d7 --- /dev/null +++ b/scenes/ui/rhythm_track.tscn @@ -0,0 +1,180 @@ +[gd_scene format=3 uid="uid://csydrlqpqyx3s"] + +[ext_resource type="Script" uid="uid://c43c882iifnbi" path="res://scenes/ui/rhythm_track.gd" id="1"] +[ext_resource type="Texture2D" uid="uid://brqr1gyyxth8p" path="res://assets/ui/rhythm/center.png" id="2_center"] +[ext_resource type="Texture2D" uid="uid://bkqec7mh5yfrd" path="res://assets/ui/rhythm/center_flash.png" id="3_center_flash"] +[ext_resource type="Texture2D" uid="uid://cj5pa4c3arevn" path="res://assets/ui/rhythm/rod.png" id="4_rod"] +[ext_resource type="Texture2D" uid="uid://dbmdivnpjf48l" path="res://assets/ui/rhythm/blue_ball.png" id="5_blue_ball"] +[ext_resource type="Texture2D" uid="uid://ewr8k3lwpcna" path="res://assets/ui/rhythm/yellow_ball.png" id="6_yellow_ball"] + +[node name="RhythmTrack" type="Control" unique_id=1294325361] +layout_mode = 3 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -520.0 +offset_top = 28.0 +offset_right = 520.0 +offset_bottom = 222.0 +grow_horizontal = 2 +script = ExtResource("1") + +[node name="LeftRod" type="TextureRect" parent="." unique_id=956987652] +layout_mode = 0 +offset_left = 64.0 +offset_top = 60.0 +offset_right = 464.0 +offset_bottom = 84.0 +texture = ExtResource("4_rod") +expand_mode = 1 +stretch_mode = 5 + +[node name="LeftRod" type="TextureRect" parent="LeftRod" unique_id=1055715767] +layout_mode = 0 +offset_left = 130.0 +offset_top = 1.0 +offset_right = 530.0 +offset_bottom = 25.0 +texture = ExtResource("4_rod") +expand_mode = 1 +stretch_mode = 5 + +[node name="LeftRod" type="TextureRect" parent="LeftRod/LeftRod" unique_id=475076301] +layout_mode = 0 +offset_left = 257.0 +offset_top = 1.0 +offset_right = 657.0 +offset_bottom = 25.0 +texture = ExtResource("4_rod") +expand_mode = 1 +stretch_mode = 5 + +[node name="RightRod" type="TextureRect" parent="." unique_id=1615082011] +layout_mode = 0 +offset_left = 576.0 +offset_top = 60.0 +offset_right = 976.0 +offset_bottom = 84.0 +texture = ExtResource("4_rod") +expand_mode = 1 +stretch_mode = 5 + +[node name="BlueBallLeft1" type="TextureRect" parent="." unique_id=1528330935] +layout_mode = 0 +offset_left = 184.0 +offset_top = 49.0 +offset_right = 228.0 +offset_bottom = 93.0 +texture = ExtResource("5_blue_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="BlueBallLeft2" type="TextureRect" parent="." unique_id=1648598230] +layout_mode = 0 +offset_left = 309.0 +offset_top = 50.0 +offset_right = 353.0 +offset_bottom = 94.0 +texture = ExtResource("5_blue_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="BlueBallLeft3" type="TextureRect" parent="." unique_id=1872499202] +layout_mode = 0 +offset_left = 427.0 +offset_top = 51.0 +offset_right = 471.0 +offset_bottom = 95.0 +texture = ExtResource("5_blue_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="BlueBallRight1" type="TextureRect" parent="." unique_id=1519743424] +layout_mode = 0 +offset_left = 567.0 +offset_top = 52.0 +offset_right = 611.0 +offset_bottom = 96.0 +texture = ExtResource("5_blue_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="BlueBallRight2" type="TextureRect" parent="." unique_id=1004523117] +layout_mode = 0 +offset_left = 687.0 +offset_top = 52.0 +offset_right = 731.0 +offset_bottom = 96.0 +texture = ExtResource("5_blue_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="BlueBallRight3" type="TextureRect" parent="." unique_id=1902582723] +layout_mode = 0 +offset_left = 813.0 +offset_top = 52.0 +offset_right = 857.0 +offset_bottom = 96.0 +texture = ExtResource("5_blue_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="LeftMover" type="TextureRect" parent="." unique_id=790581017] +layout_mode = 0 +offset_left = 183.0 +offset_top = 47.0 +offset_right = 227.0 +offset_bottom = 91.0 +texture = ExtResource("6_yellow_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="RightMover" type="TextureRect" parent="." unique_id=46330219] +layout_mode = 0 +offset_left = 815.0 +offset_top = 52.0 +offset_right = 859.0 +offset_bottom = 96.0 +texture = ExtResource("6_yellow_ball") +expand_mode = 1 +stretch_mode = 5 + +[node name="CenterBase" type="TextureRect" parent="." unique_id=652811094] +layout_mode = 0 +offset_left = 464.0 +offset_top = 16.0 +offset_right = 576.0 +offset_bottom = 128.0 +texture = ExtResource("2_center") +expand_mode = 1 +stretch_mode = 5 + +[node name="CenterFlash" type="TextureRect" parent="." unique_id=1409206211] +modulate = Color(1, 1, 1, 0) +layout_mode = 0 +offset_left = 440.0 +offset_top = -8.0 +offset_right = 600.0 +offset_bottom = 152.0 +texture = ExtResource("3_center_flash") +expand_mode = 1 +stretch_mode = 5 + +[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 + +[node name="JudgementLabel" type="Label" parent="." unique_id=1712665799] +layout_mode = 0 +offset_left = 280.0 +offset_top = 146.0 +offset_right = 760.0 +offset_bottom = 194.0 +theme_override_font_sizes/font_size = 24 +text = "READY" +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/tests/test_action_controller_flow.gd b/tests/test_action_controller_flow.gd new file mode 100644 index 0000000..3575ace --- /dev/null +++ b/tests/test_action_controller_flow.gd @@ -0,0 +1,211 @@ +extends SceneTree + +const InputIntentScript := preload("res://scenes/components/input_intent.gd") + +var failures: Array[String] = [] +var rejected: Array[StringName] = [] +var started: Array[StringName] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + var player := scene.instantiate() + root.add_child(player) + await process_frame + + var controller := player.get_node_or_null("ActionController") + _expect_bool(controller != null, true, "Player should have ActionController") + if controller == null: + player.free() + _finish() + return + + controller.connect("action_rejected", _on_action_rejected) + controller.connect("action_started", _on_action_started) + + player.get_node("ComboWindow").record(&"S") + var miss: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) + miss.judgement = {"label": "miss", "diff": 0.0} + controller.call("submit_intent", miss) + _expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should enter ComboWindow as an explicit placeholder") + _expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss intent rejection reason") + await create_timer(0.4).timeout + await process_frame + _expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should not clear ComboWindow by itself") + player.get_node("ComboWindow").clear(&"test-reset") + + player.get_node("ComboWindow").clear(&"test-reset") + player.get_node("EnergyComponent").set_current(10) + controller.call("_reset_to_idle") + rejected.clear() + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + var startup_miss: RefCounted = InputIntentScript.create(&"D", &"d", &"pressed", float(Time.get_ticks_msec())) + startup_miss.judgement = {"label": "miss", "diff": 0.0} + controller.call("submit_intent", startup_miss) + _expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should enter ComboWindow immediately") + if rejected.is_empty(): + failures.append("Miss during an action should be rejected immediately: no rejection emitted") + else: + _expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss during an action should be rejected immediately") + await create_timer(0.4).timeout + await process_frame + _expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should not clear ComboWindow by itself") + player.get_node("ComboWindow").clear(&"test-reset") + + started.clear() + player.get_node("EnergyComponent").set_current(0) + var first: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) + first.judgement = {"label": "perfect", "diff": 0.0} + controller.call("submit_intent", first) + _expect_int(player.call("get_energy"), 0, "A reward should wait until active phase") + var second: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec())) + second.judgement = {"label": "perfect", "diff": 0.0} + controller.call("submit_intent", second) + _expect_array(player.call("get_combo_slots"), [&"A"], "Input during startup should wait outside ComboWindow") + _expect_int(started.size(), 1, "Second input during startup should not start a second action immediately") + await create_timer(0.2).timeout + await physics_frame + _expect_int(player.call("get_energy"), 1, "A reward should apply once active phase begins") + await create_timer(0.48).timeout + await physics_frame + _expect_array(player.call("get_combo_slots"), [&"A", &"A"], "Pending input should enter ComboWindow only when cancel window opens") + _expect_bool(started.size() >= 2, true, "Pending input should start after phase completes or cancel window opens") + + player.get_node("ComboWindow").clear(&"test-reset") + player.get_node("EnergyComponent").set_current(10) + controller.call("_reset_to_idle") + rejected.clear() + started.clear() + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + controller.call("submit_intent", _perfect_intent(&"D", &"d")) + controller.call("submit_intent", _perfect_intent(&"S", &"s")) + _expect_array(player.call("get_combo_slots"), [&"A"], "Rapid inputs should not advance ComboWindow at raw input speed") + _expect_bool(rejected.has(&"replaced"), true, "Rapid inputs should replace the single pending intent") + await create_timer(0.5).timeout + await physics_frame + _expect_array(player.call("get_combo_slots"), [&"A", &"S"], "Only the latest pending intent should enter ComboWindow at cancel time") + _expect_int(started.size(), 2, "Replaced rapid inputs should start only the initial action and the latest pending fallback") + _expect_bool(started.has(&"skill_s"), true, "Latest pending S should fall back to normal S when A+S is unresolved") + + player.get_node("ComboWindow").clear(&"test-reset") + player.get_node("EnergyComponent").set_current(10) + controller.call("_reset_to_idle") + started.clear() + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + controller.call("submit_intent", _perfect_intent(&"SP", &"space")) + await create_timer(0.55).timeout + await physics_frame + _expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should show its full input pattern") + await create_timer(0.2).timeout + await physics_frame + _expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should not clear from a stale display timer before it finishes") + await create_timer(0.4).timeout + await physics_frame + _expect_array(player.call("get_combo_slots"), [], "Clear-window action should clear when its action finishes") + + player.get_node("ComboWindow").clear(&"test-reset") + player.get_node("EnergyComponent").set_current(10) + player.get_node("ChargeComponent").cancel() + controller.call("_reset_to_idle") + started.clear() + player.call("_on_input_intent_created", _perfect_intent(&"D", &"d")) + player.call("_on_input_intent_created", _release_intent(&"D", &"d")) + player.call("_on_input_intent_created", _perfect_intent(&"D", &"d")) + player.call("_on_input_intent_created", _release_intent(&"D", &"d")) + await create_timer(0.55).timeout + await physics_frame + _expect_array(player.call("get_combo_slots"), [&"D", &"D"], "Released pending D should still be adjudicated into DD at cancel time") + await create_timer(0.8).timeout + await process_frame + _expect_bool(player.call("is_charge_active"), false, "Released pending D should not leave player stuck charging") + _expect_int(int(round(float(player.call("get_charge")))), 0, "Released pending D should not accumulate charge") + + player.get_node("ComboWindow").clear(&"test-reset") + player.get_node("EnergyComponent").set_current(10) + controller.call("_reset_to_idle") + started.clear() + controller.call("submit_intent", _perfect_intent(&"S", &"s")) + controller.call("_reset_to_idle") + controller.call("submit_intent", _perfect_intent(&"SP", &"space")) + controller.call("_reset_to_idle") + controller.call("submit_intent", _perfect_intent(&"S", &"s")) + controller.call("_reset_to_idle") + controller.call("submit_intent", _perfect_intent(&"SP", &"space")) + _expect_bool(started.has(&"skill_s_projectile_1"), true, "First S+SP should start projectile") + _expect_bool(started.count(&"skill_s_projectile_1") >= 2, true, "Second S+SP should restart projectile chain") + + player.get_node("ComboWindow").clear(&"test-reset") + player.get_node("EnergyComponent").set_current(10) + controller.call("_reset_to_idle") + started.clear() + rejected.clear() + player.get_node("ComboWindow").record(&"A") + player.get_node("ComboWindow").record(&"D") + controller.call("submit_intent", _perfect_intent(&"S", &"s")) + controller.call("_reset_to_idle") + controller.call("submit_intent", _perfect_intent(&"SP", &"space")) + _expect_bool(started.has(&"skill_s_projectile_1"), true, "S+SP should start projectile from the trailing two slots") + _expect_bool(not rejected.has(&"unresolved"), true, "Trailing S+SP should not be rejected as unresolved") + + var context: Dictionary = controller.call("_resolver_context") + _expect_bool(context.has("state"), true, "Resolver context should include state") + _expect_bool(context.has("blade_chain_active"), true, "Resolver context should include blade_chain_active") + _expect_bool(context.has("counter_ready"), true, "Resolver context should include counter_ready") + _expect_bool(context.has("burst_action_id"), true, "Resolver context should include burst_action_id") + + player.free() + _finish() + + +func _on_action_rejected(_intent, reason: StringName) -> void: + rejected.append(reason) + + +func _on_action_started(action: Resource, _intent) -> void: + started.append(StringName(str(action.get("id")))) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _perfect_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted: + var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec())) + intent.judgement = {"label": "perfect", "diff": 0.0} + return intent + + +func _release_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted: + var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"released", float(Time.get_ticks_msec())) + intent.judgement = {"label": "perfect", "diff": 0.0} + return intent + + +func _finish() -> void: + if failures.is_empty(): + print("PASS action controller flow") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_action_controller_flow.gd.uid b/tests/test_action_controller_flow.gd.uid new file mode 100644 index 0000000..579b9eb --- /dev/null +++ b/tests/test_action_controller_flow.gd.uid @@ -0,0 +1 @@ +uid://bfrcdxpy2fwkd diff --git a/tests/test_architecture_refactor.gd b/tests/test_architecture_refactor.gd new file mode 100644 index 0000000..15286c6 --- /dev/null +++ b/tests/test_architecture_refactor.gd @@ -0,0 +1,158 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _check_event_bus_autoload() + _check_main_is_thin() + _check_player_components() + _check_skill_resources() + _check_stage_and_ui_scenes() + _check_project_layers_and_inputs() + _finish() + + +func _check_event_bus_autoload() -> void: + _expect(ProjectSettings.has_setting("autoload/EventBus"), "EventBus should be registered as a game autoload") + if ProjectSettings.has_setting("autoload/EventBus"): + var path := str(ProjectSettings.get_setting("autoload/EventBus")) + _expect(path.contains("res://autoload/event_bus.gd"), "EventBus autoload should point at autoload/event_bus.gd") + var event_bus_script := load("res://autoload/event_bus.gd") + _expect(event_bus_script != null, "autoload/event_bus.gd should exist") + + +func _check_main_is_thin() -> void: + var main_scene: PackedScene = load("res://scenes/main/main.tscn") + _expect(main_scene != null, "main.tscn should load") + if main_scene != null: + var main := main_scene.instantiate() + get_root().add_child(main) + _expect(main.has_node("Stage"), "Main should instance a Stage child") + _expect(main.has_node("UI"), "Main should instance a standalone UI child") + _expect(not main.has_node("RhythmConductor"), "Main should use RhythmManager autoload instead of a RhythmConductor child") + main.free() + var source := _read_text("res://scenes/main/main.gd") + _expect_not_contains(source, "has_signal", "Main should not probe child signals with has_signal") + _expect_not_contains(source, "has_method", "Main should not probe child methods with has_method") + _expect_not_contains(source, ".call(", "Main should not use dynamic call() for game wiring") + + +func _check_player_components() -> void: + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(player_scene != null, "player.tscn should load") + if player_scene != null: + var player := player_scene.instantiate() + get_root().add_child(player) + for node_name: String in [ + "StateMachine", + "InputComponent", + "ComboWindow", + "ActionResolver", + "ActionExecutor", + "MotionExecutor", + "BurstComponent", + "ChargeComponent", + "EnergyComponent", + "HealthComponent", + "DamageReceiver", + "DamageEmitter", + ]: + _expect(player.has_node(node_name), "Player should have %s child component" % node_name) + player.free() + var source := _read_text("res://scenes/characters/player.gd") + _expect_not_contains(source, "KEY_", "Player should not match raw KEY_* values") + _expect_not_contains(source, "get_first_node_in_group(\"rhythm_conductor\")", "Player should not look up RhythmConductor by group") + _expect_not_contains(source, "PlayerProjectile.new()", "Player should not instantiate projectiles directly") + _expect_not_contains(source, "get_parent()", "Player should not reach upward with get_parent()") + + +func _check_skill_resources() -> void: + var action_script := load("res://resources/action_data.gd") + _expect(action_script != null, "resources/action_data.gd should exist") + var action_dir := DirAccess.open("res://resources/actions") + _expect(action_dir != null, "resources/actions should exist") + if action_dir != null: + var action_files: Array[String] = [] + for file_name: String in action_dir.get_files(): + if file_name.ends_with(".tres"): + action_files.append(file_name) + _expect(action_files.size() >= 20, "actions should be saved as migrated .tres resources") + var tracker_script := load("res://scenes/components/combo_window.gd") + var resolver_script := load("res://scenes/combat/action_resolver.gd") + _expect(tracker_script != null, "ComboWindow script should load") + _expect(resolver_script != null, "ActionResolver script should load") + if tracker_script == null or resolver_script == null: + return + var window = tracker_script.new() + window.record(&"A") + var resolved = resolver_script.resolve(window) + _expect(resolved is Resource, "ActionResolver.resolve should return an ActionData Resource, not a Dictionary") + if resolved is Resource: + _expect(resolved.get("input_pattern") is Array, "Resolved ActionData should expose input_pattern") + _expect(str(resolved.get("id")) == "skill_a", "A pattern should resolve to skill_a") + resolver_script.clear_cache() + window.free() + + +func _check_stage_and_ui_scenes() -> void: + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + _expect(stage_scene != null, "stage.tscn should exist") + if stage_scene != null: + var stage := stage_scene.instantiate() + get_root().add_child(stage) + _expect(stage.has_node("ActorsContainer"), "Stage should own ActorsContainer") + _expect(stage.has_node("ActorsContainer/Player"), "ActorsContainer should own Player") + stage.free() + for scene_path: String in [ + "res://scenes/ui/main_ui.tscn", + "res://scenes/ui/rhythm_track.tscn", + "res://scenes/ui/combo_window_hud.tscn", + "res://scenes/ui/energy_bar.tscn", + ]: + _expect(load(scene_path) != null, "%s should exist" % scene_path) + + +func _check_project_layers_and_inputs() -> void: + var expected_layers := { + "layer_names/2d_physics/layer_1": "world", + "layer_names/2d_physics/layer_2": "player_hurtbox", + "layer_names/2d_physics/layer_3": "enemy_hurtbox", + "layer_names/2d_physics/layer_4": "player_hitbox", + "layer_names/2d_physics/layer_5": "enemy_hitbox", + } + for key: String in expected_layers: + _expect(ProjectSettings.has_setting(key), "%s should be configured" % key) + if ProjectSettings.has_setting(key): + _expect(str(ProjectSettings.get_setting(key)) == expected_layers[key], "%s should be named %s" % [key, expected_layers[key]]) + for action_name: String in ["move_left", "move_right", "combo_w", "combo_a", "combo_d", "combo_s", "combo_space"]: + _expect(InputMap.has_action(action_name), "InputMap should define %s" % action_name) + _expect(not InputMap.has_action("player_space"), "InputMap should remove duplicate player_space action") + + +func _read_text(path: String) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("Could not read %s" % path) + return "" + return file.get_as_text() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_not_contains(source: String, needle: String, label: String) -> void: + if source.contains(needle): + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS architecture refactor") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_architecture_refactor.gd.uid b/tests/test_architecture_refactor.gd.uid new file mode 100644 index 0000000..00c76ab --- /dev/null +++ b/tests/test_architecture_refactor.gd.uid @@ -0,0 +1 @@ +uid://dfs2gbjkn4png diff --git a/tests/test_chart_layer.gd b/tests/test_chart_layer.gd new file mode 100644 index 0000000..fbdc634 --- /dev/null +++ b/tests/test_chart_layer.gd @@ -0,0 +1,131 @@ +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 := root.get_node_or_null("EventBus") + var owns_bus := false + if bus == null: + bus = bus_script.new() + bus.name = "EventBus" + root.add_child(bus) + owns_bus = true + + var mirrored_upcoming: Array[Resource] = [] + var mirrored_triggered: Array[Resource] = [] + bus.connect("chart_event_upcoming", func(_event: Resource, _time_to_event: float) -> void: + mirrored_upcoming.append(_event) + ) + bus.connect("chart_event_triggered", func(_event: Resource) -> void: + mirrored_triggered.append(_event) + ) + + 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.size() == 2, "ChartRunner should mirror upcoming events to EventBus") + _expect(mirrored_triggered.size() == 1, "ChartRunner should mirror triggered events to EventBus") + + runner.queue_free() + if owns_bus: + 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) diff --git a/tests/test_chart_layer.gd.uid b/tests/test_chart_layer.gd.uid new file mode 100644 index 0000000..266340a --- /dev/null +++ b/tests/test_chart_layer.gd.uid @@ -0,0 +1 @@ +uid://bphi58hvfrrnl diff --git a/tests/test_combo_hud.gd b/tests/test_combo_hud.gd index 133d32c..f04e048 100644 --- a/tests/test_combo_hud.gd +++ b/tests/test_combo_hud.gd @@ -4,80 +4,82 @@ var failures: Array[String] = [] func _init() -> void: - var scene: PackedScene = load("res://scenes/main/main.tscn") + _run.call_deferred() + + +func _run() -> void: + _check_energy_bar_scene_has_editor_segments() + + var scene: PackedScene = load("res://scenes/ui/main_ui.tscn") if scene == null: - push_error("Could not load main.tscn") + push_error("Could not load main_ui.tscn") quit(1) return - - var main: Node = scene.instantiate() - get_root().add_child(main) - var player: Node = main.get_node_or_null("Player") - if player == null: - failures.append("Missing Player") - elif not player.has_signal("combo_window_cleared"): - failures.append("Player should expose combo_window_cleared") - - if not main.has_method("_play_combo_clear_animation"): - failures.append("Main should implement _play_combo_clear_animation") - if not main.has_method("_on_energy_changed"): - failures.append("Main should implement _on_energy_changed") - if not main.has_method("_on_health_changed"): - failures.append("Main should implement _on_health_changed") - if not main.has_method("_on_charge_changed"): - failures.append("Main should implement _on_charge_changed") - - var status_bars: Node = main.get_node_or_null("RhythmFeedback/StatusBars") - if status_bars == null: - failures.append("Missing StatusBars") - else: - var health_bar := status_bars.get_node_or_null("HealthBar") - if health_bar == null: - failures.append("Missing HealthBar") - elif not health_bar is ProgressBar: - failures.append("HealthBar should be a ProgressBar") - var energy_bar := status_bars.get_node_or_null("EnergyBar") - if energy_bar == null: - failures.append("Missing EnergyBar") - else: - for index: int in range(10): - var segment := energy_bar.get_node_or_null("Segment%d" % index) - if segment == null: - failures.append("Missing energy segment %d" % index) - elif not segment is Panel: - failures.append("Energy segment %d should be a Panel" % index) - var charge_bar := status_bars.get_node_or_null("ChargeBar") - if charge_bar == null: - failures.append("Missing ChargeBar") - elif not charge_bar is ProgressBar: - failures.append("ChargeBar should be a ProgressBar") - elif main.has_method("_on_charge_changed") and main.has_method("_update_charge_bar_flash"): - main.set("charge_bar", charge_bar) - main.call("_on_charge_changed", 1.1, 1.1, true, true) - main.call("_update_charge_bar_flash", 0.13) - var flashing_alpha: float = charge_bar.modulate.a - main.call("_on_charge_changed", 1.1, 1.1, true, true) - if is_equal_approx(charge_bar.modulate.a, 1.0): - failures.append("Ready charge updates should not reset ChargeBar flash alpha") - if not is_equal_approx(charge_bar.modulate.a, flashing_alpha): - failures.append("Ready charge updates should preserve ChargeBar flash alpha") - - var combo_window: Node = main.get_node_or_null("RhythmFeedback/ComboWindow") - if combo_window == null: - failures.append("Missing ComboWindow") - else: - for index: int in range(4): - var slot := combo_window.get_node_or_null("Slot%d" % index) - if slot == null: - failures.append("Missing visual slot %d" % index) - continue - if not slot is PanelContainer: - failures.append("Slot%d should be a PanelContainer" % index) - if slot.get_node_or_null("Key") == null: - failures.append("Slot%d should contain Key label" % index) - - main.free() - + + var ui := scene.instantiate() + root.add_child(ui) + await process_frame + + _expect_node(ui, "RhythmTrack", "UI should include RhythmTrack") + _expect_node(ui, "ComboWindow", "UI should include ComboWindow") + _expect_node(ui, "StatusBars/HealthBar", "UI should include HealthBar") + _expect_node(ui, "StatusBars/EnergyBar", "UI should include EnergyBar") + _expect_node(ui, "StatusBars/ChargeBar", "UI should include ChargeBar") + + var combo_window := ui.get_node("ComboWindow") + _expect_bool(combo_window.get_child_count() >= 4, true, "ComboWindowHud should build four visual slots") + + var bus := _event_bus() + bus.emit_signal("player_health_changed", 42, 100) + bus.emit_signal("player_energy_changed", 3.0, 10.0) + bus.emit_signal("player_charge_changed", 0.8, 1.1, false, true) + bus.emit_signal("combo_updated", [&"A", &"SP"]) + await process_frame + + var health_bar := ui.get_node("StatusBars/HealthBar") as ProgressBar + var charge_bar := ui.get_node("StatusBars/ChargeBar") as ProgressBar + _expect_float(float(health_bar.value), 42.0, "HealthBar should follow EventBus health") + _expect_float(float(charge_bar.value), 0.8, "ChargeBar should follow EventBus charge") + + ui.free() + _finish() + + +func _check_energy_bar_scene_has_editor_segments() -> void: + var scene: PackedScene = load("res://scenes/ui/energy_bar.tscn") + if scene == null: + failures.append("Could not load energy_bar.tscn") + return + var energy_bar := scene.instantiate() + _expect_bool(energy_bar.get_child_count() == 10, true, "EnergyBar scene should contain ten editor-visible segments before _ready") + energy_bar.free() + + +func _event_bus() -> Node: + 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 _expect_node(node: Node, path: String, label: String) -> void: + if node.get_node_or_null(path) == null: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _finish() -> void: if failures.is_empty(): print("PASS combo hud") quit(0) diff --git a/tests/test_combo_window.gd b/tests/test_combo_window.gd index abb471c..bdf2a59 100644 --- a/tests/test_combo_window.gd +++ b/tests/test_combo_window.gd @@ -4,188 +4,98 @@ var failures: Array[String] = [] func _init() -> void: - var window_script: Script = load("res://scenes/combat/combo_window.gd") - var resolver_script: Script = load("res://scenes/combat/input_resolver.gd") - if window_script == null: + var tracker_script: Script = load("res://scenes/components/combo_window.gd") + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + if tracker_script == null: failures.append("Missing combo_window.gd") _finish() return if resolver_script == null: - failures.append("Missing input_resolver.gd") + failures.append("Missing action_resolver.gd") _finish() return - - var window: RefCounted = window_script.new() - window.record("A") - window.record("Ø") - window.record("SP") - _expect_array(window.get_slots(), ["A", "Ø", "SP"], "miss placeholder should be visible in slots") + + var window = tracker_script.new() + window.record(&"A") + window.record(&"Ø") + window.record(&"SP") + _expect_array(window.get_slots(), [&"A", &"Ø", &"SP"], "miss placeholder should be visible in slots") + _expect_bool(window.has_pending_clear(), false, "miss placeholder should not request a clear") _expect_string(window.get_pattern(), "ASP", "miss placeholder should be ignored by pattern") - window.clear("test-reset") - - window.record("W") - window.record("A") - window.record("S") - _expect_array(window.get_slots(), ["W", "A", "S"], "three recorded slots") - window.record("SP") - _expect_array(window.get_slots(), ["W", "A", "S", "SP"], "fourth input should be visible before clear") - _expect_string(window.consume_pending_clear_reason(), "full", "fourth input should request full clear") - window.clear("test-reset") - - window.record("W") - var resolved: Dictionary = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_w", "W alone skill") - _expect_string(str(resolved.get("animation", "")), "warrior_w", "W should play row 6 animation") - _expect_bool(bool(resolved.get("clear_window", true)), false, "W skill should not clear window") - window.record("A") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_wa", "W+A skill") - _expect_array(window.get_slots(), ["W", "A"], "W+A should be visible before skill clear") - window.clear("test-reset") + _expect_string(window.get_contiguous_pattern(), "SP", "miss should break contiguous pattern") + window.clear(&"test-reset") - window.record("W") - window.record("Ø") - window.record("A") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_a", "miss should break W+A into trailing A only") - window.clear("test-reset") + window.record(&"W") + _expect_skill(resolver_script.resolve(window), &"skill_w", &"warrior_w", &"", false, "W alone") + window.record(&"A") + _expect_skill(resolver_script.resolve(window), &"skill_wa", &"warrior_wa", &"left", false, "W+A") + window.clear(&"test-reset") - window.record("W") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_w", "W alone before mirrored W+D") - window.record("D") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_wd", "W+D should mirror W+A skill") - _expect_string(str(resolved.get("animation", "")), "warrior_wa", "W+D should reuse W+A animation") - _expect_string(str(resolved.get("displacement", "")), "right", "W+D should move right") - _expect_array(window.get_slots(), ["W", "D"], "W+D should be visible before skill clear") - window.clear("test-reset") - - window.record("A") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_a", "A skill") - _expect_string(str(resolved.get("animation", "")), "warrior_a", "A should play row 10 animation") - _expect_string(str(resolved.get("displacement", "")), "left", "A should move left") - _expect_bool(bool(resolved.get("clear_window", true)), false, "A skill should not clear window") - window.record("A") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_aa", "A+A skill") - _expect_bool(bool(resolved.get("clear_window", true)), false, "A+A skill should not clear window") - window.record("A") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_aaa", "A+A+A skill") - _expect_bool(bool(resolved.get("clear_window", true)), false, "A+A+A skill should not clear window") - _expect_array(window.get_slots(), ["A", "A", "A"], "A+A+A should be visible before skill clear") - window.clear("test-reset") + window.record(&"W") + window.record(&"D") + _expect_skill(resolver_script.resolve(window), &"skill_wd", &"warrior_wa", &"right", false, "W+D") + window.clear(&"test-reset") - window.record("D") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_d", "D skill") - _expect_string(str(resolved.get("animation", "")), "warrior_a", "D should reuse row 10 animation") - _expect_string(str(resolved.get("displacement", "")), "right", "D should move right") - _expect_bool(bool(resolved.get("clear_window", true)), false, "D skill should not clear window") - window.record("D") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_dd", "D+D should mirror A+A skill") - _expect_string(str(resolved.get("animation", "")), "warrior_aa", "D+D should reuse A+A animation") - window.record("D") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_ddd", "D+D+D should mirror A+A+A skill") - _expect_string(str(resolved.get("animation", "")), "warrior_aaa", "D+D+D should reuse A+A+A animation") - _expect_array(window.get_slots(), ["D", "D", "D"], "D+D+D should be visible before skill clear") - window.clear("test-reset") + window.record(&"A") + _expect_skill(resolver_script.resolve(window), &"skill_a", &"warrior_a", &"left", false, "A") + window.record(&"A") + _expect_skill(resolver_script.resolve(window), &"skill_aa", &"warrior_aa", &"left", false, "A+A") + window.record(&"A") + _expect_skill(resolver_script.resolve(window), &"skill_aaa", &"warrior_aaa", &"left", false, "A+A+A") + window.clear(&"test-reset") - window.record("A") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_a_space", "A+Space skill") - _expect_string(str(resolved.get("animation", "")), "warrior_a_space", "A+Space should play row 17 animation") - _expect_string(str(resolved.get("displacement", "")), "left", "A+Space should move left") - _expect_bool(bool(resolved.get("clear_window", false)), true, "A+Space should clear window") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_a_space_space", "A+Space+Space skill") - _expect_string(str(resolved.get("animation", "")), "warrior_a_space_space", "A+Space+Space should play row 15 animation") - _expect_bool(bool(resolved.get("clear_window", false)), true, "A+Space+Space should clear window") - window.clear("test-reset") + window.record(&"D") + window.record(&"D") + window.record(&"D") + _expect_skill(resolver_script.resolve(window), &"skill_ddd", &"warrior_aaa", &"right", false, "D+D+D") + window.clear(&"test-reset") - window.record("D") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_d_space", "D+Space skill") - _expect_string(str(resolved.get("animation", "")), "warrior_a_space", "D+Space should reuse row 17 animation") - _expect_string(str(resolved.get("displacement", "")), "right", "D+Space should move right") - _expect_bool(bool(resolved.get("clear_window", false)), true, "D+Space should clear window") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_d_space_space", "D+Space+Space skill") - _expect_string(str(resolved.get("animation", "")), "warrior_a_space_space", "D+Space+Space should reuse row 15 animation") - _expect_bool(bool(resolved.get("clear_window", false)), true, "D+Space+Space should clear window") - window.clear("test-reset") + window.record(&"A") + window.record(&"SP") + _expect_skill(resolver_script.resolve(window), &"skill_a_space", &"warrior_a_space", &"left", true, "A+Space") + window.record(&"SP") + _expect_bool(resolver_script.resolve(window) == null, true, "A+Space+Space should not resolve") + window.clear(&"test-reset") - window.record("A") - window.record("A") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_aa_space", "A+A+Space skill") - _expect_bool(bool(resolved.get("clear_window", false)), true, "A+A+Space should clear window") - window.clear("test-reset") + window.record(&"S") + _expect_skill(resolver_script.resolve(window), &"skill_s", &"warrior_s", &"", false, "S") + window.record(&"SP") + _expect_skill(resolver_script.resolve(window), &"skill_s_projectile_1", &"warrior_s_projectile", &"", false, "S+Space") + window.record(&"SP") + _expect_skill(resolver_script.resolve(window), &"skill_s_projectile_2", &"warrior_s_projectile", &"", false, "S+Space+Space") + window.record(&"SP") + _expect_skill(resolver_script.resolve(window), &"skill_s_projectile_3", &"warrior_s_projectile", &"", false, "S+Space+Space+Space") + _expect_array(window.get_slots(), [&"S", &"SP", &"SP", &"SP"], "projectile chain should fill four slots") + _expect_string(str(window.consume_pending_clear_reason()), "full", "fourth slot should request full clear") + window.clear(&"test-reset") - window.record("A") - window.record("D") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_ad_space", "A+D+Space skill") - _expect_string(str(resolved.get("displacement", "")), "right", "A+D+Space should move toward the last direction") - _expect_bool(bool(resolved.get("clear_window", false)), true, "A+D+Space should clear window") - window.clear("test-reset") + window.record(&"S") + window.record(&"Ø") + window.record(&"SP") + _expect_bool(resolver_script.resolve(window) == null, true, "S miss Space should not resolve") + window.clear(&"test-reset") - window.record("D") - window.record("A") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_da_space", "D+A+Space skill") - _expect_string(str(resolved.get("displacement", "")), "left", "D+A+Space should move toward the last direction") - _expect_bool(bool(resolved.get("clear_window", false)), true, "D+A+Space should clear window") - window.clear("test-reset") + window.record(&"A") + window.record(&"D") + window.record(&"S") + window.record(&"SP") + _expect_skill(resolver_script.resolve(window), &"skill_s_projectile_1", &"warrior_s_projectile", &"", false, "S+Space in trailing slots") + resolver_script.clear_cache() + window.free() - window.record("D") - window.record("D") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_dd_space", "D+D+Space skill") - _expect_bool(bool(resolved.get("clear_window", false)), true, "D+D+Space should clear window") - window.clear("test-reset") - - window.record("S") - resolved = resolver_script.resolve(window) - _expect_bool(resolved.is_empty(), true, "S alone should not resolve a skill") - window.record("Ø") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_bool(resolved.is_empty(), true, "S miss Space should not resolve projectile skill") - window.clear("test-reset") - - window.record("S") - resolved = resolver_script.resolve(window) - _expect_bool(resolved.is_empty(), true, "S alone should not resolve a skill") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_s_projectile_1", "S+Space skill") - _expect_bool(bool(resolved.get("clear_window", true)), false, "S+Space skill should not clear window") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_s_projectile_2", "S+Space+Space skill") - _expect_bool(bool(resolved.get("clear_window", true)), false, "S+Space+Space skill should not clear window") - window.record("SP") - resolved = resolver_script.resolve(window) - _expect_string(str(resolved.get("id", "")), "skill_s_projectile_3", "S+Space+Space+Space skill") - _expect_bool(bool(resolved.get("clear_window", true)), false, "S+Space+Space+Space skill should not clear window") - _expect_array(window.get_slots(), ["S", "SP", "SP", "SP"], "S projectile chain should fill four slots before clear") - _finish() +func _expect_skill(skill: Resource, expected_id: StringName, expected_animation: StringName, expected_displacement: StringName, expected_clear: bool, label: String) -> void: + if skill == null: + failures.append("%s should resolve a skill" % label) + return + _expect_string(str(skill.get("id")), str(expected_id), "%s id" % label) + _expect_string(str(skill.get("animation")), str(expected_animation), "%s animation" % label) + _expect_string(str(skill.get("displacement")), str(expected_displacement), "%s displacement" % label) + _expect_bool(bool(skill.get("clear_window")), expected_clear, "%s clear_window" % label) + + func _expect_array(actual: Array, expected: Array, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) diff --git a/tests/test_input_component_intents.gd b/tests/test_input_component_intents.gd new file mode 100644 index 0000000..0a564d4 --- /dev/null +++ b/tests/test_input_component_intents.gd @@ -0,0 +1,82 @@ +extends SceneTree + +var failures: Array[String] = [] +var intents: Array = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var component: Node = load("res://scenes/components/input_component.gd").new() + root.add_child(component) + await process_frame + if not component.has_signal("intent_created"): + failures.append("InputComponent should expose intent_created") + else: + component.connect("intent_created", _on_intent_created) + + var normal := InputEventKey.new() + normal.pressed = true + normal.keycode = KEY_A + normal.physical_keycode = KEY_A + var handled: bool = component.call("handle_input_event", normal) + _expect_bool(handled, true, "A press should be handled") + _expect_int(intents.size(), 1, "A press should emit one intent") + if intents.size() == 1: + _expect_string(str(intents[0].get("symbol")), "A", "intent symbol") + _expect_string(str(intents[0].get("rhythm_action")), "a", "intent rhythm action") + _expect_string(str(intents[0].get("event_type")), "pressed", "intent event type") + _expect_bool(float(intents[0].get("timestamp_ms")) > 0.0, true, "intent timestamp") + + var echo := InputEventKey.new() + echo.pressed = true + echo.echo = true + echo.keycode = KEY_A + echo.physical_keycode = KEY_A + handled = component.call("handle_input_event", echo) + _expect_bool(handled, false, "echo press should not be handled") + _expect_int(intents.size(), 1, "echo press should not emit another intent") + + var release := InputEventKey.new() + release.pressed = false + release.keycode = KEY_A + release.physical_keycode = KEY_A + handled = component.call("handle_input_event", release) + _expect_bool(handled, true, "A release should be handled") + _expect_int(intents.size(), 2, "A release should emit one release intent") + if intents.size() == 2: + _expect_string(str(intents[1].get("event_type")), "released", "release event type") + + component.free() + _finish() + + +func _on_intent_created(intent) -> void: + intents.append(intent) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS input component intents") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_input_component_intents.gd.uid b/tests/test_input_component_intents.gd.uid new file mode 100644 index 0000000..cef2a22 --- /dev/null +++ b/tests/test_input_component_intents.gd.uid @@ -0,0 +1 @@ +uid://dq3egd3nxnund diff --git a/tests/test_player_air_attack.gd b/tests/test_player_air_attack.gd index f32cdbd..d504491 100644 --- a/tests/test_player_air_attack.gd +++ b/tests/test_player_air_attack.gd @@ -11,26 +11,31 @@ var failures: Array[String] = [] func _init() -> void: + _run.call_deferred() + + +func _run() -> void: var scene: PackedScene = load("res://scenes/characters/player.tscn") if scene == null: push_error("Could not load player.tscn") quit(1) return - + var player: Node = scene.instantiate() get_root().add_child(player) + await process_frame var animation_player: AnimationPlayer = player.get_node("AnimationPlayer") as AnimationPlayer - - _expect_action_has_key("player_w", KEY_W) - _expect_action_has_key("player_a", KEY_A) - _expect_action_has_key("player_d", KEY_D) - _expect_action_has_key("player_s", KEY_S) - _expect_action_has_key("player_space", KEY_SPACE) - + + _expect_action_has_key("combo_w", KEY_W) + _expect_action_has_key("combo_a", KEY_A) + _expect_action_has_key("combo_d", KEY_D) + _expect_action_has_key("combo_s", KEY_S) + _expect_action_has_key("combo_space", KEY_SPACE) + _expect_warrior_animation(animation_player, "warrior_idle", 1, 8) _expect_warrior_animation(animation_player, "warrior_w", 6, 6) _expect_warrior_animation(animation_player, "warrior_wa", 7, 5) - _expect_warrior_animation(animation_player, "warrior_s", 9, 10) + _expect_warrior_animation(animation_player, "warrior_s", 9, 3) _expect_warrior_animation(animation_player, "warrior_a", 10, 7) _expect_warrior_animation(animation_player, "warrior_aa", 11, 5) _expect_warrior_animation(animation_player, "warrior_aaa", 12, 8) @@ -42,24 +47,26 @@ func _init() -> void: _expect_warrior_animation(animation_player, "warrior_a_space_space", 15, 12) _expect_warrior_animation(animation_player, "warrior_a_space", 17, 10) _expect_charge_effect(player) - + if animation_player.has_animation("player_punch"): failures.append("Old player_punch animation should be removed") if animation_player.has_animation("挥砍"): failures.append("Old slash animation should be removed") - - player.call("submit_combo_input", "W") + + player.call("submit_combo_input", "W", "perfect") _expect_string(str(player.get("last_requested_skill_id")), "skill_w", "W alone should request row 6 skill") _expect_string(str(player.get("current_skill_animation")), "warrior_w", "W alone should play warrior_w") - player.call("submit_combo_input", "A") + player.call("submit_combo_input", "A", "perfect") + await create_timer(0.55).timeout + await physics_frame _expect_string(str(player.get("last_requested_skill_id")), "skill_wa", "W+A should request row 7 skill") _expect_string(str(player.get("current_skill_animation")), "warrior_wa", "W+A should play warrior_wa") - + var projectile := PlayerProjectile.new() get_root().add_child(projectile) _expect_projectile_animation(projectile) projectile.queue_free() - + player.queue_free() _finish() diff --git a/tests/test_player_combo_input.gd b/tests/test_player_combo_input.gd index 8b4028c..edc76f2 100644 --- a/tests/test_player_combo_input.gd +++ b/tests/test_player_combo_input.gd @@ -1,541 +1,169 @@ extends SceneTree +const InputIntentScript := preload("res://scenes/components/input_intent.gd") + var failures: Array[String] = [] var requested_skills: Array[String] = [] +var projectile_requests: Array[Dictionary] = [] +var judged_labels: Array[String] = [] func _init() -> void: + _run.call_deferred() + + +func _run() -> void: var scene: PackedScene = load("res://scenes/characters/player.tscn") if scene == null: push_error("Could not load player.tscn") quit(1) return - + var player: Node = scene.instantiate() - get_root().add_child(player) - var animation_player: AnimationPlayer = player.get_node("AnimationPlayer") as AnimationPlayer - var supports_energy := player.has_method("get_energy") and player.has_method("get_max_energy") - var supports_charge := player.has_method("get_charge") and player.has_method("get_max_charge") and player.has_method("is_charge_active") and player.has_method("is_charge_ready") - if player.has_signal("skill_requested"): - player.connect("skill_requested", _on_skill_requested) - else: - failures.append("Player missing skill_requested signal") - if not player.has_signal("charge_changed"): - failures.append("Player should expose charge_changed signal") - if supports_charge: - _expect_zero(player.call("get_charge"), "charge should start empty") - _expect_bool(player.call("is_charge_ready"), false, "charge should not start ready") - else: - failures.append("Player should expose charge getters") - if not player.has_signal("energy_changed"): - failures.append("Player should expose energy_changed signal") - if not player.has_signal("health_changed"): - failures.append("Player should expose health_changed signal") - if supports_energy: - _expect_int(player.call("get_max_energy"), 10, "energy bar should have ten segments") - _expect_int(player.call("get_energy"), 0, "energy should start empty") - else: - failures.append("Player should expose get_energy and get_max_energy") - if player.has_method("get_health") and player.has_method("get_max_health"): - _expect_int(player.call("get_health"), player.call("get_max_health"), "health should start full") - else: - failures.append("Player should expose get_health and get_max_health") - - _expect_action("player_w", KEY_W) - _expect_action("player_a", KEY_A) - _expect_action("player_d", KEY_D) - _expect_action("player_s", KEY_S) - _expect_action("player_space", KEY_SPACE) - - var w_event := InputEventKey.new() - w_event.pressed = true - w_event.physical_keycode = KEY_W - player.call("_input", w_event) - w_event.echo = true - player.call("_input", w_event) - _expect_array(player.call("get_combo_slots"), ["W"], "W key press should enter once and ignore echo repeat") - _expect_last_skill("skill_w", "W alone should request row 6 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_w", "W should play row 6 animation") - player.get("combo_window").clear("test-reset") - requested_skills.clear() - - var a_event := InputEventKey.new() - a_event.pressed = true - a_event.physical_keycode = KEY_A - var a_release_event := InputEventKey.new() - a_release_event.pressed = false - a_release_event.physical_keycode = KEY_A - player.call("_input", a_event) - _expect_array(player.call("get_combo_slots"), ["A"], "A alone should stay visible in the combo window") - _expect_last_skill("skill_a", "A should request row 10 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_a", "A should play row 10 animation") - _expect_negative((player as CharacterBody2D).velocity.x, "A should lunge left") - player.get("combo_window").clear("test-reset") - requested_skills.clear() + root.add_child(player) + await process_frame - if supports_charge: - player.call("_input", a_event) - player.set("state", Character.State.IDLE) - player.set("attack_time_left", 0.0) - player.call("_process", 0.2) - _expect_bool(player.call("is_charge_active"), true, "holding A after its animation should enter charge state") - _expect_string(animation_player.current_animation, "warrior_charge_intro", "holding A charge should start with charge intro animation") - _expect_positive(player.call("get_charge"), "holding A should grow charge") - var charge_effect := player.get_node_or_null("ChargeEffectSprite") as Sprite2D - if charge_effect == null: - failures.append("ChargeEffectSprite missing during A charge test") - else: - _expect_bool(charge_effect.visible, true, "holding A should show charge effect") - requested_skills.clear() - player.call("_input", a_release_event) - _expect_bool(player.call("is_charge_active"), false, "early A release should cancel charge") - _expect_bool(player.call("is_charge_ready"), false, "early A release should not be ready") - _expect_no_skill_requested("early A release should not request charge release skill") - player.get("combo_window").clear("test-reset") - requested_skills.clear() + for node_name: String in ["InputComponent", "ComboWindow", "ActionResolver", "ActionExecutor", "MotionExecutor", "BurstComponent", "ChargeComponent", "EnergyComponent", "HealthComponent", "DamageReceiver", "DamageEmitter"]: + if not player.has_node(node_name): + failures.append("Player missing component %s" % node_name) + for signal_name: String in ["skill_requested", "energy_changed", "health_changed", "charge_changed", "projectile_requested"]: + if not player.has_signal(signal_name): + failures.append("Player missing signal %s" % signal_name) - player.call("_input", a_event) - player.set("state", Character.State.IDLE) - player.set("attack_time_left", 0.0) - player.call("_process", player.call("get_max_charge") + 0.1) - _expect_bool(player.call("is_charge_ready"), true, "held A should become ready when charge is full") - _expect_string(animation_player.current_animation, "warrior_charge_loop", "full A hold should keep charge loop animation") - requested_skills.clear() - player.call("_input", a_release_event) - _expect_last_skill("skill_a_charge_release", "full A release should request charge release skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_charge_release", "full A release should play row 13 animation") - _expect_negative((player as CharacterBody2D).velocity.x, "full A release should lunge left") - _expect_bool(player.call("is_charge_active"), false, "full A release should leave charge state") - player.get("combo_window").clear("test-reset") - requested_skills.clear() + player.connect("skill_requested", _on_skill_requested) + player.connect("projectile_requested", _on_projectile_requested) + _event_bus().connect("action_judged", _on_action_judged) - player.call("_input", a_event) - player.call("_input", a_event) - _expect_array(player.call("get_combo_slots"), ["A", "A"], "two separate A presses should both enter the combo window") - _expect_last_skill("skill_aa", "A+A should request row 11 skill") - player.get("combo_window").clear("test-reset") - requested_skills.clear() + _expect_action("move_left", KEY_A) + _expect_action("move_right", KEY_D) + _expect_action("combo_w", KEY_W) + _expect_action("combo_a", KEY_A) + _expect_action("combo_d", KEY_D) + _expect_action("combo_s", KEY_S) + _expect_action("combo_space", KEY_SPACE) + _expect_bool(InputMap.has_action("player_space"), false, "player_space should be removed") - player.call("_input", a_event) - Input.action_press("player_a") - player.set("state", Character.State.IDLE) - player.set("velocity", Vector2.ZERO) - player.call("handle_input") - Input.action_release("player_a") - _expect_array(player.call("get_combo_slots"), ["A"], "one A key event should not be recorded again by physics polling") - _expect_last_skill("skill_a", "single A key event should still be the last requested skill after physics polling") - _expect_zero((player as CharacterBody2D).velocity.x, "holding consumed A key should not keep sliding after combo input") - var a_release_after_single_hold := InputEventKey.new() - a_release_after_single_hold.pressed = false - a_release_after_single_hold.physical_keycode = KEY_A - player.call("_input", a_release_after_single_hold) - player.call("flush_pending_combo_clear") - player.get("combo_window").clear("test-reset") - requested_skills.clear() - - var d_event := InputEventKey.new() - d_event.pressed = true - d_event.physical_keycode = KEY_D - player.call("_input", d_event) - _expect_array(player.call("get_combo_slots"), ["D"], "D key press should enter the combo window") - _expect_last_skill("skill_d", "D should request mirrored row 10 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_a", "D should reuse row 10 animation") - _expect_positive((player as CharacterBody2D).velocity.x, "D should lunge right") - player.call("flush_pending_combo_clear") - player.get("combo_window").clear("test-reset") - requested_skills.clear() + _expect_int(player.call("get_max_energy"), 10, "energy bar should have ten segments") + _expect_int(player.call("get_energy"), 0, "energy should start empty") + _expect_int(player.call("get_health"), player.call("get_max_health"), "health should start full") - if supports_charge: - player.call("_input", d_event) - player.set("state", Character.State.IDLE) - player.set("attack_time_left", 0.0) - player.call("_process", player.call("get_max_charge") + 0.1) - _expect_string(animation_player.current_animation, "warrior_charge_loop", "full D hold should keep charge loop animation") - var d_release_event := InputEventKey.new() - d_release_event.pressed = false - d_release_event.physical_keycode = KEY_D - requested_skills.clear() - player.call("_input", d_release_event) - _expect_last_skill("skill_d_charge_release", "full D release should request charge release skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_charge_release", "full D release should reuse row 13 animation") - _expect_positive((player as CharacterBody2D).velocity.x, "full D release should lunge right") - player.get("combo_window").clear("test-reset") - requested_skills.clear() - - player.call("_input", d_event) - player.call("_input", d_release_event) - player.call("_input", d_event) - player.set("state", Character.State.IDLE) - player.set("attack_time_left", 0.0) - player.call("_process", 0.2) - _expect_array(player.call("get_combo_slots"), ["D", "D"], "second held D should keep D+D in the combo window") - _expect_last_skill("skill_dd", "second held D should trigger D+D skill before charging") - _expect_bool(player.call("is_charge_active"), true, "holding second D after D+D animation should enter charge state") - _expect_string(animation_player.current_animation, "warrior_charge_intro", "holding second D should start with charge intro animation") - _expect_positive(player.call("get_charge"), "holding second D should grow charge after D+D") - requested_skills.clear() - player.call("_input", d_release_event) - _expect_bool(player.call("is_charge_active"), false, "releasing held second D should cancel D+D charge") - player.get("combo_window").clear("test-reset") - requested_skills.clear() - - player.call("_input", a_event) - player.call("_input", a_release_event) - player.call("_input", a_event) - player.set("state", Character.State.IDLE) - player.set("attack_time_left", 0.0) - player.call("_process", 0.2) - _expect_array(player.call("get_combo_slots"), ["A", "A"], "second held A should keep A+A in the combo window") - _expect_last_skill("skill_aa", "second held A should trigger A+A skill before charging") - _expect_bool(player.call("is_charge_active"), true, "holding second A after A+A animation should enter charge state") - _expect_string(animation_player.current_animation, "warrior_charge_intro", "holding second A should start with charge intro animation") - _expect_positive(player.call("get_charge"), "holding second A should grow charge after A+A") - requested_skills.clear() - player.call("_input", a_release_event) - _expect_bool(player.call("is_charge_active"), false, "releasing held second A should cancel A+A charge") - player.get("combo_window").clear("test-reset") - requested_skills.clear() - - player.call("_input", d_event) - player.call("_input", d_event) - Input.action_press("player_d") - player.set("state", Character.State.IDLE) - player.set("velocity", Vector2.ZERO) - player.call("handle_input") - Input.action_release("player_d") - _expect_array(player.call("get_combo_slots"), ["D", "D"], "held second D should still record D+D once") - _expect_last_skill("skill_dd", "held second D should request D+D skill") - _expect_zero((player as CharacterBody2D).velocity.x, "holding consumed second D should not slide in idle") - var d_release_after_hold := InputEventKey.new() - d_release_after_hold.pressed = false - d_release_after_hold.physical_keycode = KEY_D - player.call("_input", d_release_after_hold) - player.get("combo_window").clear("test-reset") - requested_skills.clear() - - player.call("_input", a_event) - player.call("_input", a_event) - Input.action_press("player_a") - player.set("state", Character.State.IDLE) - player.set("velocity", Vector2.ZERO) - player.call("handle_input") - Input.action_release("player_a") - _expect_array(player.call("get_combo_slots"), ["A", "A"], "held second A should still record A+A once") - _expect_last_skill("skill_aa", "held second A should request A+A skill") - _expect_zero((player as CharacterBody2D).velocity.x, "holding consumed second A should not slide in idle") - var a_release_after_hold := InputEventKey.new() - a_release_after_hold.pressed = false - a_release_after_hold.physical_keycode = KEY_A - player.call("_input", a_release_after_hold) - player.get("combo_window").clear("test-reset") - requested_skills.clear() - - Input.action_press("player_a") - player.set("state", Character.State.IDLE) - player.set("velocity", Vector2.ZERO) - player.call("handle_input") - _expect_negative((player as CharacterBody2D).velocity.x, "A should move the player left") - _expect_vector(player.get("heading"), Vector2.LEFT, "A should face left") - _expect_array(player.call("get_combo_slots"), [], "physics-only movement polling should not write combo slots") - Input.action_release("player_a") - player.call("flush_pending_combo_clear") - player.get("combo_window").clear("test-reset") - - Input.action_press("player_d") - player.set("state", Character.State.IDLE) - player.set("velocity", Vector2.ZERO) - player.call("handle_input") - _expect_positive((player as CharacterBody2D).velocity.x, "D should move the player right") - _expect_vector(player.get("heading"), Vector2.RIGHT, "D should face right") - Input.action_release("player_d") - player.get("combo_window").clear("test-reset") - - var unhandled_s_event := InputEventKey.new() - unhandled_s_event.pressed = true - unhandled_s_event.physical_keycode = KEY_S - player.call("_unhandled_input", unhandled_s_event) - _expect_array(player.call("get_combo_slots"), ["S"], "unhandled S should enter S") - _expect_no_skill_requested("S alone should not request a skill") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "S", "miss") - _expect_array(player.call("get_combo_slots"), ["Ø"], "miss should display Ø in the combo window") - player.get("combo_window").clear("test-reset") - - if supports_energy: - player.set("current_energy", 0) - player.call("submit_combo_input", "W", "perfect") - _expect_int(player.call("get_energy"), 0, "W should not add energy") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "A", "good") - _expect_int(player.call("get_energy"), 1, "A skill should add one energy segment") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "D", "bad") - _expect_int(player.call("get_energy"), 2, "D skill should add one energy segment") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "S", "miss") - _expect_int(player.call("get_energy"), 2, "miss input should not add energy") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "A", "perfect") - player.call("submit_combo_input", "A", "perfect") - _expect_int(player.call("get_energy"), 4, "A+A skill should add one energy segment") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "D", "perfect") - player.call("submit_combo_input", "D", "perfect") - player.call("submit_combo_input", "D", "perfect") - _expect_int(player.call("get_energy"), 7, "D, D+D, and D+D+D skills should each add one energy segment") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "A", "miss") - _expect_int(player.call("get_energy"), 7, "missed A should not add energy") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "SP", "perfect") - _expect_int(player.call("get_energy"), 7, "Space should not add energy") - player.call("flush_pending_combo_clear") - player.get("combo_window").clear("test-reset") - player.set("current_energy", 9) - player.call("submit_combo_input", "W", "perfect") - _expect_int(player.call("get_energy"), 9, "W should not change energy near cap") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "A", "perfect") - _expect_int(player.call("get_energy"), 10, "A energy reward should cap at ten segments") - player.get("combo_window").clear("test-reset") - - requested_skills.clear() - player.call("_play_skill_animation", "warrior_a", Vector2.LEFT) - player.call("submit_combo_input", "A", "miss") - _expect_array(player.call("get_combo_slots"), ["Ø"], "missed A should display Ø in the combo window") - _expect_no_skill_requested("missed A should not request a skill") - _expect_zero((player as CharacterBody2D).velocity.x, "missed A should stop horizontal lunge") - _expect_int(int(player.get("state")), Character.State.IDLE, "missed A should return to idle state") - _expect_string(animation_player.current_animation, "warrior_idle", "missed A should keep idle animation") - player.get("combo_window").clear("test-reset") - - requested_skills.clear() - player.call("_play_skill_animation", "warrior_a", Vector2.RIGHT) - player.call("submit_combo_input", "D", "miss") - _expect_array(player.call("get_combo_slots"), ["Ø"], "missed D should display Ø in the combo window") - _expect_no_skill_requested("missed D should not request a skill") - _expect_zero((player as CharacterBody2D).velocity.x, "missed D should stop horizontal lunge") - _expect_int(int(player.get("state")), Character.State.IDLE, "missed D should return to idle state") - _expect_string(animation_player.current_animation, "warrior_idle", "missed D should keep idle animation") - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "W", "perfect") + _expect_last_judgement("perfect", "W should display Perfect judgement") + _expect_array(player.call("get_combo_slots"), [&"W"], "W should enter the combo window") + _expect_last_skill("skill_w", "W should request skill_w") + _expect_string(str(player.get("current_skill_animation")), "warrior_w", "W should play warrior_w") + _expect_int(player.call("get_energy"), 0, "W should not add energy") + player.get_node("ComboWindow").clear(&"test-reset") + requested_skills.clear() + await _wait_for_action_settle() + player.call("submit_combo_input", "A", "good") - _expect_array(player.call("get_combo_slots"), ["W", "A"], "W+A should stay visible after skill trigger") - _expect_last_skill("skill_wa", "W+A should request row 7 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_wa", "W+A should play row 7 animation") - _expect_negative((player as CharacterBody2D).velocity.x, "W+A should lunge left") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), ["W", "A"], "W+A should not clear combo window") - player.get("combo_window").clear("test-reset") + _expect_last_judgement("good", "A should display Good judgement") + player.get_node("ChargeComponent").finish_hold(&"A") + _expect_last_skill("skill_a", "A should request skill_a") + _expect_int(player.call("get_energy"), 0, "A should not reward during startup") + await _wait_for_active_phase() + _expect_int(player.call("get_energy"), 1, "A should reward one energy") + _expect_negative((player as CharacterBody2D).velocity.x, "A should lunge left") + player.get_node("ComboWindow").clear(&"test-reset") + await _wait_for_action_settle() - player.call("submit_combo_input", "W", "perfect") - player.call("submit_combo_input", "D", "good") - _expect_array(player.call("get_combo_slots"), ["W", "D"], "W+D should stay visible after skill trigger") - _expect_last_skill("skill_wd", "W+D should request mirrored row 7 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_wa", "W+D should reuse row 7 animation") - _expect_positive((player as CharacterBody2D).velocity.x, "W+D should lunge right") - _expect_vector(player.get("heading"), Vector2.RIGHT, "W+D should face right") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), ["W", "D"], "W+D should not clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "A") - _expect_array(player.call("get_combo_slots"), ["A", "A", "A"], "A+A+A should stay visible after skill trigger") - _expect_last_skill("skill_aaa", "A+A+A should request row 12 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_aaa", "A+A+A should play row 12 animation") - _expect_negative((player as CharacterBody2D).velocity.x, "A+A+A should lunge left") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), ["A", "A", "A"], "A+A+A should not clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "A") - _expect_array(player.call("get_combo_slots"), ["A", "A", "A", "A"], "fourth A should still fill the old four-slot window before clear") - _expect_last_skill("skill_a", "fourth A after A+A+A should play normal A animation") - _expect_string(str(player.get("current_skill_animation")), "warrior_a", "fourth A should fall back to row 10 animation") - _expect_negative((player as CharacterBody2D).velocity.x, "fourth A should lunge left as a normal A") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "fourth A full window should clear after display") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "D") - _expect_array(player.call("get_combo_slots"), ["D", "D", "D"], "D+D+D should stay visible after skill trigger") - _expect_last_skill("skill_ddd", "D+D+D should request mirrored row 12 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_aaa", "D+D+D should reuse row 12 animation") - _expect_positive((player as CharacterBody2D).velocity.x, "D+D+D should lunge right") - _expect_vector(player.get("heading"), Vector2.RIGHT, "D+D+D should face right") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), ["D", "D", "D"], "D+D+D should not clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "D") - _expect_array(player.call("get_combo_slots"), ["D", "D", "D", "D"], "fourth D should still fill the old four-slot window before clear") - _expect_last_skill("skill_d", "fourth D after D+D+D should play normal D animation") - _expect_string(str(player.get("current_skill_animation")), "warrior_a", "fourth D should fall back to row 10 animation") - _expect_positive((player as CharacterBody2D).velocity.x, "fourth D should lunge right as a normal D") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "fourth D full window should clear after display") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "SP") - _expect_array(player.call("get_combo_slots"), ["A", "SP"], "A+Space should be visible before skill clear") - _expect_last_skill("skill_a_space", "A+Space should request row 17 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_a_space", "A+Space should play row 17 animation") - _expect_negative((player as CharacterBody2D).velocity.x, "A+Space should lunge left") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "A+Space should clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "SP") - _expect_array(player.call("get_combo_slots"), ["D", "SP"], "D+Space should be visible before skill clear") - _expect_last_skill("skill_d_space", "D+Space should request mirrored row 17 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_a_space", "D+Space should reuse row 17 animation") - _expect_positive((player as CharacterBody2D).velocity.x, "D+Space should lunge right") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "D+Space should clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "SP") - player.call("submit_combo_input", "SP") - _expect_array(player.call("get_combo_slots"), ["A", "SP", "SP"], "A+Space+Space should cancel the pending A+Space clear and stay visible before its own clear") - _expect_last_skill("skill_a_space_space", "A+Space+Space should request row 15 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_a_space_space", "A+Space+Space should play row 15 animation") - _expect_negative((player as CharacterBody2D).velocity.x, "A+Space+Space should lunge left") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "A+Space+Space should clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "SP") - player.call("submit_combo_input", "SP") - _expect_array(player.call("get_combo_slots"), ["D", "SP", "SP"], "D+Space+Space should cancel the pending D+Space clear and stay visible before its own clear") - _expect_last_skill("skill_d_space_space", "D+Space+Space should request mirrored row 15 skill") - _expect_string(str(player.get("current_skill_animation")), "warrior_a_space_space", "D+Space+Space should reuse row 15 animation") - _expect_positive((player as CharacterBody2D).velocity.x, "D+Space+Space should lunge right") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "D+Space+Space should clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "A") - player.call("submit_combo_input", "SP") - _expect_array(player.call("get_combo_slots"), ["A", "A", "SP"], "A+A+Space should be visible before skill clear") - _expect_last_skill("skill_aa_space", "A+A+Space should request clear skill") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "A+A+Space should clear combo window") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "D") - player.call("submit_combo_input", "SP") - _expect_array(player.call("get_combo_slots"), ["D", "D", "SP"], "D+D+Space should be visible before skill clear") - _expect_last_skill("skill_dd_space", "D+D+Space should request clear skill") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "D+D+Space should clear combo window") - player.get("combo_window").clear("test-reset") + player.call("submit_combo_input", "D", "bad") + _expect_last_judgement("bad", "D should display Bad judgement") + player.get_node("ChargeComponent").finish_hold(&"D") + _expect_last_skill("skill_d", "D should request skill_d") + await _wait_for_active_phase() + _expect_int(player.call("get_energy"), 2, "D should reward one energy") + _expect_positive((player as CharacterBody2D).velocity.x, "D should lunge right") + player.get_node("ComboWindow").clear(&"test-reset") requested_skills.clear() - - player.call("submit_combo_input", "SP") - _expect_array(player.call("get_combo_slots"), ["SP"], "Space should be visible before space clear") - _expect_no_skill_requested("Space alone should not request a skill") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "Space should clear combo window") + await _wait_for_action_settle() + + _reset_player_action_state(player) + _press_release_symbol(player, &"D") + await _wait_for_cancel_consumption() + _press_release_symbol(player, &"D") + await _wait_for_cancel_consumption() + player.call("_on_input_intent_created", _perfect_intent(&"D", &"pressed")) + _expect_last_skill("skill_ddd", "D+D+D should request the third D skill") + _expect_string(str(player.get("current_skill_animation")), "warrior_aaa", "D+D+D should start warrior_aaa") + player.call("_on_input_intent_created", _perfect_intent(&"D", &"released")) + await _wait_for_cancel_consumption() + _expect_array(player.call("get_combo_slots"), [&"D", &"D", &"D"], "D+D+D should keep the three input slots") + _expect_int(int(player.get("state")), Character.State.IDLE, "D+D+D should return player presentation to idle when the action ends") + _expect_string(_current_animation(player), "warrior_idle", "D+D+D should not leave warrior_aaa stuck after the action ends") + player.call("_on_input_intent_created", _perfect_intent(&"D", &"pressed")) + _expect_array(player.call("get_combo_slots"), [&"D", &"D", &"D", &"D"], "Fourth D should fill the four-slot window before clear") + _expect_last_skill("skill_d", "Fourth D after D+D+D should fall back to normal D") + _expect_string(str(player.get("current_skill_animation")), "warrior_a", "Fourth D should play normal D animation") + _expect_string(str(player.get("heading")), str(Vector2.RIGHT), "Fourth D should face right") + player.call("_on_input_intent_created", _perfect_intent(&"D", &"released")) + await create_timer(0.4).timeout + await process_frame + _expect_array(player.call("get_combo_slots"), [], "Fourth D full window should clear after display") + + _reset_player_action_state(player) + player.call("_on_input_intent_created", _perfect_intent(&"A", &"pressed")) + player.call("_on_input_intent_created", _perfect_intent(&"A", &"released")) + player.call("_on_input_intent_created", _perfect_intent(&"D", &"pressed")) + _expect_array(player.call("get_combo_slots"), [&"A"], "A then immediate D should keep D outside the window during A startup") + _expect_string(str(player.get("heading")), str(Vector2.LEFT), "Pending D should not change heading before it is consumed") + player.call("_on_input_intent_created", _perfect_intent(&"D", &"released")) + await _wait_for_cancel_consumption() + _expect_array(player.call("get_combo_slots"), [&"A", &"D"], "A then D should enter the window only when the phase allows it") + _expect_last_skill("skill_d", "A then D unresolved prefix should fall back to normal D") + _expect_string(str(player.get("current_skill_animation")), "warrior_a", "A then D fallback should play normal D animation") + _expect_string(str(player.get("heading")), str(Vector2.RIGHT), "A then D fallback should face right") + await _wait_for_action_settle() + _expect_int(int(player.get("state")), Character.State.IDLE, "A then D fallback should settle back to idle") + _expect_string(_current_animation(player), "warrior_idle", "A then D fallback should not leave the previous presentation stuck") + _reset_player_action_state(player) + player.get_node("EnergyComponent").set_current(2) + + player.call("submit_combo_input", "S", "miss") + _expect_last_judgement("miss", "S should display Miss judgement") + _expect_array(player.call("get_combo_slots"), [&"Ø"], "miss should enter the combo window as an explicit placeholder") + _expect_no_skill_requested("miss should not request a skill") + _expect_int(player.call("get_energy"), 2, "miss should not change energy") + await create_timer(0.4).timeout + await process_frame + _expect_array(player.call("get_combo_slots"), [&"Ø"], "miss should not clear the combo window by itself") + player.get_node("ComboWindow").clear(&"test-reset") + await _wait_for_action_settle() + + player.call("submit_combo_input", "A", "perfect") + player.get_node("ChargeComponent").finish_hold(&"A") + player.call("submit_combo_input", "SP", "perfect") + _expect_array(player.call("get_combo_slots"), [&"A"], "A+Space should wait for cancel window before adding Space") + await _wait_for_cancel_consumption() + _expect_last_skill("skill_a_space", "A+Space should request clear-window skill") + _expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "A+Space should stay visible before clear") + await _wait_for_action_settle() + _expect_array(player.call("get_combo_slots"), [], "A+Space should clear when the action finishes") + player.get_node("ComboWindow").clear(&"test-reset") + await _wait_for_action_settle() + + player.get_node("EnergyComponent").set_current(3) + projectile_requests.clear() requested_skills.clear() - var space_event := InputEventKey.new() - space_event.pressed = true - space_event.physical_keycode = KEY_SPACE - player.set("state", Character.State.IDLE) - player.set("current_skill_animation", "") - animation_player.play("warrior_idle") - player.call("_input", space_event) - Input.action_press("player_space") - Input.action_press("jump") - player.call("handle_input") - Input.action_release("jump") - Input.action_release("player_space") - _expect_int(int(player.get("state")), Character.State.IDLE, "direct Space should keep idle state") - _expect_string(animation_player.current_animation, "warrior_idle", "direct Space should keep idle animation") - _expect_no_skill_requested("direct Space key should not request a skill") - player.call("flush_pending_combo_clear") - player.get("combo_window").clear("test-reset") - - if supports_energy: - player.set("current_energy", 3) player.call("submit_combo_input", "S", "perfect") player.call("submit_combo_input", "SP", "perfect") + _expect_array(player.call("get_combo_slots"), [&"S"], "S+Space should wait for cancel window before adding Space") + await _wait_for_cancel_consumption() _expect_last_skill("skill_s_projectile_1", "S+Space should request projectile skill") - _expect_projectile_count(1, "S+Space should fire one projectile") - if supports_energy: - _expect_int(player.call("get_energy"), 0, "S+Space should spend three energy without input rewards") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), ["S", "SP"], "S+Space should not clear combo window") - if supports_energy: - player.set("current_energy", 2) - player.call("submit_combo_input", "SP", "perfect") - _expect_last_skill("skill_s_projectile_2", "S+Space+Space should request projectile skill") - _expect_projectile_count(2, "Second Space should fire another projectile") - if supports_energy: - _expect_int(player.call("get_energy"), 0, "S+Space+Space should spend two energy without Space reward") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), ["S", "SP", "SP"], "S+Space+Space should not clear combo window") - if supports_energy: - player.set("current_energy", 1) - player.call("submit_combo_input", "SP", "perfect") - _expect_last_skill("skill_s_projectile_3", "S+Space+Space+Space should request projectile skill") - _expect_projectile_count(3, "Third Space should fire another projectile") - if supports_energy: - _expect_int(player.call("get_energy"), 0, "S+Space+Space+Space should spend one energy without Space reward") - _expect_array(player.call("get_combo_slots"), ["S", "SP", "SP", "SP"], "projectile chain should fill four slots before clear") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "projectile chain should clear combo window because four slots are full") + await _wait_for_active_phase() + _expect_int(player.call("get_energy"), 0, "S+Space should spend three energy") + _expect_int(projectile_requests.size(), 1, "S+Space should emit one projectile request") + await _wait_for_action_settle() - if supports_energy: - requested_skills.clear() - player.set("current_energy", 0) - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "S", "bad") - player.call("submit_combo_input", "SP", "bad") - _expect_no_skill_requested("S+Space should not execute when energy is insufficient") - _expect_projectile_count(3, "insufficient energy should not fire another projectile") - _expect_int(player.call("get_energy"), 0, "insufficient projectile attempt should leave energy at zero") - player.get("combo_window").clear("test-reset") + player.get_node("ChargeComponent").cancel() + Input.action_press(&"move_left") + player.set("state", Character.State.IDLE) + player.set("velocity", Vector2.ZERO) + player.call("handle_input") + Input.action_release(&"move_left") + _expect_negative((player as CharacterBody2D).velocity.x, "move_left should move the player left") - requested_skills.clear() - if supports_energy: - player.set("current_energy", 10) - player.get("combo_window").clear("test-reset") - player.call("submit_combo_input", "S", "perfect") - player.call("submit_combo_input", "A", "miss") - player.call("submit_combo_input", "SP", "perfect") - _expect_array(player.call("get_combo_slots"), ["S", "Ø", "SP"], "miss should remain visible between S and Space") - _expect_no_skill_requested("S miss Space should not execute projectile skill") - _expect_projectile_count(3, "S miss Space should not fire another projectile") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "S miss Space should clear as a normal Space input") - player.get("combo_window").clear("test-reset") - - player.call("submit_combo_input", "W") - player.call("submit_combo_input", "W") - player.call("submit_combo_input", "W") - player.call("submit_combo_input", "W") - _expect_array(player.call("get_combo_slots"), ["W", "W", "W", "W"], "four non-skill inputs should be visible before clear") - player.call("flush_pending_combo_clear") - _expect_array(player.call("get_combo_slots"), [], "four non-skill inputs should clear combo window") - player.queue_free() _finish() @@ -565,6 +193,20 @@ func _expect_no_skill_requested(label: String) -> void: failures.append("%s: expected no skill, got %s" % [label, requested_skills[requested_skills.size() - 1]]) +func _expect_last_judgement(expected: String, label: String) -> void: + if judged_labels.is_empty(): + failures.append("%s: no judgement displayed" % label) + return + var actual := judged_labels[judged_labels.size() - 1] + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + func _expect_string(actual: String, expected: String, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) @@ -575,20 +217,7 @@ func _expect_int(actual: int, expected: int, label: String) -> void: failures.append("%s: expected %d, got %d" % [label, expected, actual]) -func _expect_projectile_count(expected: int, label: String) -> void: - var actual := _count_projectiles(get_root()) - if actual != expected: - failures.append("%s: expected %d, got %d" % [label, expected, actual]) - - -func _count_projectiles(node: Node) -> int: - var total := 1 if node.is_in_group("player_projectiles") else 0 - for child: Node in node.get_children(): - total += _count_projectiles(child) - return total - - -func _expect_array(actual: Array, expected: Array, label: String) -> void: +func _expect_bool(actual: bool, expected: bool, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) @@ -603,25 +232,80 @@ func _expect_positive(actual: float, label: String) -> void: failures.append("%s: expected positive x velocity, got %.3f" % [label, actual]) -func _expect_bool(actual: bool, expected: bool, label: String) -> void: - if actual != expected: - failures.append("%s: expected %s, got %s" % [label, expected, actual]) - - -func _expect_zero(actual: float, label: String) -> void: - if not is_zero_approx(actual): - failures.append("%s: expected zero x velocity, got %.3f" % [label, actual]) - - -func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: - if not actual.is_equal_approx(expected): - failures.append("%s: expected %s, got %s" % [label, expected, actual]) - - func _on_skill_requested(skill_id: String) -> void: requested_skills.append(skill_id) +func _on_projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2) -> void: + projectile_requests.append({ + "scene": projectile_scene, + "position": spawn_position, + "direction": direction, + }) + + +func _on_action_judged(_action_name: StringName, rating: Dictionary) -> void: + judged_labels.append(str(rating.get("label", ""))) + + +func _event_bus() -> Node: + 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 _reset_player_action_state(player: Node) -> void: + player.get_node("ComboWindow").clear(&"test-reset") + player.get_node("ActionController").call("_reset_to_idle") + player.get_node("ChargeComponent").cancel() + player.set("state", Character.State.IDLE) + player.set("attack_time_left", 0.0) + player.set("velocity", Vector2.ZERO) + player.set("heading", Vector2.RIGHT) + player.set("current_skill_animation", "warrior_idle") + var animation_player := player.get_node("AnimationPlayer") as AnimationPlayer + if animation_player != null and animation_player.has_animation("warrior_idle"): + animation_player.play("warrior_idle") + requested_skills.clear() + + +func _press_release_symbol(player: Node, symbol: StringName) -> void: + player.call("_on_input_intent_created", _perfect_intent(symbol, &"pressed")) + player.call("_on_input_intent_created", _perfect_intent(symbol, &"released")) + + +func _perfect_intent(symbol: StringName, event_type: StringName) -> RefCounted: + var rhythm_action: StringName = symbol.to_lower() + if symbol == &"SP": + rhythm_action = &"space" + var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, event_type, float(Time.get_ticks_msec())) + intent.judgement = {"label": "perfect", "diff": 0.0} + return intent + + +func _current_animation(player: Node) -> String: + var animation_player := player.get_node("AnimationPlayer") as AnimationPlayer + return animation_player.current_animation if animation_player != null else "" + + +func _wait_for_active_phase() -> void: + await create_timer(0.2).timeout + await physics_frame + + +func _wait_for_cancel_consumption() -> void: + await create_timer(0.55).timeout + await physics_frame + + +func _wait_for_action_settle() -> void: + await create_timer(0.7).timeout + await physics_frame + + func _finish() -> void: if failures.is_empty(): print("PASS player combo input") diff --git a/tests/test_rhythm_action_architecture.gd b/tests/test_rhythm_action_architecture.gd new file mode 100644 index 0000000..0a8955a --- /dev/null +++ b/tests/test_rhythm_action_architecture.gd @@ -0,0 +1,236 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _check_autoloads() + _check_chart_layer() + _check_action_data() + _check_combo_window() + _check_action_resolver() + _check_player_components() + _check_no_legacy_runtime_architecture() + _check_combat_manager() + _finish() + + +func _check_autoloads() -> void: + _expect(ProjectSettings.has_setting("autoload/RhythmManager"), "RhythmManager should be registered as an autoload") + if ProjectSettings.has_setting("autoload/RhythmManager"): + _expect(str(ProjectSettings.get_setting("autoload/RhythmManager")).contains("res://autoload/rhythm_manager.gd"), "RhythmManager autoload should point at autoload/rhythm_manager.gd") + _expect(ProjectSettings.has_setting("autoload/CombatManager"), "CombatManager should be registered as an autoload") + if ProjectSettings.has_setting("autoload/CombatManager"): + _expect(str(ProjectSettings.get_setting("autoload/CombatManager")).contains("res://autoload/combat_manager.gd"), "CombatManager autoload should point at autoload/combat_manager.gd") + + var rhythm_script: Script = load("res://autoload/rhythm_manager.gd") + _expect(rhythm_script != null, "autoload/rhythm_manager.gd should load") + if rhythm_script != null: + var rhythm: Node = rhythm_script.new() as Node + _expect(_has_property(rhythm, "beat_time"), "RhythmManager should expose beat_time") + _expect(_has_property(rhythm, "beat_index"), "RhythmManager should expose beat_index") + _expect(_has_property(rhythm, "judgement_scale"), "RhythmManager should expose judgement_scale") + _expect(rhythm.has_method("judge"), "RhythmManager should expose judge(timestamp)") + _expect(rhythm.has_method("judge_action"), "RhythmManager should expose judge_action(action_name)") + rhythm.free() + + +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() + + +func _check_action_data() -> void: + var action_script: Script = load("res://resources/action_data.gd") + _expect(action_script != null, "resources/action_data.gd should load") + if action_script == null: + return + var action: Resource = action_script.new() as Resource + for property_name: String in [ + "id", + "display_name", + "input_pattern", + "required_state", + "base_cost", + "damage_mult", + "move_mult_x", + "move_mult_y", + "action_beats", + "hit_type", + "range", + "target_type", + "armor_level", + "clear_window", + "can_chain", + "special", + "startup_beats", + "active_beats", + "recovery_beats", + "cancel_from", + ]: + _expect(_has_property(action, property_name), "ActionData should expose %s" % property_name) + _expect(DirAccess.open("res://resources/actions") != null, "resources/actions should exist") + var action_dir := DirAccess.open("res://resources/actions") + if action_dir != null: + var action_count := 0 + for file_name: String in action_dir.get_files(): + if file_name.ends_with(".tres"): + action_count += 1 + _expect(action_count >= 20, "resources/actions should contain the full migrated action set") + + +func _check_combo_window() -> void: + var combo_script: Script = load("res://scenes/components/combo_window.gd") + _expect(combo_script != null, "scenes/components/combo_window.gd should load") + if combo_script == null: + return + var combo: Node = combo_script.new() as Node + root.add_child(combo) + await process_frame + combo.call("record", &"A") + _expect(combo.call("get_slots") == [&"A"], "ComboWindow should record explicit inputs") + combo.call("record", &"Ø") + _expect(combo.call("get_slots") == [&"A", &"Ø"], "ComboWindow should keep explicit miss placeholders") + combo.free() + + +func _check_action_resolver() -> void: + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + _expect(resolver_script != null, "scenes/combat/action_resolver.gd should load") + if resolver_script == null: + return + if resolver_script.has_method("clear_cache"): + resolver_script.clear_cache() + var action: Resource = resolver_script.resolve_pattern("A", null, {"state": &"ground"}) + _expect(action != null, "ActionResolver should resolve A from resources/actions") + if action != null: + _expect(str(action.get("id")) == "skill_a", "ActionResolver should resolve A to skill_a") + _expect(_has_property(action, "action_beats"), "Resolved action should be full ActionData") + _expect(str(action.resource_path).contains("res://resources/actions/"), "ActionResolver should resolve migrated ActionData resources, not legacy skill resources") + _expect(resolver_script.has_method("space_priority_labels"), "ActionResolver should expose ordered Space priority labels") + + +func _check_player_components() -> void: + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(player_scene != null, "player.tscn should load") + if player_scene == null: + return + var player := player_scene.instantiate() + root.add_child(player) + await process_frame + for node_path: String in [ + "ComboWindow", + "ActionResolver", + "ActionExecutor", + "MotionExecutor", + "BurstComponent", + "ChargeComponent", + "EnergyComponent", + "DamageEmitter", + "DamageReceiver", + ]: + _expect(player.get_node_or_null(node_path) != null, "Player should own %s component" % node_path) + _expect(player.get_node_or_null("ComboTracker") == null, "Player should not keep legacy ComboTracker component") + player.free() + + +func _check_no_legacy_runtime_architecture() -> void: + var player_source := _read_text("res://scenes/characters/player.gd") + _expect(not player_source.contains("InputResolver"), "Player should not reference legacy InputResolver") + _expect(not player_source.contains("charge_duration"), "Player charge timing should live in a component") + _expect(not player_source.contains("func _update_charge"), "Player should not own charge update loop") + _expect(not player_source.contains("attack_lunge_time_left ="), "Player should not set lunge timers directly") + _expect(not player_source.contains("energy_component.spend"), "Player should delegate action costs to ActionExecutor") + _expect(not player_source.contains("damage_emitter.configure_hit"), "Player should delegate damage context setup to ActionExecutor") + _expect(not player_source.contains("resolve_cost"), "Player should not resolve action costs directly") + _expect(player_source.contains("intent_created.connect"), "Player should connect InputComponent intents to ActionController") + _expect(not player_source.contains("action_resolver.resolve_window"), "Player should not resolve actions directly") + _expect(not player_source.contains("action_executor.execute"), "Player should not execute actions directly") + _expect(not player_source.contains("func _record_combo_symbol"), "Player should not own combo symbol adjudication") + _expect(not player_source.contains("func _execute_combo_skill"), "Player should not own action execution") + _expect(not player_source.contains("func judge_rhythm_action"), "Player should not own rhythm judgement") + _expect(player_source.contains("func _on_action_started"), "Player should present actions from ActionController") + var resolver_source := _read_text("res://scenes/combat/action_resolver.gd") + _expect(not resolver_source.contains("resources/skills"), "ActionResolver should not load legacy resources/skills") + _expect(FileAccess.file_exists("res://scenes/combat/input_resolver.gd") == false, "Legacy InputResolver file should be removed") + _expect(FileAccess.file_exists("res://resources/skill_data.gd") == false, "Legacy SkillData script should be removed") + var damage_source := _read_text("res://scenes/components/damage_emitter.gd") + _expect(damage_source.contains("resolve_damage"), "DamageEmitter should route damage through CombatManager") + + +func _check_combat_manager() -> void: + var combat_script: Script = load("res://autoload/combat_manager.gd") + _expect(combat_script != null, "autoload/combat_manager.gd should load") + if combat_script == null: + return + var combat: Node = combat_script.new() as Node + _expect(combat.has_method("resolve_damage"), "CombatManager should expose resolve_damage") + _expect(combat.has_method("resolve_cost"), "CombatManager should expose resolve_cost") + _expect(combat.has_method("resolve_move"), "CombatManager should expose resolve_move") + var action_script: Script = load("res://resources/action_data.gd") + if action_script != null and combat.has_method("resolve_damage"): + var action: Resource = action_script.new() as Resource + action.set("damage_mult", 2.0) + var damage := float(combat.call("resolve_damage", 10.0, action, {"damage_mult": 1.5}, null, null)) + _expect(is_equal_approx(damage, 30.0), "CombatManager should apply action and judgement damage multipliers") + combat.free() + + +func _rhythm_manager() -> Node: + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null: + return rhythm + var rhythm_script: Script = load("res://autoload/rhythm_manager.gd") + if rhythm_script == null: + return null + rhythm = rhythm_script.new() + rhythm.name = "RhythmManager" + root.add_child(rhythm) + return rhythm + + +func _has_property(object: Object, property_name: String) -> bool: + for property: Dictionary in object.get_property_list(): + if str(property.get("name", "")) == property_name: + return true + return false + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _read_text(path: String) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("Could not read %s" % path) + return "" + return file.get_as_text() + + +func _finish() -> void: + if failures.is_empty(): + print("PASS rhythm action architecture") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_rhythm_action_architecture.gd.uid b/tests/test_rhythm_action_architecture.gd.uid new file mode 100644 index 0000000..e983f36 --- /dev/null +++ b/tests/test_rhythm_action_architecture.gd.uid @@ -0,0 +1 @@ +uid://2fdtunkqclaw diff --git a/tests/test_rhythm_scene.gd b/tests/test_rhythm_scene.gd index 15b2f9c..676eb21 100644 --- a/tests/test_rhythm_scene.gd +++ b/tests/test_rhythm_scene.gd @@ -9,30 +9,24 @@ func _init() -> void: push_error("Could not load main.tscn") quit(1) return - - var main: Node = scene.instantiate() + + var main := scene.instantiate() if main.get_script() == null: failures.append("Main script failed to load") - var required_nodes := [ - "RhythmConductor", - "RhythmFeedback", - "Player", - ] - for node_name: String in required_nodes: - if not main.has_node(node_name): - failures.append("Missing required node: %s" % node_name) - + for node_path: String in ["Stage", "Stage/ActorsContainer/Player", "ChartRunner", "UI"]: + if not main.has_node(node_path): + failures.append("Missing required node: %s" % node_path) + if main.has_node("RhythmConductor"): - var conductor: Node = main.get_node("RhythmConductor") - if not conductor.has_method("judge_action"): - failures.append("RhythmConductor missing judge_action") - if not conductor is AudioStreamPlayer: - failures.append("RhythmConductor should be an AudioStreamPlayer") - elif (conductor as AudioStreamPlayer).stream == null: - failures.append("RhythmConductor should have a music stream") - + failures.append("RhythmConductor should be promoted to RhythmManager autoload") + if not ProjectSettings.has_setting("autoload/RhythmManager"): + failures.append("RhythmManager autoload should be configured") + main.free() - + _finish() + + +func _finish() -> void: if failures.is_empty(): print("PASS rhythm scene") quit(0) diff --git a/tests/test_rhythm_ui.gd b/tests/test_rhythm_ui.gd index 2fc1895..fe9ba42 100644 --- a/tests/test_rhythm_ui.gd +++ b/tests/test_rhythm_ui.gd @@ -4,34 +4,53 @@ var failures: Array[String] = [] func _init() -> void: - var scene: PackedScene = load("res://scenes/main/main.tscn") + _run.call_deferred() + + +func _run() -> void: + var scene: PackedScene = load("res://scenes/ui/main_ui.tscn") if scene == null: - push_error("Could not load main.tscn") + push_error("Could not load main_ui.tscn") quit(1) return - - var main: Node = scene.instantiate() - var required_nodes := [ - "RhythmFeedback/RhythmTrack", - "RhythmFeedback/RhythmTrack/LeftRod", - "RhythmFeedback/RhythmTrack/RightRod", - "RhythmFeedback/RhythmTrack/CenterBase", - "RhythmFeedback/RhythmTrack/CenterFlash", - "RhythmFeedback/RhythmTrack/LeftMover", - "RhythmFeedback/RhythmTrack/RightMover", - "RhythmFeedback/RhythmTrack/BlueBallLeft1", - "RhythmFeedback/RhythmTrack/BlueBallRight1", - "RhythmFeedback/JudgementLabel", - ] - for node_path: String in required_nodes: - if not main.has_node(node_path): + + var ui := scene.instantiate() + root.add_child(ui) + await process_frame + + for node_path: String in [ + "RhythmTrack", + "RhythmTrack/JudgementLabel", + "RhythmTrack/ChartMarkerContainer", + "ComboWindow", + "StatusBars/HealthBar", + "StatusBars/EnergyBar", + "StatusBars/ChargeBar", + ]: + if not ui.has_node(node_path): failures.append("Missing rhythm UI node: %s" % node_path) - - if main.has_method("_update_rhythm_track") == false: - failures.append("Main script missing _update_rhythm_track") - - main.free() - + + var bus := _event_bus() + bus.emit_signal("action_judged", &"skill_a", {"label": "perfect", "diff": 0.0}) + await process_frame + var label := ui.get_node("RhythmTrack/JudgementLabel") as Label + if not label.text.contains("SKILL_A"): + failures.append("RhythmTrack should render EventBus judgement text") + + ui.free() + _finish() + + +func _event_bus() -> Node: + 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 _finish() -> void: if failures.is_empty(): print("PASS rhythm ui") quit(0) diff --git a/tests/test_rhythm_ui_layout.gd b/tests/test_rhythm_ui_layout.gd index 4bf192d..0318a63 100644 --- a/tests/test_rhythm_ui_layout.gd +++ b/tests/test_rhythm_ui_layout.gd @@ -8,34 +8,35 @@ func _init() -> void: func _run() -> void: - var scene: PackedScene = load("res://scenes/main/main.tscn") + var scene: PackedScene = load("res://scenes/ui/rhythm_track.tscn") if scene == null: - push_error("Could not load main.tscn") + push_error("Could not load rhythm_track.tscn") quit(1) return - - var main: Node = scene.instantiate() - var initial_left_mover: Control = main.get_node("RhythmFeedback/RhythmTrack/LeftMover") - var initial_right_mover: Control = main.get_node("RhythmFeedback/RhythmTrack/RightMover") - var initial_center_base: Control = main.get_node("RhythmFeedback/RhythmTrack/CenterBase") - var initial_center_flash: Control = main.get_node("RhythmFeedback/RhythmTrack/CenterFlash") - var expected_left_start := _control_center(initial_left_mover) - var expected_right_start := _control_center(initial_right_mover) - var expected_track_center := _control_center(initial_center_base) - var expected_mover_size := initial_left_mover.size - var expected_center_flash_size := initial_center_flash.size - - root.add_child(main) + + var track := scene.instantiate() as Control + root.add_child(track) await process_frame - - _expect_vector("left_mover_start", main.get("left_mover_start"), expected_left_start) - _expect_vector("right_mover_start", main.get("right_mover_start"), expected_right_start) - _expect_vector("track_center", main.get("track_center"), expected_track_center) - _expect_vector("mover_size", main.get("mover_size"), expected_mover_size) - _expect_vector("center_flash_size", main.get("center_flash_size"), expected_center_flash_size) - - main.free() - + + _expect_float(track.anchor_left, 0.5, "RhythmTrack should stay centered") + _expect_float(track.anchor_right, 0.5, "RhythmTrack should stay centered") + _expect_bool(track.has_node("JudgementLabel"), true, "RhythmTrack should own its judgement label") + + track.free() + _finish() + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: if failures.is_empty(): print("PASS rhythm ui layout") quit(0) @@ -43,18 +44,3 @@ func _run() -> void: for failure: String in failures: push_error(failure) quit(1) - - -func _control_center(control: Control) -> Vector2: - return Vector2( - (control.offset_left + control.offset_right) * 0.5, - (control.offset_top + control.offset_bottom) * 0.5 - ) - - -func _expect_vector(label: String, actual: Variant, expected: Vector2) -> void: - if not actual is Vector2: - failures.append("%s should be cached as Vector2, got %s" % [label, typeof(actual)]) - return - if not (actual as Vector2).is_equal_approx(expected): - failures.append("%s should match scene layout: expected %s got %s" % [label, expected, actual]) diff --git a/tests/test_ui_animation_regression.gd b/tests/test_ui_animation_regression.gd new file mode 100644 index 0000000..621bb1f --- /dev/null +++ b/tests/test_ui_animation_regression.gd @@ -0,0 +1,109 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var scene: PackedScene = load("res://scenes/ui/main_ui.tscn") + if scene == null: + push_error("Could not load main_ui.tscn") + quit(1) + return + + var ui := scene.instantiate() + root.add_child(ui) + await process_frame + + var track := ui.get_node_or_null("RhythmTrack") as Control + if track == null: + failures.append("Missing RhythmTrack") + _finish(ui) + return + + _expect_node(track, "LeftMover", "RhythmTrack should keep the animated left mover") + _expect_node(track, "RightMover", "RhythmTrack should keep the animated right mover") + _expect_node(track, "CenterFlash", "RhythmTrack should keep the center beat flash") + + var bus := _event_bus() + bus.emit_signal("beat_ticked", 1) + await process_frame + var center_flash := track.get_node_or_null("CenterFlash") as CanvasItem + if center_flash != null: + _expect_bool(center_flash.modulate.a > 0.5, true, "CenterFlash should become visible on beat") + + var marker_container := track.get_node_or_null("ChartMarkerContainer") as Control + if marker_container == null: + failures.append("RhythmTrack should include ChartMarkerContainer") + else: + 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 + _expect_bool(marker_container.get_child_count() > 0, true, "Chart upcoming event should create a rhythm marker") + + bus.emit_signal("action_judged", &"skill_a", {"label": "perfect", "diff": 0.0, "color": Color("00f2ff")}) + await process_frame + var label := track.get_node_or_null("JudgementLabel") as Label + if label != null: + _expect_bool(label.scale.x > 1.0, true, "JudgementLabel should pulse on judgement") + _expect_bool(label.modulate.is_equal_approx(Color("00f2ff")), true, "JudgementLabel should use judgement color") + + var combo_window := ui.get_node_or_null("ComboWindow") as Control + if combo_window == null: + failures.append("Missing ComboWindow") + else: + bus.emit_signal("combo_updated", [&"A"]) + await process_frame + var first_slot := combo_window.get_child(0) as Control + _expect_bool(first_slot.scale.x > 1.0, true, "Combo slot should pulse when filled") + bus.emit_signal("combo_cleared", &"full") + await process_frame + _expect_bool(first_slot.scale.x > 1.0, true, "Combo slots should flash when cleared") + + var charge_bar := ui.get_node_or_null("StatusBars/ChargeBar") as ProgressBar + if charge_bar == null: + failures.append("Missing ChargeBar") + else: + bus.emit_signal("player_charge_changed", 1.1, 1.1, true, true) + await create_timer(0.05).timeout + var first_alpha := charge_bar.modulate.a + await create_timer(0.05).timeout + var second_alpha := charge_bar.modulate.a + _expect_bool(not is_equal_approx(first_alpha, second_alpha), true, "ChargeBar should flash while ready") + + _finish(ui) + + +func _event_bus() -> Node: + 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 _expect_node(node: Node, path: String, label: String) -> void: + if node.get_node_or_null(path) == null: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish(ui: Node) -> void: + ui.free() + if failures.is_empty(): + print("PASS ui animation regression") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_ui_animation_regression.gd.uid b/tests/test_ui_animation_regression.gd.uid new file mode 100644 index 0000000..345cc56 --- /dev/null +++ b/tests/test_ui_animation_regression.gd.uid @@ -0,0 +1 @@ +uid://sdwicy8mmpkr