Refactor rhythm action architecture

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

View File

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

View File

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

19
autoload/event_bus.gd Normal file
View File

@@ -0,0 +1,19 @@
extends Node
signal rhythm_action_requested(action_name: StringName)
signal beat_ticked(beat_index: int)
signal judgement_made(quality: StringName, offset_ms: float)
signal action_judged(action_name: StringName, rating: Dictionary)
signal chart_event_upcoming(event: Resource, time_to_event: float)
signal chart_event_triggered(event: Resource)
signal chart_reset(chart_id: StringName)
signal skill_executed(skill: Resource, judgement: StringName)
signal projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2)
signal damage_dealt(target: Node, amount: int, hit_type: StringName)
signal player_health_changed(current: int, max_value: int)
signal player_energy_changed(current: float, max_value: float)
signal player_charge_changed(current: float, max_value: float, ready: bool, active: bool)
signal combo_updated(inputs: Array[StringName])
signal combo_cleared(reason: StringName)

View File

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

167
autoload/rhythm_manager.gd Normal file
View File

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

View File

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