Refactor rhythm action architecture
This commit is contained in:
343
scenes/components/action_controller.gd
Normal file
343
scenes/components/action_controller.gd
Normal 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
|
||||
1
scenes/components/action_controller.gd.uid
Normal file
1
scenes/components/action_controller.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bk4dutttdieeg
|
||||
42
scenes/components/action_executor.gd
Normal file
42
scenes/components/action_executor.gd
Normal 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")
|
||||
1
scenes/components/action_executor.gd.uid
Normal file
1
scenes/components/action_executor.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cxcw3mad0gewc
|
||||
68
scenes/components/burst_component.gd
Normal file
68
scenes/components/burst_component.gd
Normal 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)
|
||||
1
scenes/components/burst_component.gd.uid
Normal file
1
scenes/components/burst_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://vsrv3pov77hh
|
||||
126
scenes/components/charge_component.gd
Normal file
126
scenes/components/charge_component.gd
Normal 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)
|
||||
1
scenes/components/charge_component.gd.uid
Normal file
1
scenes/components/charge_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://417rdyr4hkco
|
||||
108
scenes/components/combo_window.gd
Normal file
108
scenes/components/combo_window.gd
Normal 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")
|
||||
1
scenes/components/combo_window.gd.uid
Normal file
1
scenes/components/combo_window.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://jgl00xkxwy2s
|
||||
47
scenes/components/damage_emitter.gd
Normal file
47
scenes/components/damage_emitter.gd
Normal 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")
|
||||
1
scenes/components/damage_emitter.gd.uid
Normal file
1
scenes/components/damage_emitter.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://sw8ppylne36n
|
||||
23
scenes/components/damage_receiver.gd
Normal file
23
scenes/components/damage_receiver.gd
Normal 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
|
||||
1
scenes/components/damage_receiver.gd.uid
Normal file
1
scenes/components/damage_receiver.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b1ljynf0b873n
|
||||
54
scenes/components/energy_component.gd
Normal file
54
scenes/components/energy_component.gd
Normal 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
|
||||
1
scenes/components/energy_component.gd.uid
Normal file
1
scenes/components/energy_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bwau6ud30k62u
|
||||
49
scenes/components/health_component.gd
Normal file
49
scenes/components/health_component.gd
Normal 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
|
||||
1
scenes/components/health_component.gd.uid
Normal file
1
scenes/components/health_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://0svshg5yfjyg
|
||||
65
scenes/components/input_component.gd
Normal file
65
scenes/components/input_component.gd
Normal 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
|
||||
1
scenes/components/input_component.gd.uid
Normal file
1
scenes/components/input_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c4n1p3g64qiqj
|
||||
32
scenes/components/input_intent.gd
Normal file
32
scenes/components/input_intent.gd
Normal 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
|
||||
1
scenes/components/input_intent.gd.uid
Normal file
1
scenes/components/input_intent.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://yurugl8r6qre
|
||||
40
scenes/components/motion_executor.gd
Normal file
40
scenes/components/motion_executor.gd
Normal 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
|
||||
1
scenes/components/motion_executor.gd.uid
Normal file
1
scenes/components/motion_executor.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cqr3o0h5abv3f
|
||||
34
scenes/components/state_machine.gd
Normal file
34
scenes/components/state_machine.gd
Normal 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"
|
||||
1
scenes/components/state_machine.gd.uid
Normal file
1
scenes/components/state_machine.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bxquc8qy20e6l
|
||||
Reference in New Issue
Block a user