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

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -0,0 +1,110 @@
class_name ChartRunner
extends Node
signal chart_event_upcoming(event: Resource, time_to_event: float)
signal chart_event_triggered(event: Resource)
signal chart_reset(chart_id: StringName)
signal chart_finished(chart_id: StringName)
const UPCOMING_TIME_EPSILON := 0.02
@export var chart: Resource
@export var rhythm_manager_path: NodePath
@export var beat_time_override := 0.0
@export var auto_run := true
var running := true
var _upcoming_keys: Dictionary = {}
var _triggered_keys: Dictionary = {}
func _ready() -> void:
running = auto_run
func _physics_process(_delta: float) -> void:
if not running or chart == null:
return
var rhythm := _rhythm_manager()
if rhythm == null or not rhythm.has_method("song_position"):
return
update_for_song_time(float(rhythm.call("song_position")))
func set_chart(next_chart: Resource) -> void:
chart = next_chart
reset()
func reset() -> void:
_upcoming_keys.clear()
_triggered_keys.clear()
var chart_id := &""
if chart != null:
chart_id = StringName(str(chart.get("chart_id")))
chart_reset.emit(chart_id)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("chart_reset", chart_id)
func update_for_song_time(song_time: float) -> void:
if chart == null:
return
var beat_time := _beat_time()
for event: Resource in chart.call("all_events"):
var event_time := float(event.call("time_seconds", beat_time))
var time_to_event := event_time - song_time
var lead_time := maxf(0.0, float(event.get("lead_beats"))) * beat_time
var event_key: StringName = event.call("key")
if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time + UPCOMING_TIME_EPSILON:
_upcoming_keys[event_key] = true
_emit_upcoming(event, time_to_event)
if not _triggered_keys.has(event_key) and song_time >= event_time:
_triggered_keys[event_key] = true
_emit_triggered(event)
func pause() -> void:
running = false
func resume() -> void:
running = true
func _emit_upcoming(event: Resource, time_to_event: float) -> void:
chart_event_upcoming.emit(event, time_to_event)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("chart_event_upcoming", event, time_to_event)
func _emit_triggered(event: Resource) -> void:
chart_event_triggered.emit(event)
var bus := _event_bus_or_null()
if bus != null:
bus.emit_signal("chart_event_triggered", event)
func _beat_time() -> float:
if beat_time_override > 0.0:
return beat_time_override
var rhythm := _rhythm_manager()
if rhythm != null:
return float(rhythm.get("beat_time"))
return 0.5
func _rhythm_manager() -> Node:
if not is_inside_tree():
return null
if not rhythm_manager_path.is_empty():
return get_node_or_null(rhythm_manager_path)
return get_tree().root.get_node_or_null("RhythmManager")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

View File

@@ -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")]

View File

@@ -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

View File

@@ -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

View File

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

8
scenes/stage/stage.gd Normal file
View File

@@ -0,0 +1,8 @@
class_name Stage
extends Node2D
@onready var actors_container: Node2D = $ActorsContainer
func get_player() -> Node:
return $ActorsContainer/Player

View File

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

18
scenes/stage/stage.tscn Normal file
View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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")

58
scenes/ui/energy_bar.gd Normal file
View File

@@ -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

View File

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

75
scenes/ui/energy_bar.tscn Normal file
View File

@@ -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")

95
scenes/ui/main_ui.gd Normal file
View File

@@ -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

1
scenes/ui/main_ui.gd.uid Normal file
View File

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

57
scenes/ui/main_ui.tscn Normal file
View File

@@ -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

174
scenes/ui/rhythm_track.gd Normal file
View File

@@ -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

View File

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

180
scenes/ui/rhythm_track.tscn Normal file
View File

@@ -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