Refactor rhythm action architecture

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

View File

@@ -0,0 +1,343 @@
class_name ActionController
extends Node
signal action_started(action: Resource, intent)
signal action_active_started(action: Resource, intent)
signal action_active_finished(action: Resource)
signal action_finished(action: Resource)
signal action_rejected(intent, reason: StringName)
enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY }
@export var combo_window_path: NodePath
@export var action_resolver_path: NodePath
@export var action_executor_path: NodePath
@export var state_machine_path: NodePath
@export var burst_component_path: NodePath
@onready var combo_window: Node = get_node_or_null(combo_window_path)
@onready var action_resolver: Node = get_node_or_null(action_resolver_path)
@onready var action_executor: Node = get_node_or_null(action_executor_path)
@onready var state_machine: Node = get_node_or_null(state_machine_path)
@onready var burst_component: Node = get_node_or_null(burst_component_path)
var phase := Phase.IDLE
var current_action: Resource
var current_intent
var pending_intent
var phase_elapsed := 0.0
var phase_duration := 0.0
func submit_intent(intent) -> void:
if intent == null:
return
var judged_intent = _ensure_judged(intent)
if judged_intent.is_released():
action_rejected.emit(judged_intent, &"release_not_action")
return
_emit_judgement_feedback(judged_intent)
if _judgement_label(judged_intent) == &"miss":
_interrupt_current_action_for_miss()
_record_miss(judged_intent)
action_rejected.emit(judged_intent, &"miss")
return
if _window_is_showing_pending_clear():
_store_pending_intent(judged_intent)
return
if phase == Phase.IDLE:
_consume_intent(judged_intent)
return
if _can_cancel_now():
_reset_to_idle()
_consume_intent(judged_intent)
return
_store_pending_intent(judged_intent)
func _physics_process(delta: float) -> void:
if phase == Phase.IDLE:
if pending_intent != null and not _window_is_showing_pending_clear():
var idle_intent = pending_intent
pending_intent = null
_consume_intent(idle_intent)
return
phase_elapsed += delta
if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now():
var interrupted_action := current_action
var next_intent = pending_intent
pending_intent = null
_reset_to_idle()
action_finished.emit(interrupted_action)
_consume_intent(next_intent)
return
if phase_elapsed < phase_duration:
return
var carryover := maxf(0.0, phase_elapsed - phase_duration)
match phase:
Phase.STARTUP:
_enter_phase(Phase.ACTIVE)
phase_elapsed = carryover
if not _activate_current_action():
return
Phase.ACTIVE:
action_active_finished.emit(current_action)
_enter_phase(Phase.RECOVERY)
phase_elapsed = carryover
Phase.RECOVERY:
var finished_action := current_action
var next_intent = pending_intent
pending_intent = null
_reset_to_idle()
_clear_window_after_action(finished_action)
action_finished.emit(finished_action)
if next_intent != null:
_consume_intent(next_intent)
func _consume_intent(intent) -> void:
_start_action(intent)
func _start_action(intent) -> void:
if combo_window == null or action_resolver == null:
action_rejected.emit(intent, &"missing_component")
return
if _should_restart_blade_chain(intent):
combo_window.clear(&"blade_chain_restart")
_record_intent_symbol(intent)
var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context())
if action == null:
action = _resolve_fallback_action(intent)
if action == null:
action_rejected.emit(intent, &"unresolved")
return
current_action = action
current_intent = intent
_enter_phase(Phase.STARTUP)
action_started.emit(action, intent)
func _activate_current_action() -> bool:
if current_action == null or current_intent == null:
_reset_to_idle()
return false
if action_executor == null:
action_rejected.emit(current_intent, &"missing_component")
_reset_to_idle()
return false
if not action_executor.execute(current_action, StringName(str(current_intent.judgement.get("label", "perfect"))), burst_component):
combo_window.flush_pending_clear()
combo_window.clear(&"action_failed")
action_rejected.emit(current_intent, &"execution_failed")
_reset_to_idle()
return false
action_active_started.emit(current_action, current_intent)
return true
func _record_intent_symbol(intent) -> void:
if combo_window.has_pending_clear():
combo_window.flush_pending_clear()
combo_window.record(intent.symbol)
func _record_miss(_intent) -> void:
if combo_window != null:
if combo_window.has_pending_clear():
combo_window.flush_pending_clear()
combo_window.record(&"Ø")
func _interrupt_current_action_for_miss() -> void:
pending_intent = null
if phase == Phase.IDLE:
return
var interrupted_action := current_action
_reset_to_idle()
if interrupted_action != null:
action_finished.emit(interrupted_action)
func _resolve_fallback_action(intent) -> Resource:
if action_resolver == null:
return null
if not action_resolver.has_method("resolve_text_pattern"):
return null
return action_resolver.call("resolve_text_pattern", str(intent.symbol), state_machine, _resolver_context()) as Resource
func _clear_window_after_action(action: Resource) -> void:
if combo_window == null or action == null:
return
if bool(action.get("clear_window")):
combo_window.clear(StringName("skill:%s" % action.get("id")))
func _store_pending_intent(intent) -> void:
if pending_intent != null:
action_rejected.emit(pending_intent, &"replaced")
pending_intent = intent
func _should_restart_blade_chain(intent) -> bool:
if combo_window == null:
return false
if intent.symbol != &"S":
return false
var pattern: String = combo_window.get_contiguous_pattern()
return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP"
func _enter_phase(next_phase: Phase) -> void:
phase = next_phase
phase_elapsed = 0.0
phase_duration = _phase_duration_seconds(next_phase)
func _phase_duration_seconds(next_phase: Phase) -> float:
if current_action == null:
return 0.0
var beat_time := _beat_time()
match next_phase:
Phase.STARTUP:
return maxf(0.01, float(current_action.get("startup_beats")) * beat_time)
Phase.ACTIVE:
return maxf(0.01, float(current_action.get("active_beats")) * beat_time)
Phase.RECOVERY:
return maxf(0.01, float(current_action.get("recovery_beats")) * beat_time)
return 0.0
func _can_cancel_now() -> bool:
if phase != Phase.RECOVERY or current_action == null:
return false
var duration := maxf(0.01, phase_duration)
var progress := clampf(phase_elapsed / duration, 0.0, 1.0)
return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0)
func _reset_to_idle() -> void:
phase = Phase.IDLE
current_action = null
current_intent = null
phase_elapsed = 0.0
phase_duration = 0.0
func _window_is_showing_pending_clear() -> bool:
return combo_window != null and combo_window.has_pending_clear()
func _ensure_judged(intent):
if not intent.judgement.is_empty():
return intent.with_judgement(_judgement_with_defaults(intent.judgement))
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
if rhythm != null and rhythm.has_method("judge"):
return intent.with_judgement(_judgement_with_defaults(rhythm.call("judge", intent.timestamp_ms)))
return intent.with_judgement(_judgement_with_defaults({"label": "perfect", "diff": 0.0, "abs_diff": 0.0}))
func _judgement_label(intent) -> StringName:
return StringName(str(intent.judgement.get("label", "miss")))
func _emit_judgement_feedback(intent) -> void:
var rating := _judgement_with_defaults(intent.judgement)
var action_name: StringName = intent.rhythm_action if not intent.rhythm_action.is_empty() else intent.symbol
rating["action"] = action_name
var label := StringName(str(rating.get("label", "miss")))
var diff_ms := float(rating.get("diff", INF)) * 1000.0
var bus := _event_bus_or_null()
if bus == null:
return
bus.emit_signal("judgement_made", label, diff_ms)
bus.emit_signal("action_judged", action_name, rating)
func _judgement_with_defaults(judgement: Dictionary) -> Dictionary:
var rating := judgement.duplicate()
var label := StringName(str(rating.get("label", "miss")))
rating["label"] = str(label)
if not rating.has("diff"):
rating["diff"] = 0.0
if not rating.has("abs_diff"):
rating["abs_diff"] = absf(float(rating.get("diff", 0.0)))
if not rating.has("color"):
rating["color"] = _judgement_color(label)
return rating
func _judgement_color(label: StringName) -> Color:
match label:
&"perfect":
return Color("00f2ff")
&"good":
return Color("ffffff")
&"bad":
return Color("ffaa00")
return Color("ff0055")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
var root := get_tree().root
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _resolver_context() -> Dictionary:
var state := &"any"
if state_machine != null and state_machine.has_method("get_current_state_name"):
state = StringName(str(state_machine.call("get_current_state_name")))
return {
"state": state,
"charge_release_action_id": _charge_release_action_id(),
"burst_action_id": _burst_action_id(),
"counter_action_id": _counter_action_id(),
"counter_ready": _counter_ready(),
"blade_chain_action_id": _blade_chain_action_id(),
"blade_chain_active": _blade_chain_active(),
}
func _charge_release_action_id() -> StringName:
return &""
func _burst_action_id() -> StringName:
if burst_component != null and bool(burst_component.get("burst_ready")):
return &"skill_burst_activate"
return &""
func _counter_action_id() -> StringName:
return &"skill_s_counter_projectile"
func _counter_ready() -> bool:
return false
func _blade_chain_action_id() -> StringName:
if _blade_chain_active():
return &"skill_s_projectile_2"
return &""
func _blade_chain_active() -> bool:
if current_action == null:
return false
return bool(current_action.get("can_chain"))
func _beat_time() -> float:
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
if rhythm != null:
return float(rhythm.get("beat_time"))
return 0.5

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
class_name InputComponent
extends Node
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
signal intent_created(intent)
signal combo_pressed(symbol: StringName, rhythm_action: StringName)
signal combo_released(symbol: StringName)
const COMBO_ACTIONS: Dictionary = {
&"combo_w": [&"W", &"w"],
&"combo_a": [&"A", &"a"],
&"combo_d": [&"D", &"d"],
&"combo_s": [&"S", &"s"],
&"combo_space": [&"SP", &"space"],
}
const COMBO_ACTION_ORDER: Array[StringName] = [
&"combo_w",
&"combo_a",
&"combo_d",
&"combo_s",
&"combo_space",
]
var _suppressed_movement: Dictionary = {
&"move_left": false,
&"move_right": false,
}
func handle_input_event(event: InputEvent) -> bool:
var key_event := event as InputEventKey
if key_event != null and key_event.echo:
return false
for action_name: StringName in COMBO_ACTION_ORDER:
if event.is_action_pressed(action_name, false, true):
var data: Array = COMBO_ACTIONS[action_name]
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec()))
intent_created.emit(intent)
combo_pressed.emit(data[0], data[1])
return true
if event.is_action_released(action_name, true):
var data: Array = COMBO_ACTIONS[action_name]
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"released", float(Time.get_ticks_msec()))
intent_created.emit(intent)
combo_released.emit(data[0])
return true
return false
func set_direction_suppressed(symbol: StringName, suppressed: bool) -> void:
if symbol == &"A":
_suppressed_movement[&"move_left"] = suppressed
elif symbol == &"D":
_suppressed_movement[&"move_right"] = suppressed
func get_horizontal_axis() -> float:
var axis := 0.0
if Input.is_action_pressed(&"move_left") and not bool(_suppressed_movement.get(&"move_left", false)):
axis -= 1.0
if Input.is_action_pressed(&"move_right") and not bool(_suppressed_movement.get(&"move_right", false)):
axis += 1.0
return axis

View File

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

View File

@@ -0,0 +1,32 @@
class_name InputIntent
extends RefCounted
var symbol: StringName
var rhythm_action: StringName
var event_type: StringName
var timestamp_ms := 0.0
var judgement: Dictionary = {}
static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> RefCounted:
var script: Script = load("res://scenes/components/input_intent.gd")
var intent: RefCounted = script.new()
intent.symbol = next_symbol
intent.rhythm_action = next_rhythm_action
intent.event_type = next_event_type
intent.timestamp_ms = next_timestamp_ms
return intent
func is_pressed() -> bool:
return event_type == &"pressed"
func is_released() -> bool:
return event_type == &"released"
func with_judgement(next_judgement: Dictionary) -> RefCounted:
var copy: RefCounted = load("res://scenes/components/input_intent.gd").create(symbol, rhythm_action, event_type, timestamp_ms)
copy.judgement = next_judgement.duplicate()
return copy

View File

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

View File

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

View File

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

View File

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

View File

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