Initial project sync

This commit is contained in:
wxm
2026-07-06 22:55:14 -07:00
commit 8cca00d0da
1066 changed files with 58585 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
extends Node
const StatResolverScript := preload("res://scripts/resolvers/stat_resolver.gd")
const CombatResolverScript := preload("res://scripts/resolvers/combat_resolver.gd")
var _resolved_pairs: Dictionary = {}
func _physics_process(_delta: float) -> void:
_resolved_pairs.clear()
func resolve_damage(base_attack: float, action: Resource, judgement: Dictionary, buffs: Variant = null, burst: Variant = null) -> float:
return StatResolverScript.resolve_damage(base_attack, action, judgement, buffs, burst)
func resolve_cost(action: Resource, judgement: Variant = {}, effects: Variant = null) -> float:
return StatResolverScript.resolve_cost(action, judgement, effects)
func resolve_reward(action: Resource, judgement: Dictionary, effects: Variant = null) -> float:
return StatResolverScript.resolve_reward(action, judgement, effects)
func resolve_move(action: Resource, judgement: Dictionary, burst: Variant = null) -> Vector2:
return StatResolverScript.resolve_move(action, judgement, burst)
func resolve_action_snapshot(action: Resource, judgement: Dictionary = {}, effects: Variant = null) -> Dictionary:
return StatResolverScript.resolve_action_snapshot(action, judgement, effects)
func resolve_hit(emitter: Area2D, receiver: Area2D) -> Dictionary:
if emitter == null or receiver == null:
return {}
if _receiver_is_dead(receiver):
return {}
var key := "%s:%s" % [emitter.get_instance_id(), receiver.get_instance_id()]
if _resolved_pairs.has(key):
return _resolved_pairs[key]
var result := CombatResolverScript.resolve_hit(emitter, receiver)
_resolved_pairs[key] = result
_apply_interrupt(receiver, result)
_apply_target_health(receiver, result)
_apply_knockback(receiver, result)
if receiver.has_method("receive_hit"):
receiver.call("receive_hit", result)
_apply_action_on_hit_effects(receiver, result)
_dispatch_effect_events(emitter, receiver, result)
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("hit_confirmed"):
bus.emit_signal("hit_confirmed", result)
return result
func _apply_target_health(receiver: Area2D, result: Dictionary) -> void:
var target := receiver.get_parent()
if target == null:
return
var health_component := target.get_node_or_null("HealthComponent")
if health_component != null and health_component.has_method("receive_hit"):
health_component.call("receive_hit", result)
func _apply_interrupt(receiver: Area2D, result: Dictionary) -> void:
if not bool(result.get("interrupts", true)):
return
var target := receiver.get_parent()
if target == null:
return
var action_controller := target.get_node_or_null("ActionController")
if action_controller != null and action_controller.has_method("cancel_current"):
action_controller.call("cancel_current", _cancel_reason_for_hit(target, result))
func _cancel_reason_for_hit(target: Node, result: Dictionary) -> StringName:
var health_component := target.get_node_or_null("HealthComponent")
if health_component != null and int(result.get("damage", 0)) > 0:
var current := int(health_component.get("current"))
if current > 0 and int(result.get("damage", 0)) >= current:
return &"death"
return &"interrupt"
func _apply_knockback(receiver: Area2D, result: Dictionary) -> void:
var knockback := result.get("knockback", Vector2.ZERO) as Vector2
if knockback == Vector2.ZERO:
return
var target := receiver.get_parent()
if target == null:
return
knockback.x = _knockback_x_away_from_attacker(target, result, knockback.x)
knockback.x *= _knockback_velocity_scale_for_receiver(target)
var movement_motor := target.get_node_or_null("MovementMotor")
if movement_motor != null and movement_motor.has_method("apply_knockback"):
movement_motor.call("apply_knockback", knockback)
## 受击方可选钩子(鸭子类型,同 shrugs_off_hit):knockback_distance_taken_mult()
## 返回"水平滑行距离"倍数。线性摩擦下距离 ∝ 初速²,距离 ×N 需初速 ×√N。
func _knockback_velocity_scale_for_receiver(target: Node) -> float:
if not target.has_method("knockback_distance_taken_mult"):
return 1.0
return sqrt(maxf(float(target.call("knockback_distance_taken_mult")), 0.0))
func _knockback_x_away_from_attacker(target: Node, result: Dictionary, knockback_x: float) -> float:
# resolve_knockback 输出无符号水平分量(数值契约,禁改);施加时按受击者
# 相对攻击者的位置定向为"背离攻击者"。dx 恰好为 0 时沿用历史 +X 语义。
if not (target is Node2D):
return knockback_x
var from = result.get("from")
if not (from is Vector2):
return knockback_x
var dx := (target as Node2D).global_position.x - (from as Vector2).x
if dx == 0.0:
return knockback_x
return signf(dx) * absf(knockback_x)
func _receiver_is_dead(receiver: Area2D) -> bool:
var target := receiver.get_parent()
if target == null:
return false
var state_machine := target.get_node_or_null("StateMachine")
if state_machine == null or not state_machine.has_method("build_context"):
return false
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
func _dispatch_effect_events(emitter: Area2D, receiver: Area2D, result: Dictionary) -> void:
_dispatch_actor_effect_event(emitter.get_parent(), &"on_hit", result)
_dispatch_actor_effect_event(receiver.get_parent(), &"on_hurt", result)
if _result_defense_state(result) == &"Parrying":
_dispatch_actor_effect_event(receiver.get_parent(), &"on_parry_success", result)
if int(result.get("damage", 0)) > 0 and _receiver_is_dead(receiver):
_dispatch_actor_effect_event(emitter.get_parent(), &"on_kill", result)
func _apply_action_on_hit_effects(receiver: Area2D, result: Dictionary) -> void:
var target := receiver.get_parent()
if target == null:
return
var effect_container := target.get_node_or_null("EffectContainer")
if effect_container == null or not effect_container.has_method("add_effect"):
return
var action: Resource = result.get("action", null) as Resource
if action == null:
return
var effects = action.get("on_hit_effects")
if not effects is Array:
return
for definition: Resource in effects:
if definition != null:
effect_container.call("add_effect", definition, StringName(str(action.get("id"))))
func _dispatch_actor_effect_event(actor: Node, event_name: StringName, result: Dictionary) -> void:
if actor == null:
return
var effect_container := actor.get_node_or_null("EffectContainer")
if effect_container != null and effect_container.has_method("dispatch_event"):
effect_container.call("dispatch_event", event_name, {"result": result})
func _result_defense_state(result: Dictionary) -> StringName:
var defense = result.get("defense", {})
if defense is Dictionary:
return StringName(str(defense.get("defense_state", &"Vulnerable")))
return &"Vulnerable"
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")
+1
View File
@@ -0,0 +1 @@
uid://dmeiefmd38a30
+30
View File
@@ -0,0 +1,30 @@
extends Node
signal beat_ticked(beat_index: int)
signal judgement_made(quality: StringName, offset_ms: float, beat_index: int)
signal intent_replaced(previous_intent, next_intent)
signal action_started(action: Resource, intent)
signal action_cancelled(action: Resource, reason: StringName)
signal hit_confirmed(result: 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, context: Dictionary)
signal damage_dealt(target: Node, amount: int, hit_type: StringName)
signal time_phase_changed(previous: StringName, current: StringName, reason: StringName)
signal time_anchor_scheduled(anchor: Dictionary)
signal time_anchor_resolved(anchor: Dictionary, held: bool, judgement: Dictionary)
signal streak_changed(count: int)
signal attack_buff_changed(stacks: int, max_stacks: int)
signal flow_state_changed(previous: StringName, current: StringName)
signal front_area_cleared
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)
+1
View File
@@ -0,0 +1 @@
uid://cpgixq8ibqhh4
+964
View File
@@ -0,0 +1,964 @@
extends Node
## Outer game-flow state machine (AnchorV1.0 §4.5 / §19.5 / §21.7).
## Owns Title / DifficultySelect / Pause / VolumeSettings / confirm dialogs /
## Defeat / Victory / Result and the front-area → boss-room progression.
## It never judges input or resolves combat — battle systems stay untouched;
## pausing is the SceneTree pause plus the RhythmManager clock freeze.
signal flow_state_changed(previous: StringName, current: StringName)
const STATE_TITLE := &"Title"
const STATE_DIFFICULTY := &"DifficultySelect"
const STATE_FRONT := &"Gameplay_FrontArea"
const STATE_BOSS_ROOM := &"Gameplay_BossRoom"
const STATE_PAUSED := &"Paused"
const STATE_VOLUME := &"VolumeSettings"
const STATE_EXIT_CONFIRM := &"ExitGameConfirm"
const STATE_RETURN_CONFIRM := &"ReturnTitleConfirm"
const STATE_DEFEAT := &"Defeat"
const STATE_VICTORY := &"Victory"
const STATE_RESULT := &"Result"
const GAMEPLAY_STATES: Array[StringName] = [STATE_FRONT, STATE_BOSS_ROOM]
const BOSS_ENTRY_MAX_ATTACK_BUFF := 3
# 2026-07-05 策划定案:胜负后的停留时间为最初值(1.6 / 1.2)的 3 倍。
const DEFEAT_SCREEN_DELAY := 4.8
const VICTORY_SCREEN_DELAY := 3.6
const TITLE_BG_COLOR := Color(0.06, 0.05, 0.1, 1.0)
const OVERLAY_BG_COLOR := Color(0.03, 0.03, 0.06, 0.78)
const ACCENT_COLOR := Color(1.0, 0.84, 0.4)
const FUTURE_ACCENT_COLOR := Color(0.45, 0.85, 1.0)
const DIFFICULTY_ORDER: Array[StringName] = [&"easy", &"normal", &"hard"]
const DIFFICULTY_NAMES := {&"easy": "简单", &"normal": "普通", &"hard": "困难"}
## 2026-07-05 组件化菜单套件:面板边框 + 按钮底两态 + 文字贴图,不再用整张菜单截图。
const MENU_FONT_PATH := "res://assets/fonts/fzzdhjw.ttf"
const PANEL_TALL := "res://assets/ui/menu2/panel_tall.png"
const BUTTON_BASE_NORMAL := "res://assets/ui/menu2/button_base_normal.png"
const BUTTON_BASE_HOVER := "res://assets/ui/menu2/button_base_hover.png"
## Title-side states keep the rhythm music playing; entering gameplay stops it
## so the run restarts the track from beat zero (§20 双层音乐 stays untouched).
const TITLE_MUSIC_STATES: Array[StringName] = [STATE_TITLE, STATE_DIFFICULTY, STATE_EXIT_CONFIRM]
const TITLE_MUSIC_STREAM_PATH := "res://assets/audio/ev_past1.mp3"
## 2026-07-05 策划:标题背景以 2 秒为单位在过去/未来间轮流切换,渐入过渡。
const TITLE_BG_HOLD_SECONDS := 2.0
const TITLE_BG_FADE_SECONDS := 0.8
## 标题/难度背景改用无 Logo 烙印的干净关卡原画;Logo 由独立悬浮节点叠加,
## 背景渐变时 Logo 与菜单保持固定,不再出现顶部重叠感。
const TITLE_BG_PAST_ART := "res://assets/art/ground/past/past.png"
const TITLE_BG_FUTURE_ART := "res://assets/art/ground/future/future.png"
const TITLE_LOGO_ART := "res://assets/ui/menu2/title_logo.png"
const TITLE_LOGO_RECT := Rect2(390.0, 140.0, 616.0, 300.0)
## Skips scene reloads / quit in headless test runs.
var manage_scene := true
var state: StringName = STATE_TITLE
var _screens: Dictionary = {}
var _ui_layer: CanvasLayer
var _pending_difficulty: StringName = &""
var _difficulty_buttons: Dictionary = {}
var _run_finished := false
var _victory := false
var _end_timer: SceneTreeTimer
var _boss_health: Node
var _music_slider: HSlider
var _sfx_slider: HSlider
var _music_value_label: Label
var _sfx_value_label: Label
var _title_music_player: AudioStreamPlayer
var _title_bg_future: TextureRect
var _title_bg_tween: Tween
var _menu_font: Font
var _title_difficulty_value: Label
var _difficulty_footer_value: Label
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
# Headless runs (tests / CI) must never boot into the paused title overlay.
if DisplayServer.get_name() == "headless" and name == "GameFlowManager":
manage_scene = false
_build_ui()
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("player_health_changed") and not bus.is_connected("player_health_changed", _on_player_health_changed):
bus.connect("player_health_changed", _on_player_health_changed)
if manage_scene:
call_deferred("_enter_title")
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("ui_cancel"):
return
match state:
STATE_FRONT, STATE_BOSS_ROOM:
pause_game()
STATE_PAUSED:
resume_game()
STATE_VOLUME:
_set_state(STATE_PAUSED)
STATE_RETURN_CONFIRM:
_set_state(STATE_PAUSED)
STATE_EXIT_CONFIRM:
_set_state(STATE_TITLE)
func is_gameplay_state(value: StringName = &"") -> bool:
var checked := value if not value.is_empty() else state
return GAMEPLAY_STATES.has(checked)
## ------------------------------------------------------------------ actions
func start_game() -> void:
_play_ui_sound()
_run_finished = false
_victory = false
if manage_scene:
await _reload_gameplay_scene()
_reset_run_state()
_set_state(STATE_FRONT)
_resume_tree()
_connect_boss_health()
func pause_game() -> void:
if not is_gameplay_state():
return
_pause_tree()
_set_state(STATE_PAUSED)
func resume_game() -> void:
if state != STATE_PAUSED and state != STATE_VOLUME:
return
_set_state(STATE_BOSS_ROOM if _boss_room_entered else STATE_FRONT)
_resume_tree()
func return_to_title() -> void:
# Abandoning the run (spec: 回到标题视为放弃本局).
_stats_stop()
_pause_tree()
_enter_title()
func quit_game() -> void:
if manage_scene:
get_tree().quit()
var _boss_room_entered := false
func enter_boss_room() -> void:
## Boss-room entry rules (spec §4.5): combo cleared, four-slot cleared,
## attack buff capped at 3, door locked behind the player.
if _boss_room_entered or not is_gameplay_state():
return
_boss_room_entered = true
var player := _player_or_null()
if player != null:
var streak_counter := player.get_node_or_null("StreakCounter")
if streak_counter != null and streak_counter.has_method("reset"):
streak_counter.call("reset")
var combo_window := player.get_node_or_null("ComboWindow")
if combo_window != null and combo_window.has_method("clear"):
combo_window.call("clear", &"boss_room_entry")
var buff := player.get_node_or_null("AttackBuffComponent")
if buff != null and buff.has_method("cap_stacks"):
buff.call("cap_stacks", BOSS_ENTRY_MAX_ATTACK_BUFF)
var boss := _boss_or_null()
if boss != null:
boss.set("combat_enabled", true)
boss.set("stationary", false)
var stage := _stage_or_null()
if stage != null and stage.has_method("enter_boss_room_view"):
stage.call("enter_boss_room_view")
_set_state(STATE_BOSS_ROOM)
func notify_boss_defeated() -> void:
if _run_finished or not is_gameplay_state():
return
_run_finished = true
_victory = true
_stats_stop()
_end_timer = get_tree().create_timer(VICTORY_SCREEN_DELAY, false)
_end_timer.timeout.connect(func() -> void:
# 回标题 / 重开新局会把 _run_finished 清掉;被暂停冻结的旧计时器
# 恢复后不得把结算画面砸进新一局。
if not _run_finished:
return
_pause_tree()
_set_state(STATE_VICTORY)
)
func notify_player_defeated() -> void:
if _run_finished or not is_gameplay_state():
return
_run_finished = true
_victory = false
_stats_stop()
_end_timer = get_tree().create_timer(DEFEAT_SCREEN_DELAY, false)
_end_timer.timeout.connect(func() -> void:
if not _run_finished:
return
_pause_tree()
_set_state(STATE_DEFEAT)
)
## ------------------------------------------------------------ state plumbing
func _enter_title() -> void:
_boss_room_entered = false
_run_finished = false
_pause_tree()
_set_state(STATE_TITLE)
func _set_state(next: StringName) -> void:
if next == state and _screens.has(next) and (_screens[next] as Control).visible:
return
var previous := state
state = next
_refresh_screens()
flow_state_changed.emit(previous, state)
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("flow_state_changed"):
bus.emit_signal("flow_state_changed", previous, state)
func _pause_tree() -> void:
if not is_inside_tree():
return
get_tree().paused = true
var rhythm := _rhythm_manager_or_null()
if rhythm != null and rhythm.has_method("pause_clock"):
rhythm.call("pause_clock")
func _resume_tree() -> void:
if not is_inside_tree():
return
var rhythm := _rhythm_manager_or_null()
if rhythm != null and rhythm.has_method("resume_clock"):
rhythm.call("resume_clock")
get_tree().paused = false
func _reload_gameplay_scene() -> void:
var tree := get_tree()
if tree.current_scene == null:
return
tree.reload_current_scene()
await tree.process_frame
await tree.process_frame
func _reset_run_state() -> void:
_boss_room_entered = false
var rhythm := _rhythm_manager_or_null()
if rhythm != null and rhythm.has_method("start"):
rhythm.call("start")
# The reloaded scene started its music layers on the stale clock position;
# snap them to the freshly zeroed clock so the run's music restarts from beat 0.
var audio_layers := _audio_layers_or_null()
if audio_layers != null and audio_layers.has_method("restart_layers"):
audio_layers.call("restart_layers")
var runner := _chart_runner_or_null()
if runner != null and runner.has_method("reset"):
runner.call("reset")
var stats := _game_stats_or_null()
if stats != null and stats.has_method("start_run"):
stats.call("start_run")
func _stats_stop() -> void:
var stats := _game_stats_or_null()
if stats != null and stats.has_method("stop_tracking"):
stats.call("stop_tracking")
func _on_player_health_changed(current: int, _maximum: int) -> void:
if current <= 0:
notify_player_defeated()
func _connect_boss_health() -> void:
_boss_health = null
var boss := _boss_or_null()
if boss == null:
return
_boss_health = boss.get_node_or_null("HealthComponent")
if _boss_health == null:
return
var callback := Callable(self, "_on_boss_health_changed")
if not _boss_health.is_connected("health_changed", callback):
_boss_health.connect("health_changed", callback)
func _on_boss_health_changed(current: int, _maximum: int) -> void:
if current <= 0:
notify_boss_defeated()
## ------------------------------------------------------------------- lookup
func _player_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("Stage/ActorsContainer/Player")
func _boss_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("Stage/ActorsContainer/Boss")
func _stage_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("Stage")
func _chart_runner_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("ChartRunner")
func _rhythm_manager_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("RhythmManager")
func _audio_layers_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("AudioLayerController")
func _game_settings_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("GameSettings")
func _game_stats_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("GameStats")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")
func _play_ui_sound() -> void:
var sfx := get_tree().root.get_node_or_null("SfxManager") if is_inside_tree() else null
if sfx != null and sfx.has_method("play_sfx"):
sfx.call("play_sfx", &"ui_click")
## ------------------------------------------------------------- title ambience
## 标题侧播放与游戏内相同的节奏音乐(Title / DifficultySelect / ExitConfirm),
## 由本节点直接持有播放器,因此标题界面暂停 SceneTree 时音乐照常播放。
func _setup_title_music() -> void:
_title_music_player = AudioStreamPlayer.new()
_title_music_player.name = "TitleMusicPlayer"
_title_music_player.process_mode = Node.PROCESS_MODE_ALWAYS
if AudioServer.get_bus_index("Music") != -1:
_title_music_player.bus = "Music"
if DisplayServer.get_name() != "headless" and ResourceLoader.exists(TITLE_MUSIC_STREAM_PATH):
_title_music_player.stream = load(TITLE_MUSIC_STREAM_PATH)
add_child(_title_music_player)
func _should_play_title_music() -> bool:
return TITLE_MUSIC_STATES.has(state)
func _update_title_music() -> void:
if _title_music_player == null:
return
if _should_play_title_music():
if not _title_music_player.playing:
_title_music_player.play(0.0)
elif _title_music_player.playing:
_title_music_player.stop()
## 标题背景在「现在」与「过去」两张场景图之间循环交叉淡入淡出。
func _start_title_background_loop() -> void:
if _title_bg_future == null:
return
if _title_bg_tween != null and _title_bg_tween.is_valid():
return
_title_bg_future.modulate.a = 1.0
_title_bg_tween = _title_bg_future.create_tween()
_title_bg_tween.set_loops()
_title_bg_tween.tween_interval(TITLE_BG_HOLD_SECONDS)
_title_bg_tween.tween_property(_title_bg_future, "modulate:a", 0.0, TITLE_BG_FADE_SECONDS)
_title_bg_tween.tween_interval(TITLE_BG_HOLD_SECONDS)
_title_bg_tween.tween_property(_title_bg_future, "modulate:a", 1.0, TITLE_BG_FADE_SECONDS)
func _stop_title_background_loop() -> void:
if _title_bg_tween != null and _title_bg_tween.is_valid():
_title_bg_tween.kill()
_title_bg_tween = null
## ----------------------------------------------------------------------- UI
## Flow screens are assembled from the modular menu kit (assets/ui/menu2/):
## panel frame + two-state button bases + text stamps, per the 2026-07-05
## designer mockups — no whole-screen menu art. State and settings logic
## stays in this manager.
func _build_ui() -> void:
_ui_layer = CanvasLayer.new()
_ui_layer.name = "FlowUI"
_ui_layer.layer = 100
_ui_layer.process_mode = Node.PROCESS_MODE_ALWAYS
add_child(_ui_layer)
_setup_title_music()
if ResourceLoader.exists(MENU_FONT_PATH):
_menu_font = load(MENU_FONT_PATH)
_screens[STATE_TITLE] = _build_title_screen()
_screens[STATE_DIFFICULTY] = _build_difficulty_screen()
_screens[STATE_PAUSED] = _build_pause_screen()
_screens[STATE_VOLUME] = _build_volume_screen()
_screens[STATE_EXIT_CONFIRM] = _build_confirm_screen(STATE_EXIT_CONFIRM, "确定要离开游戏吗?", func() -> void: quit_game(), func() -> void: _set_state(STATE_TITLE))
_screens[STATE_RETURN_CONFIRM] = _build_confirm_screen(STATE_RETURN_CONFIRM, "回到标题将放弃本局,确定吗?", func() -> void: return_to_title(), func() -> void: _set_state(STATE_PAUSED))
_screens[STATE_DEFEAT] = _build_end_splash(STATE_DEFEAT, "res://assets/ui/menu2/text_defeat.png")
_screens[STATE_VICTORY] = _build_end_splash(STATE_VICTORY, "res://assets/ui/menu2/text_victory.png")
_screens[STATE_RESULT] = _build_result_screen()
_refresh_screens()
func _refresh_screens() -> void:
for key: StringName in _screens.keys():
(_screens[key] as Control).visible = key == state
if state == STATE_VOLUME:
_sync_volume_widgets()
if state == STATE_DIFFICULTY:
_sync_difficulty_buttons()
if state == STATE_RESULT:
_populate_result_screen()
if state == STATE_TITLE or state == STATE_DIFFICULTY:
_sync_difficulty_footers()
if state == STATE_TITLE:
_start_title_background_loop()
else:
_stop_title_background_loop()
_update_title_music()
func _build_title_screen() -> Control:
var root := _make_screen_root("TitleScreen", TITLE_BG_COLOR)
# 标题背景循环动画:「过去」场景打底,「未来」场景叠在上层做透明度循环。
# 两张都是干净原画(Logo 不烙在背景里),渐变期间悬浮 Logo/菜单纹丝不动。
_add_art_background(root, TITLE_BG_PAST_ART, "SceneBackgroundPast")
_title_bg_future = _add_art_background(root, TITLE_BG_FUTURE_ART, "SceneBackgroundFuture")
_add_menu_art(root, PANEL_TALL, Vector2(38.0, 31.0))
_add_menu_texture(root, "TitleLogo", TITLE_LOGO_ART, TITLE_LOGO_RECT.position, TITLE_LOGO_RECT.size)
_make_art_button(root, "StartGameButton", Rect2(84, 230, 218, 72), func() -> void: start_game(), "res://assets/ui/menu2/text_start_game.png", Vector2(155.0, 41.0))
var open_difficulty := func() -> void:
_pending_difficulty = _current_difficulty()
_set_state(STATE_DIFFICULTY)
_make_art_button(root, "DifficultyButton", Rect2(84, 318, 218, 72), open_difficulty, "res://assets/ui/menu2/text_difficulty_select.png", Vector2(153.0, 41.0))
_make_art_button(root, "QuitGameButton", Rect2(84, 406, 218, 72), func() -> void: _set_state(STATE_EXIT_CONFIRM), "res://assets/ui/menu2/text_quit_game.png", Vector2(153.0, 40.0))
var caption := _make_title_label("当前难度:", 18, Color(0.85, 0.86, 0.96))
caption.name = "TitleDifficultyCaption"
caption.position = Vector2(84.0, 498.0)
caption.size = Vector2(218.0, 24.0)
root.add_child(caption)
_title_difficulty_value = _make_title_label("普通", 26, ACCENT_COLOR)
_title_difficulty_value.name = "TitleDifficultyValue"
_title_difficulty_value.position = Vector2(84.0, 526.0)
_title_difficulty_value.size = Vector2(218.0, 34.0)
root.add_child(_title_difficulty_value)
return root
func _build_difficulty_screen() -> Control:
var root := _make_screen_root("DifficultyScreen", TITLE_BG_COLOR)
_add_art_background(root, TITLE_BG_FUTURE_ART)
_add_menu_texture(root, "TitleLogo", TITLE_LOGO_ART, TITLE_LOGO_RECT.position, TITLE_LOGO_RECT.size)
_add_menu_art(root, PANEL_TALL, Vector2(38.0, 31.0))
_add_menu_texture(root, "DifficultyTitle", "res://assets/ui/menu2/text_difficulty_select.png", Vector2(112.0, 88.0), Vector2(153.0, 41.0))
_difficulty_buttons[&"easy"] = _make_difficulty_option(
root,
"EasyButton",
Rect2(84.0, 160.0, 218.0, 72.0),
&"easy",
"res://assets/ui/menu2/text_easy.png",
Vector2(81.0, 42.0)
)
_difficulty_buttons[&"normal"] = _make_difficulty_option(
root,
"NormalButton",
Rect2(84.0, 250.0, 218.0, 72.0),
&"normal",
"res://assets/ui/menu2/text_normal.png",
Vector2(83.0, 42.0)
)
_difficulty_buttons[&"hard"] = _make_difficulty_option(
root,
"HardButton",
Rect2(84.0, 340.0, 218.0, 72.0),
&"hard",
"res://assets/ui/menu2/text_hard.png",
Vector2(82.0, 42.0)
)
_make_art_button(root, "ConfirmStartButton", Rect2(84.0, 440.0, 218.0, 56.0), func() -> void: start_game(), "res://assets/ui/menu2/text_start_game.png", Vector2(155.0, 41.0))
_make_art_button(root, "BackTitleButton", Rect2(84.0, 504.0, 218.0, 56.0), func() -> void: _set_state(STATE_TITLE), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0))
_difficulty_footer_value = _make_title_label("当前难度:普通", 18, ACCENT_COLOR)
_difficulty_footer_value.name = "DifficultyFooterValue"
_difficulty_footer_value.position = Vector2(84.0, 572.0)
_difficulty_footer_value.size = Vector2(218.0, 26.0)
root.add_child(_difficulty_footer_value)
return root
func _build_pause_screen() -> Control:
var root := _make_screen_root("PauseScreen", OVERLAY_BG_COLOR)
_make_panel(root, "PausePanel", Rect2(425.0, 180.0, 302.0, 287.0))
_make_art_button(root, "ContinueButton", Rect2(468, 236, 216, 74), func() -> void: resume_game(), "res://assets/ui/menu2/text_continue_game.png", Vector2(156.0, 41.0))
_make_art_button(root, "ReturnTitleButton", Rect2(468, 334, 216, 74), func() -> void: _set_state(STATE_RETURN_CONFIRM), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0))
return root
func _build_volume_screen() -> Control:
var root := _make_screen_root("VolumeScreen", OVERLAY_BG_COLOR)
var box := _make_center_menu(root)
box.add_child(_make_title_label("音量设置", 40, ACCENT_COLOR))
box.add_child(_make_spacer(14))
var music_row := _make_slider_row("音乐音量")
_music_slider = music_row[1]
_music_value_label = music_row[2]
_music_slider.value_changed.connect(func(value: float) -> void:
var settings := _game_settings_or_null()
if settings != null:
settings.call("set_music_volume", int(value))
_music_value_label.text = str(int(value))
)
box.add_child(music_row[0])
var sfx_row := _make_slider_row("音效音量")
_sfx_slider = sfx_row[1]
_sfx_value_label = sfx_row[2]
_sfx_slider.value_changed.connect(func(value: float) -> void:
var settings := _game_settings_or_null()
if settings != null:
settings.call("set_sfx_volume", int(value))
_sfx_value_label.text = str(int(value))
)
_sfx_slider.drag_ended.connect(func(_changed: bool) -> void: _play_ui_sound())
box.add_child(sfx_row[0])
box.add_child(_make_spacer(16))
box.add_child(_make_menu_button("返回", func() -> void: _set_state(STATE_PAUSED)))
return root
func _build_confirm_screen(screen_state: StringName, question: String, on_confirm: Callable, on_cancel: Callable) -> Control:
var root := _make_screen_root("%sScreen" % screen_state, OVERLAY_BG_COLOR)
_make_panel(root, "ConfirmPanel", Rect2(425.0, 194.0, 302.0, 260.0))
var prompt := _make_title_label(question, 20, Color(0.92, 0.93, 1.0))
prompt.name = "ConfirmPrompt"
prompt.position = Vector2(455.0, 224.0)
prompt.size = Vector2(242.0, 64.0)
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
root.add_child(prompt)
_make_art_button(root, "ConfirmButton", Rect2(467.0, 300.0, 218.0, 56.0), on_confirm, "res://assets/ui/menu2/text_confirm.png", Vector2(88.0, 44.0))
_make_art_button(root, "CancelButton", Rect2(467.0, 366.0, 218.0, 56.0), on_cancel, "res://assets/ui/menu2/text_cancel.png", Vector2(85.0, 43.0))
return root
func _build_end_splash(splash_state: StringName, banner_texture_path: String) -> Control:
var root := _make_screen_root("%sScreen" % splash_state, OVERLAY_BG_COLOR)
_make_panel(root, "SplashPanel", Rect2(425.0, 186.0, 302.0, 276.0))
# 胜利/失败字样按 2 倍整数放大,配 NEAREST 过滤保持像素锐利。
var banner := _add_menu_texture(root, "SplashBanner", banner_texture_path, Vector2.ZERO, Vector2.ZERO)
if banner.texture != null:
var tex_size: Vector2 = banner.texture.get_size() * 2.0
banner.size = tex_size
banner.position = Vector2(425.0 + (302.0 - tex_size.x) * 0.5, 224.0)
banner.stretch_mode = TextureRect.STRETCH_SCALE
banner.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_make_art_button(root, "ShowResultButton", Rect2(467.0, 356.0, 218.0, 56.0), func() -> void: _set_state(STATE_RESULT), "", Vector2.ZERO, "查看结算")
return root
var _result_labels: Dictionary = {}
func _build_result_screen() -> Control:
var root := _make_screen_root("ResultScreen", TITLE_BG_COLOR)
_add_menu_art(root, PANEL_TALL, Vector2(425.0, 31.0))
_add_menu_texture(root, "ResultTitle", "res://assets/ui/menu2/text_result_title.png", Vector2(503.0, 62.0), Vector2(145.0, 38.0))
var row_y := 130.0
for entry: Array in [
["max_combo", "最大连击"],
["perfect", "完美"],
["good", "良好"],
["bad", "勉强"],
["miss", "失误"],
["anchor_success", "锚点成功"],
["phase_shift_count", "相位切换"],
]:
var name_label := _make_title_label(str(entry[1]), 18, Color(0.78, 0.8, 0.92))
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
name_label.position = Vector2(465.0, row_y)
name_label.size = Vector2(140.0, 26.0)
root.add_child(name_label)
var value_label := _make_title_label("0", 18, Color(1, 1, 1))
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
value_label.position = Vector2(560.0, row_y)
value_label.size = Vector2(127.0, 26.0)
root.add_child(value_label)
_result_labels[entry[0]] = value_label
row_y += 36.0
var rank_label := _make_title_label("RANK -", 40, FUTURE_ACCENT_COLOR)
rank_label.name = "RankLabel"
rank_label.position = Vector2(465.0, 396.0)
rank_label.size = Vector2(222.0, 56.0)
_result_labels["rank"] = rank_label
root.add_child(rank_label)
_make_art_button(root, "ReturnTitleButton", Rect2(467.0, 486.0, 218.0, 56.0), func() -> void: return_to_title(), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0))
return root
func _populate_result_screen() -> void:
var stats := _game_stats_or_null()
if stats == null:
return
var summary: Dictionary = stats.call("summary")
for key: String in ["max_combo", "perfect", "good", "bad", "miss", "anchor_success", "phase_shift_count"]:
if _result_labels.has(key):
(_result_labels[key] as Label).text = str(int(summary.get(key, 0)))
var rank_label := _result_labels.get("rank", null) as Label
if rank_label == null:
return
if _victory:
# Rank only on victory (§19.6); defeat shows stats without a rank.
# 2026-07-05 策划定案:rank 后只保留等级,不再附带分数。
rank_label.visible = true
rank_label.text = "RANK %s" % str(stats.call("current_rank"))
else:
rank_label.visible = false
func _sync_volume_widgets() -> void:
var settings := _game_settings_or_null()
if settings == null or _music_slider == null:
return
_music_slider.set_value_no_signal(float(int(settings.get("music_volume"))))
_sfx_slider.set_value_no_signal(float(int(settings.get("sfx_volume"))))
_music_value_label.text = str(int(settings.get("music_volume")))
_sfx_value_label.text = str(int(settings.get("sfx_volume")))
func _sync_difficulty_buttons() -> void:
if _pending_difficulty.is_empty():
_pending_difficulty = _current_difficulty()
for key: Variant in _difficulty_buttons.keys():
var button := _difficulty_buttons[key] as Button
if button == null:
continue
var selected := StringName(str(key)) == _pending_difficulty
button.set_pressed_no_signal(selected)
# 选中项换成高亮橙框底,未选中项回普通蓝框底并略压暗。
button.add_theme_stylebox_override("normal", _base_stylebox(selected))
button.modulate = Color(1.0, 1.0, 1.0, 1.0) if selected else Color(0.8, 0.8, 0.86, 1.0)
func _select_difficulty(value: StringName) -> void:
if not DIFFICULTY_ORDER.has(value):
return
_pending_difficulty = value
var settings := _game_settings_or_null()
if settings != null:
settings.call("set_difficulty", _pending_difficulty)
_sync_difficulty_buttons()
_sync_difficulty_footers()
func _sync_difficulty_footers() -> void:
var display: String = DIFFICULTY_NAMES.get(_current_difficulty(), "普通")
if _title_difficulty_value != null:
_title_difficulty_value.text = display
if _difficulty_footer_value != null:
_difficulty_footer_value.text = "当前难度:%s" % display
func _current_difficulty() -> StringName:
var settings := _game_settings_or_null()
if settings == null:
return &"normal"
return StringName(str(settings.get("difficulty")))
## --------------------------------------------------------------- UI helpers
func _make_screen_root(node_name: String, bg_color: Color) -> Control:
var root := Control.new()
root.name = node_name
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.visible = false
root.mouse_filter = Control.MOUSE_FILTER_STOP
var background := ColorRect.new()
background.name = "Background"
background.color = bg_color
background.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_child(background)
_ui_layer.add_child(root)
return root
## Backgrounds must be added right after _make_screen_root so they stack above
## the ColorRect and below the menu art, in call order.
func _add_art_background(root: Control, texture_path: String, art_name := "SceneBackground") -> TextureRect:
var art := TextureRect.new()
art.name = art_name
art.texture = load(texture_path)
art.set_anchors_preset(Control.PRESET_FULL_RECT)
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
art.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(art)
return art
func _add_menu_art(root: Control, texture_path: String, position: Vector2) -> TextureRect:
var art := TextureRect.new()
art.name = "MenuArt"
art.texture = load(texture_path)
art.position = position
if art.texture != null:
art.size = art.texture.get_size()
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_KEEP
art.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(art)
return art
func _add_menu_texture(root: Control, node_name: String, texture_path: String, position: Vector2, size: Vector2) -> TextureRect:
var art := TextureRect.new()
art.name = node_name
art.texture = load(texture_path)
# expand/stretch 必须先于 size 赋值:默认 EXPAND_KEEP_SIZE 下最小尺寸
# 等于贴图原生尺寸,会把传入的 size 钳回原生值(Logo 缩放曾因此失效)。
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_SCALE
art.position = position
art.size = size
art.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(art)
return art
func _make_panel(root: Control, node_name: String, rect: Rect2) -> NinePatchRect:
var panel := NinePatchRect.new()
panel.name = node_name
panel.texture = load(PANEL_TALL)
panel.position = rect.position
panel.size = rect.size
panel.patch_margin_left = 26
panel.patch_margin_top = 26
panel.patch_margin_right = 26
panel.patch_margin_bottom = 26
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(panel)
return panel
func _base_stylebox(highlight: bool) -> StyleBoxTexture:
var style := StyleBoxTexture.new()
style.texture = load(BUTTON_BASE_HOVER if highlight else BUTTON_BASE_NORMAL)
return style
## 组件按钮:按钮底两态贴图 + 居中的文字贴图(或字体文字)。保持 Button 类型,
## 让既有的坐标点击测试与 is Button 断言继续成立。
func _make_art_button(root: Control, node_name: String, rect: Rect2, on_pressed: Callable, text_texture_path := "", text_size := Vector2.ZERO, label_text := "") -> Button:
var button := Button.new()
button.name = node_name
button.text = ""
# Mouse-only menus: a focused button would swallow Space/Enter, and Space
# is a core combat key — menu buttons must never eat it mid-fight.
button.focus_mode = Control.FOCUS_NONE
button.position = rect.position
button.size = rect.size
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.add_theme_stylebox_override("normal", _base_stylebox(false))
var hover := _base_stylebox(true)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", _base_stylebox(true))
button.add_theme_stylebox_override("focus", _base_stylebox(true))
if not text_texture_path.is_empty():
var text_art := TextureRect.new()
text_art.name = "ButtonText"
text_art.texture = load(text_texture_path)
text_art.position = (rect.size - text_size) * 0.5
text_art.size = text_size
text_art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
text_art.stretch_mode = TextureRect.STRETCH_KEEP
text_art.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(text_art)
elif not label_text.is_empty():
var label := _make_title_label(label_text, 24, ACCENT_COLOR)
label.name = "ButtonLabel"
label.set_anchors_preset(Control.PRESET_FULL_RECT)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(label)
button.pressed.connect(func() -> void:
_play_ui_sound()
on_pressed.call()
)
root.add_child(button)
return button
func _make_difficulty_option(root: Control, node_name: String, rect: Rect2, difficulty: StringName, text_texture_path: String, text_size: Vector2) -> Button:
var button := _make_art_button(root, node_name, rect, func() -> void: _select_difficulty(difficulty), text_texture_path, text_size)
button.toggle_mode = true
return button
func _make_center_menu(root: Control) -> VBoxContainer:
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_child(center)
var panel := PanelContainer.new()
var style := StyleBoxFlat.new()
style.bg_color = Color(0.08, 0.08, 0.14, 0.92)
style.border_color = ACCENT_COLOR * Color(1, 1, 1, 0.65)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.content_margin_left = 42.0
style.content_margin_right = 42.0
style.content_margin_top = 30.0
style.content_margin_bottom = 30.0
panel.add_theme_stylebox_override("panel", style)
center.add_child(panel)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 10)
panel.add_child(box)
return box
func _make_title_label(text: String, font_size: int, color: Color) -> Label:
var label := Label.new()
label.text = text
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
if _menu_font != null:
label.add_theme_font_override("font", _menu_font)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
label.add_theme_constant_override("shadow_offset_x", 2)
label.add_theme_constant_override("shadow_offset_y", 2)
return label
func _make_menu_button(text: String, on_pressed: Callable) -> Button:
var button := _make_button(text, 22)
button.custom_minimum_size = Vector2(320, 46)
button.pressed.connect(func() -> void:
_play_ui_sound()
on_pressed.call()
)
return button
func _make_button(text: String, font_size: int) -> Button:
var button := Button.new()
button.text = text
# Mouse-only menus: a focused button would swallow Space/Enter, and Space
# is a core combat key — the pause button must never eat it mid-fight.
button.focus_mode = Control.FOCUS_NONE
if _menu_font != null:
button.add_theme_font_override("font", _menu_font)
button.add_theme_font_size_override("font_size", font_size)
var normal := StyleBoxFlat.new()
normal.bg_color = Color(0.13, 0.13, 0.22, 0.95)
normal.border_color = Color(0.5, 0.45, 0.7, 0.8)
normal.border_width_left = 1
normal.border_width_top = 1
normal.border_width_right = 1
normal.border_width_bottom = 1
normal.corner_radius_top_left = 6
normal.corner_radius_top_right = 6
normal.corner_radius_bottom_left = 6
normal.corner_radius_bottom_right = 6
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color(0.2, 0.19, 0.32, 0.98)
hover.border_color = ACCENT_COLOR
var pressed := normal.duplicate() as StyleBoxFlat
pressed.bg_color = Color(0.32, 0.27, 0.16, 1.0)
pressed.border_color = ACCENT_COLOR
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_stylebox_override("focus", hover)
return button
func _make_slider_row(label_text: String) -> Array:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(380, 0)
row.add_theme_constant_override("separation", 12)
var label := _make_title_label(label_text, 18, Color(0.85, 0.86, 0.96))
label.custom_minimum_size = Vector2(96, 0)
row.add_child(label)
var slider := HSlider.new()
slider.focus_mode = Control.FOCUS_NONE
slider.min_value = 0
slider.max_value = 100
slider.step = 1
slider.custom_minimum_size = Vector2(200, 24)
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(slider)
var value_label := _make_title_label("0", 18, Color(1, 1, 1))
value_label.custom_minimum_size = Vector2(42, 0)
row.add_child(value_label)
return [row, slider, value_label]
func _make_spacer(height: float) -> Control:
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, height)
return spacer
+1
View File
@@ -0,0 +1 @@
uid://b2tnnoksko67h
+145
View File
@@ -0,0 +1,145 @@
extends Node
## Persistent player-facing settings (AnchorV1.0 §9.5 difficulty windows,
## §20.5 music/sfx volumes). Sole writer of the Music / SFX buses and of the
## RhythmManager judgement windows; every menu goes through this node.
signal difficulty_changed(difficulty: StringName)
signal volumes_changed(music_volume: int, sfx_volume: int)
const SETTINGS_PATH := "user://settings.cfg"
const MUSIC_BUS_NAME := "Music"
const SFX_BUS_NAME := "SFX"
const DIFFICULTY_ORDER: Array[StringName] = [&"easy", &"normal", &"hard"]
## Windows in seconds per AnchorV1.0 §21.8.
const DIFFICULTY_WINDOWS := {
&"easy": Vector3(0.090, 0.160, 0.260),
&"normal": Vector3(0.060, 0.115, 0.215),
&"hard": Vector3(0.030, 0.070, 0.170),
}
## 小怪血量难度倍率:普通 3 倍、困难 5 倍,简单保持基准值。
const DIFFICULTY_MINION_HEALTH_MULTIPLIER := {
&"easy": 1.0,
&"normal": 3.0,
&"hard": 5.0,
}
const DEFAULT_DIFFICULTY: StringName = &"normal"
const DEFAULT_MUSIC_VOLUME := 85
const DEFAULT_SFX_VOLUME := 80
var difficulty: StringName = DEFAULT_DIFFICULTY
var music_volume := DEFAULT_MUSIC_VOLUME
var sfx_volume := DEFAULT_SFX_VOLUME
func _ready() -> void:
_ensure_audio_buses()
_load_settings()
apply_difficulty_windows()
_apply_bus_volumes()
func set_difficulty(next: StringName) -> void:
if not DIFFICULTY_WINDOWS.has(next):
return
if next == difficulty:
return
difficulty = next
apply_difficulty_windows()
_save_settings()
difficulty_changed.emit(difficulty)
func set_music_volume(value: int) -> void:
music_volume = clampi(value, 0, 100)
_apply_bus_volumes()
_save_settings()
volumes_changed.emit(music_volume, sfx_volume)
func set_sfx_volume(value: int) -> void:
sfx_volume = clampi(value, 0, 100)
_apply_bus_volumes()
_save_settings()
volumes_changed.emit(music_volume, sfx_volume)
func difficulty_windows(for_difficulty: StringName = &"") -> Vector3:
var key := for_difficulty if DIFFICULTY_WINDOWS.has(for_difficulty) else difficulty
return DIFFICULTY_WINDOWS.get(key, DIFFICULTY_WINDOWS[DEFAULT_DIFFICULTY])
func minion_health_multiplier(for_difficulty: StringName = &"") -> float:
var key := for_difficulty if DIFFICULTY_MINION_HEALTH_MULTIPLIER.has(for_difficulty) else difficulty
return float(DIFFICULTY_MINION_HEALTH_MULTIPLIER.get(key, 1.0))
func difficulty_display_name(value: StringName = &"") -> String:
match (value if not value.is_empty() else difficulty):
&"easy":
return "简单"
&"hard":
return "困难"
return "普通"
func apply_difficulty_windows() -> void:
var rhythm := _rhythm_manager_or_null()
if rhythm == null or not rhythm.has_method("apply_judgement_windows"):
return
var windows := difficulty_windows()
rhythm.call("apply_judgement_windows", windows.x, windows.y, windows.z)
func _ensure_audio_buses() -> void:
for bus_name: String in [MUSIC_BUS_NAME, SFX_BUS_NAME]:
if AudioServer.get_bus_index(bus_name) != -1:
continue
AudioServer.add_bus()
var bus_index := AudioServer.bus_count - 1
AudioServer.set_bus_name(bus_index, bus_name)
AudioServer.set_bus_send(bus_index, "Master")
func _apply_bus_volumes() -> void:
_set_bus_volume(MUSIC_BUS_NAME, music_volume)
_set_bus_volume(SFX_BUS_NAME, sfx_volume)
func _set_bus_volume(bus_name: String, value: int) -> void:
var bus_index := AudioServer.get_bus_index(bus_name)
if bus_index == -1:
return
var linear := clampf(float(value) / 100.0, 0.0, 1.0)
AudioServer.set_bus_mute(bus_index, linear <= 0.0)
AudioServer.set_bus_volume_db(bus_index, linear_to_db(maxf(0.0001, linear)))
func _load_settings() -> void:
var config := ConfigFile.new()
if config.load(SETTINGS_PATH) != OK:
return
var stored_difficulty := StringName(str(config.get_value("gameplay", "difficulty", str(DEFAULT_DIFFICULTY))))
if DIFFICULTY_WINDOWS.has(stored_difficulty):
difficulty = stored_difficulty
music_volume = clampi(int(config.get_value("audio", "music_volume", DEFAULT_MUSIC_VOLUME)), 0, 100)
sfx_volume = clampi(int(config.get_value("audio", "sfx_volume", DEFAULT_SFX_VOLUME)), 0, 100)
func _save_settings() -> void:
var config := ConfigFile.new()
config.set_value("gameplay", "difficulty", str(difficulty))
config.set_value("audio", "music_volume", music_volume)
config.set_value("audio", "sfx_volume", sfx_volume)
config.save(SETTINGS_PATH)
func _rhythm_manager_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("RhythmManager")
+1
View File
@@ -0,0 +1 @@
uid://cyvknt0udm8i
+159
View File
@@ -0,0 +1,159 @@
extends Node
## Per-run battle statistics for the result screen (AnchorV1.0 §19.6 / §21.10).
## Pure fact subscriber: judgement facts, anchor resolutions and phase shifts
## flow in from the EventBus; the rank formula reads only this node.
signal stats_changed
const RANK_THRESHOLDS := [
{"rank": "S", "min": 90.0},
{"rank": "A", "min": 80.0},
{"rank": "B", "min": 65.0},
{"rank": "C", "min": 50.0},
{"rank": "D", "min": 0.0},
]
const COMBO_TARGET := 80.0
const ACCURACY_WEIGHT := 65.0
const ANCHOR_WEIGHT := 20.0
const COMBO_WEIGHT := 15.0
var perfect_count := 0
var good_count := 0
var bad_count := 0
var miss_count := 0
var max_combo := 0
var anchor_success_count := 0
var phase_shift_count := 0
var _tracking := false
func _ready() -> void:
var bus := _event_bus_or_null()
if bus == null:
return
_connect_once(bus, "judgement_made", _on_judgement_made)
_connect_once(bus, "time_anchor_resolved", _on_time_anchor_resolved)
_connect_once(bus, "time_phase_changed", _on_time_phase_changed)
_connect_once(bus, "streak_changed", _on_streak_changed)
func start_run() -> void:
reset()
_tracking = true
func stop_tracking() -> void:
_tracking = false
func reset() -> void:
perfect_count = 0
good_count = 0
bad_count = 0
miss_count = 0
max_combo = 0
anchor_success_count = 0
phase_shift_count = 0
stats_changed.emit()
func summary() -> Dictionary:
return {
"perfect": perfect_count,
"good": good_count,
"bad": bad_count,
"miss": miss_count,
"max_combo": max_combo,
"anchor_success": anchor_success_count,
"phase_shift_count": phase_shift_count,
}
## §19.6: 总分 = 节奏准确分 65 + 锚点控制分 20 + 连击稳定分 15.
func total_score() -> float:
return accuracy_score() + anchor_score() + combo_score()
func accuracy_score() -> float:
var total := perfect_count + good_count + bad_count + miss_count
if total <= 0:
return 0.0
var accuracy_raw := (float(perfect_count) * 1.00 + float(good_count) * 0.85 + float(bad_count) * 0.55) / float(total)
return accuracy_raw * ACCURACY_WEIGHT
func anchor_score() -> float:
var anchor_total := anchor_success_count + phase_shift_count
if anchor_total <= 0:
return ANCHOR_WEIGHT
return float(anchor_success_count) / float(anchor_total) * ANCHOR_WEIGHT
func combo_score() -> float:
return minf(float(max_combo) / COMBO_TARGET, 1.0) * COMBO_WEIGHT
func rank_for_score(score: float) -> String:
for entry: Dictionary in RANK_THRESHOLDS:
if score >= float(entry.get("min", 0.0)):
return str(entry.get("rank", "D"))
return "D"
func current_rank() -> String:
return rank_for_score(total_score())
func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void:
if not _tracking:
return
match quality:
&"perfect":
perfect_count += 1
&"good":
good_count += 1
&"bad":
bad_count += 1
&"miss":
miss_count += 1
stats_changed.emit()
func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void:
if not _tracking:
return
if held:
anchor_success_count += 1
stats_changed.emit()
func _on_time_phase_changed(_previous: StringName, _current: StringName, reason: StringName) -> void:
if not _tracking:
return
# Chart-driven initialisation is not a player-caused shift.
if reason == &"chart_init":
return
phase_shift_count += 1
stats_changed.emit()
func _on_streak_changed(count: int) -> void:
if not _tracking:
return
if count > max_combo:
max_combo = count
stats_changed.emit()
func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void:
if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback):
bus.connect(signal_name, callback)
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")
+1
View File
@@ -0,0 +1 @@
uid://dqcmps5vad0t
+262
View File
@@ -0,0 +1,262 @@
extends AudioStreamPlayer
signal beat_ticked(beat_index: int)
signal judgement_made(quality: StringName, offset_ms: float, beat_index: int)
@export var bpm: float = 129.2:
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.115
@export var bad_window := 0.215
@export var judgement_scale := 1.0
## AnchorV1.0 §9.5: at high BPM the Bad window may never cross 48% of a beat,
## and each tighter window may never exceed the looser one it nests inside.
@export var bad_window_beat_ratio_cap := 0.48
## new1 定案 (2026-07-05): 每个节奏点只能被一次有效输入消耗;无可用节奏点的
## 按键判 miss 并触发输入锁定,锁定期间的按键完全无效(反狂按)。
@export var input_lockout_seconds := 0.5
## 同一节奏点消耗后允许忽略的重复按键次数(双击容错),超出即 miss+锁定。
@export var consumed_repeat_grace := 1
@export var clock_volume_db: float = -10.0
@export var starts_on_ready := true
const DEFAULT_STREAM_PATH := "res://assets/audio/ev_past1.mp3"
var beat_time := 0.5
var beat_index := 0
var running := false
var _start_time_usec := 0
var _last_reported_beat := -1
var _clock_offset := 0.0
var _last_raw_playback_position := 0.0
var _playback_loop_offset := 0.0
var _clock_paused := false
var _paused_song_position := 0.0
var _consumed_beats: Dictionary = {}
var _lockout_until := -1.0
func _ready() -> void:
if stream == null and DisplayServer.get_name() != "headless":
stream = load(DEFAULT_STREAM_PATH)
volume_db = clock_volume_db
beat_time = 60.0 / maxf(1.0, bpm)
if starts_on_ready:
start()
func _process(_delta: float) -> void:
if not running or not playing:
return
var raw_song := _unwrapped_playback_position() + AudioServer.get_time_since_last_mix()
raw_song -= AudioServer.get_output_latency()
var ticks_sec := Time.get_ticks_msec() / 1000.0
_clock_offset = lerpf(_clock_offset, raw_song - ticks_sec, 0.05)
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 apply_judgement_windows(next_perfect: float, next_good: float, next_bad: float) -> void:
perfect_window = maxf(0.001, next_perfect)
good_window = maxf(perfect_window, next_good)
bad_window = maxf(good_window, next_bad)
func effective_windows() -> Vector3:
var capped_bad := minf(bad_window, beat_time * bad_window_beat_ratio_cap)
var capped_good := minf(good_window, capped_bad)
var capped_perfect := minf(perfect_window, capped_good)
return Vector3(capped_perfect, capped_good, capped_bad)
func configure(next_bpm: float, next_measures: int, next_beat_offset: float, next_windows := Vector3(0.060, 0.115, 0.215)) -> void:
bpm = next_bpm
measures = next_measures
beat_offset = next_beat_offset
apply_judgement_windows(next_windows.x, next_windows.y, next_windows.z)
func start() -> void:
running = true
_clock_paused = false
_paused_song_position = 0.0
_start_time_usec = Time.get_ticks_usec()
_clock_offset = -Time.get_ticks_msec() / 1000.0
_last_raw_playback_position = 0.0
_playback_loop_offset = 0.0
beat_index = 0
_last_reported_beat = -1
_consumed_beats.clear()
_lockout_until = -1.0
if stream != null:
if playing:
seek(0.0)
else:
play()
func stop_manager() -> void:
if playing:
stop()
running = false
_clock_paused = false
func _exit_tree() -> void:
if playing:
stop()
stream = null
## Freezes the musical clock for pause/menu states. The audio presentation can
## be paused separately; this keeps judgement time stable across menu duration.
func pause_clock() -> void:
if _clock_paused or not running:
return
_paused_song_position = song_position()
_clock_paused = true
func resume_clock() -> void:
if not _clock_paused:
return
_clock_offset = _paused_song_position - Time.get_ticks_msec() / 1000.0
_clock_paused = false
func song_position() -> float:
if _clock_paused:
return _paused_song_position
if running:
return maxf(0.0, Time.get_ticks_msec() / 1000.0 + _clock_offset)
return 0.0
func input_to_song_time(timestamp_ms: float) -> float:
return timestamp_ms / 1000.0 + _clock_offset
func judge(input_timestamp_ms: float) -> Dictionary:
return get_rating_for_time(input_to_song_time(input_timestamp_ms))
func is_input_locked() -> bool:
return _lockout_until >= 0.0 and song_position() < _lockout_until
## new1 定案:对一次已判定的实时动作输入做有状态门控。
## 返回 &"ok"(判定成立,消耗该节奏点)、&"ignored"(锁定期/双击容错,视同没按)
## 或 &"miss"(节奏点已被消耗且超出容错 → 降级为 miss,并开启输入锁定)。
## 纯判定接口 get_rating_for_time / judge 保持无副作用;只有这里改写状态。
func gate_judged_input(rating: Dictionary) -> StringName:
if is_input_locked():
return &"ignored"
if str(rating.get("label", "miss")) == "miss":
_begin_input_lockout()
return &"miss"
var beat := int(rating.get("nearest_beat", 0))
if _consumed_beats.has(beat):
var repeats := int(_consumed_beats[beat])
if repeats < consumed_repeat_grace:
_consumed_beats[beat] = repeats + 1
return &"ignored"
_begin_input_lockout()
return &"miss"
_consumed_beats[beat] = 0
_prune_consumed_beats(beat)
return &"ok"
func _begin_input_lockout() -> void:
if input_lockout_seconds > 0.0:
_lockout_until = song_position() + input_lockout_seconds
func _prune_consumed_beats(latest_beat: int) -> void:
if _consumed_beats.size() <= 16:
return
for beat: int in _consumed_beats.keys():
if beat < latest_beat - 8:
_consumed_beats.erase(beat)
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)
var windows := effective_windows()
if abs_diff <= windows.x * scale:
return _rating_result(&"perfect", Color("00f2ff"), nearest_beat, nearest_beat_time, diff, abs_diff)
if abs_diff <= windows.y * scale:
return _rating_result(&"good", Color("ffffff"), nearest_beat, nearest_beat_time, diff, abs_diff)
if abs_diff <= windows.z * 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 _apply_beat_offset(time_seconds: float) -> float:
return time_seconds + beat_offset
func _unwrapped_playback_position() -> float:
var raw_position := get_playback_position()
var length := stream.get_length() if stream != null else 0.0
if length > 0.0 and _last_raw_playback_position - raw_position > length * 0.5:
_playback_loop_offset += length
_last_raw_playback_position = raw_position
return raw_position + _playback_loop_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")
+1
View File
@@ -0,0 +1 @@
uid://hoga4p3vm5qp
+263
View File
@@ -0,0 +1,263 @@
extends Node
## Placeholder SFX layer (AnchorV1.0 §20.4). Until real sound assets land,
## every required cue is a small synthesized tone generated at startup, routed
## through the SFX bus so §20.5 sfx_volume applies. Pure fact subscriber: it
## listens to the EventBus and never drives gameplay.
const SAMPLE_RATE := 22050
const VOICE_COUNT := 10
var _streams: Dictionary = {}
var _voices: Array[AudioStreamPlayer] = []
var _voice_cursor := 0
var _last_charge_level := 0
var _last_player_health := -1
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
if DisplayServer.get_name() == "headless":
return
_build_streams()
_build_voices()
_connect_bus()
func play_sfx(sfx_name: StringName) -> void:
var stream := _streams.get(sfx_name, null) as AudioStream
if stream == null or _voices.is_empty():
return
var voice := _voices[_voice_cursor % _voices.size()]
_voice_cursor += 1
voice.stream = stream
voice.play()
func has_sfx(sfx_name: StringName) -> bool:
return _streams.has(sfx_name)
func sfx_names() -> Array:
return _streams.keys()
func _build_voices() -> void:
for index: int in range(VOICE_COUNT):
var voice := AudioStreamPlayer.new()
voice.name = "Voice%d" % index
voice.bus = "SFX" if AudioServer.get_bus_index("SFX") != -1 else "Master"
add_child(voice)
_voices.append(voice)
func _connect_bus() -> void:
var bus := _event_bus_or_null()
if bus == null:
return
_connect_once(bus, "judgement_made", _on_judgement_made)
_connect_once(bus, "time_anchor_scheduled", _on_time_anchor_scheduled)
_connect_once(bus, "time_anchor_resolved", _on_time_anchor_resolved)
_connect_once(bus, "time_phase_changed", _on_time_phase_changed)
_connect_once(bus, "hit_confirmed", _on_hit_confirmed)
_connect_once(bus, "skill_executed", _on_skill_executed)
_connect_once(bus, "player_charge_changed", _on_player_charge_changed)
_connect_once(bus, "player_health_changed", _on_player_health_changed)
_connect_once(bus, "flow_state_changed", _on_flow_state_changed)
func _build_streams() -> void:
# Judgement feedback (§20.4: Perfect / Good / Bad / Miss + key feedback).
_streams[&"perfect"] = _tone([[1318.5, 1318.5, 0.05], [1760.0, 1760.0, 0.09]], 0.35)
_streams[&"good"] = _tone([[880.0, 880.0, 0.09]], 0.3)
_streams[&"bad"] = _tone([[392.0, 340.0, 0.11]], 0.3)
_streams[&"miss"] = _noise(0.14, 0.22, 900.0)
_streams[&"key_press"] = _tone([[520.0, 500.0, 0.03]], 0.15)
# Time anchor cues.
_streams[&"anchor_appear"] = _tone([[988.0, 988.0, 0.05], [1244.5, 1244.5, 0.05]], 0.22)
_streams[&"anchor_success"] = _tone([[784.0, 784.0, 0.06], [988.0, 988.0, 0.06], [1174.7, 1174.7, 0.1]], 0.32)
_streams[&"anchor_fail"] = _tone([[660.0, 330.0, 0.22]], 0.34)
# Phase switches (both directions distinct).
_streams[&"phase_to_future"] = _tone([[440.0, 1320.0, 0.3]], 0.3)
_streams[&"phase_to_past"] = _tone([[1320.0, 440.0, 0.3]], 0.3)
# Combat.
_streams[&"hit"] = _noise(0.09, 0.4, 2400.0)
_streams[&"skill_cast"] = _tone([[600.0, 900.0, 0.12]], 0.28)
_streams[&"swing"] = _noise(0.05, 0.18, 3200.0)
_streams[&"charge_start"] = _tone([[300.0, 420.0, 0.16]], 0.2)
_streams[&"charge_level"] = _tone([[700.0, 1050.0, 0.09]], 0.26)
_streams[&"charge_release"] = _tone([[1050.0, 500.0, 0.18]], 0.3)
_streams[&"guard_success"] = _tone([[1567.98, 1244.5, 0.07]], 0.3)
_streams[&"guard_stun"] = _tone([[350.0, 250.0, 0.12]], 0.26)
_streams[&"hurt"] = _noise(0.12, 0.34, 1200.0)
_streams[&"death"] = _tone([[520.0, 130.0, 0.6]], 0.34)
_streams[&"ui_click"] = _tone([[900.0, 860.0, 0.035]], 0.2)
# Run-outcome jingles (Victory / Defeat splash). Discrete note steps so
# defeat stays distinct from the &"death" 520→130Hz glide moments earlier.
_streams[&"victory"] = _note_sequence([[523.25, 0.2], [659.25, 0.2], [784.0, 0.2], [1046.5, 0.6]], 0.34)
_streams[&"defeat"] = _note_sequence([[392.0, 0.24], [311.13, 0.24], [261.63, 0.24], [196.0, 0.58]], 0.32)
## segments: Array of [freq_from, freq_to, seconds]; simple sine with a short
## exponential decay envelope.
func _tone(segments: Array, volume: float) -> AudioStreamWAV:
var total_seconds := 0.0
for segment: Array in segments:
total_seconds += float(segment[2])
var frame_count := maxi(1, int(total_seconds * SAMPLE_RATE))
var data := PackedByteArray()
data.resize(frame_count * 2)
var written := 0
var phase := 0.0
for segment: Array in segments:
var seg_frames := int(float(segment[2]) * SAMPLE_RATE)
for index: int in range(seg_frames):
if written >= frame_count:
break
var t := float(index) / float(maxi(1, seg_frames))
var freq := lerpf(float(segment[0]), float(segment[1]), t)
phase += TAU * freq / float(SAMPLE_RATE)
var global_t := float(written) / float(frame_count)
var envelope := minf(1.0, global_t * 24.0) * pow(1.0 - global_t, 1.4)
var sample := sin(phase) * envelope * volume
_write_sample(data, written, sample)
written += 1
return _make_wav(data, frame_count)
## notes: Array of [freq_hz, seconds]; each note gets its own attack/decay
## envelope — unlike _tone, whose single whole-stream decay would bury the
## closing notes of a second-long jingle.
func _note_sequence(notes: Array, volume: float) -> AudioStreamWAV:
var frame_count := 0
for note: Array in notes:
frame_count += maxi(1, int(float(note[1]) * SAMPLE_RATE))
var data := PackedByteArray()
data.resize(frame_count * 2)
var written := 0
for note: Array in notes:
var note_frames := maxi(1, int(float(note[1]) * SAMPLE_RATE))
var phase := 0.0
for index: int in range(note_frames):
var t := float(index) / float(note_frames)
phase += TAU * float(note[0]) / float(SAMPLE_RATE)
var envelope := minf(1.0, t * 24.0) * pow(1.0 - t, 1.6)
_write_sample(data, written, sin(phase) * envelope * volume)
written += 1
return _make_wav(data, frame_count)
func _noise(seconds: float, volume: float, cutoff_hint_hz: float) -> AudioStreamWAV:
var frame_count := maxi(1, int(seconds * SAMPLE_RATE))
var data := PackedByteArray()
data.resize(frame_count * 2)
var rng := RandomNumberGenerator.new()
rng.seed = int(cutoff_hint_hz)
var previous := 0.0
var smoothing := clampf(1.0 - cutoff_hint_hz / 6000.0, 0.0, 0.96)
for index: int in range(frame_count):
var t := float(index) / float(frame_count)
var envelope := minf(1.0, t * 30.0) * pow(1.0 - t, 1.8)
var raw := rng.randf_range(-1.0, 1.0)
previous = previous * smoothing + raw * (1.0 - smoothing)
_write_sample(data, index, previous * envelope * volume)
return _make_wav(data, frame_count)
func _write_sample(data: PackedByteArray, frame_index: int, sample: float) -> void:
var value := int(clampf(sample, -1.0, 1.0) * 32767.0)
data.encode_s16(frame_index * 2, value)
func _make_wav(data: PackedByteArray, _frame_count: int) -> AudioStreamWAV:
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
stream.data = data
return stream
## ------------------------------------------------------------- subscribers
func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void:
match quality:
&"perfect":
play_sfx(&"perfect")
&"good":
play_sfx(&"good")
&"bad":
play_sfx(&"bad")
&"miss":
play_sfx(&"miss")
func _on_time_anchor_scheduled(_anchor: Dictionary) -> void:
play_sfx(&"anchor_appear")
func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void:
play_sfx(&"anchor_success" if held else &"anchor_fail")
func _on_time_phase_changed(_previous: StringName, current: StringName, reason: StringName) -> void:
if reason == &"chart_init":
return
play_sfx(&"phase_to_future" if current == &"future" else &"phase_to_past")
func _on_hit_confirmed(result: Dictionary) -> void:
var defense = result.get("defense", {})
if defense is Dictionary and StringName(str((defense as Dictionary).get("defense_state", &"Vulnerable"))) == &"Parrying":
play_sfx(&"guard_success")
return
if int(result.get("damage", 0)) > 0:
play_sfx(&"hit")
func _on_skill_executed(skill: Resource, _judgement: StringName) -> void:
if skill == null:
return
play_sfx(&"skill_cast" if float(skill.get("base_cost")) > 0.0 else &"swing")
func _on_player_charge_changed(current: float, _maximum: float, _ready: bool, active: bool) -> void:
if not active:
_last_charge_level = 0
return
var level := 1 + int(floor(current + 0.0001))
if _last_charge_level == 0:
play_sfx(&"charge_start")
elif level > _last_charge_level:
play_sfx(&"charge_level")
_last_charge_level = level
func _on_player_health_changed(current: int, _maximum: int) -> void:
if _last_player_health < 0:
_last_player_health = current
return
if current <= 0 and _last_player_health > 0:
play_sfx(&"death")
elif current < _last_player_health:
play_sfx(&"hurt")
_last_player_health = current
func _on_flow_state_changed(_previous: StringName, current: StringName) -> void:
if current == &"Victory":
play_sfx(&"victory")
elif current == &"Defeat":
play_sfx(&"defeat")
func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void:
if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback):
bus.connect(signal_name, callback)
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")
+1
View File
@@ -0,0 +1 @@
uid://cs4imfipdkhyn
+318
View File
@@ -0,0 +1,318 @@
extends Node
## Time anchor scheduling + resolution (AnchorV1.0 chapter 10/11).
## Sources v1: explicit chart `time_anchor` events + periodic baseline whose
## interval follows the streak frequency profile. Resolution only consumes
## `judgement_made` facts; it never judges input a second time.
const DEFAULT_FREQUENCY_PROFILE_PATH := "res://resources/time_anchor_frequency_default.tres"
const DEADLINE_EPSILON := 0.02
const SOURCE_CHART := &"chart"
const SOURCE_PERIODIC := &"periodic"
@export var lead_beats := 2.0
@export var periodic_scheduling_enabled := true
@export var frequency_profile: Resource
var current_streak := 0
var _anchors: Dictionary = {}
var _anchored_beats: Dictionary = {}
var _last_periodic_origin_beat := 0
var _next_periodic_window_start := 0
var _periodic_initialized := false
var _last_resolved: Dictionary = {}
func _ready() -> void:
if frequency_profile == null and ResourceLoader.exists(DEFAULT_FREQUENCY_PROFILE_PATH):
frequency_profile = load(DEFAULT_FREQUENCY_PROFILE_PATH)
var bus := _event_bus_or_null()
if bus == null:
return
_connect_once(bus, "chart_event_upcoming", _on_chart_event_upcoming)
_connect_once(bus, "chart_event_triggered", _on_chart_event_triggered)
_connect_once(bus, "judgement_made", _on_judgement_made)
_connect_once(bus, "streak_changed", _on_streak_changed)
_connect_once(bus, "chart_reset", _on_chart_reset)
func _physics_process(_delta: float) -> void:
# Only the real autoload singleton self-drives from the global clock.
# Test-added instances get auto-renamed on the name collision and are
# driven explicitly, so the autoload's wall clock never pollutes them.
if name != &"TimeAnchorSystem":
return
var rhythm := _rhythm_manager_or_null()
if rhythm == null or not bool(rhythm.get("running")):
return
var song_time := float(rhythm.call("song_position"))
var beat_time := maxf(0.001, float(rhythm.get("beat_time")))
var beat_offset := float(rhythm.get("beat_offset"))
update_periodic_scheduling((song_time + beat_offset) / beat_time)
check_deadlines_for_song_time(song_time)
func update_periodic_scheduling(judgement_beat_float: float) -> void:
if not periodic_scheduling_enabled:
return
if not _periodic_initialized:
_periodic_initialized = true
_next_periodic_window_start = maxi(0, int(floorf(judgement_beat_float / float(_window_beats()))) * _window_beats())
_last_periodic_origin_beat = _next_periodic_window_start
var lead_limit := judgement_beat_float + lead_beats
while true:
var has_candidate_after_lead := false
for offset: int in _current_window_offsets():
var candidate_beat := _next_periodic_window_start + offset
if float(candidate_beat) <= judgement_beat_float:
continue
if float(candidate_beat) > lead_limit:
has_candidate_after_lead = true
break
if not _anchored_beats.has(candidate_beat):
schedule_time_anchor(candidate_beat, SOURCE_PERIODIC)
if has_candidate_after_lead:
break
_next_periodic_window_start += _window_beats()
_last_periodic_origin_beat = _next_periodic_window_start
func schedule_time_anchor(beat: int, source: StringName, source_event: Resource = null, min_grade: StringName = &"bad") -> Dictionary:
if _anchored_beats.has(beat):
var existing_key: StringName = _anchored_beats[beat]
var existing: Dictionary = _anchors.get(existing_key, {})
if source == SOURCE_CHART and StringName(str(existing.get("source"))) == SOURCE_PERIODIC:
existing["source"] = SOURCE_CHART
existing["source_event"] = source_event
existing["min_grade"] = min_grade
_restart_periodic_from_beat(beat)
return existing
var key := StringName("%s_%d" % [source, beat])
var anchor := {
"key": key,
"beat": beat,
"deadline": _deadline_for_beat(beat),
"min_grade": min_grade,
"source": source,
"source_event": source_event,
}
_anchors[key] = anchor
_anchored_beats[beat] = key
if source == SOURCE_CHART:
_restart_periodic_from_beat(beat)
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("time_anchor_scheduled"):
bus.emit_signal("time_anchor_scheduled", anchor)
return anchor
func check_deadlines_for_song_time(song_time: float) -> void:
var broken: Array[Dictionary] = []
for key: StringName in _anchors.keys():
var anchor: Dictionary = _anchors[key]
if song_time > float(anchor.get("deadline", 0.0)):
broken.append(anchor)
for anchor: Dictionary in broken:
_resolve_anchor(anchor, false, {})
func armed_anchor_summaries() -> Array[Dictionary]:
var summaries: Array[Dictionary] = []
for key: StringName in _anchors.keys():
var anchor: Dictionary = _anchors[key]
summaries.append({
"beat": int(anchor.get("beat", 0)),
"deadline": float(anchor.get("deadline", 0.0)),
"min_grade": StringName(str(anchor.get("min_grade", &"bad"))),
"source": StringName(str(anchor.get("source", &"-"))),
})
summaries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("beat", 0)) < int(b.get("beat", 0))
)
return summaries
func last_resolved_summary() -> Dictionary:
return _last_resolved.duplicate()
func anchored_beat_count() -> int:
return _anchored_beats.size()
func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void:
if quality == &"miss":
return
var key: StringName = _anchored_beats.get(beat_index, &"")
if key.is_empty() or not _anchors.has(key):
return
var anchor: Dictionary = _anchors[key]
if _grade_rank(quality) < _grade_rank(StringName(str(anchor.get("min_grade", &"bad")))):
return
_resolve_anchor(anchor, true, {
"label": str(quality),
"offset_ms": offset_ms,
"beat_index": beat_index,
})
func _on_chart_event_upcoming(event: Resource, _time_to_event: float) -> void:
_try_schedule_chart_anchor(event)
func _on_chart_event_triggered(event: Resource) -> void:
if event == null:
return
var event_type := StringName(str(event.get("event_type")))
if event_type == &"time_anchor":
_try_schedule_chart_anchor(event)
elif event_type == &"force_time_phase":
var payload: Dictionary = event.get("payload") if event.get("payload") is Dictionary else {}
var target := StringName(str(payload.get("target_time_phase", "")))
var manager := _time_phase_manager_or_null()
if manager != null and manager.has_method("set_time_phase"):
manager.call("set_time_phase", target, &"chart_forced")
func _try_schedule_chart_anchor(event: Resource) -> void:
if event == null or StringName(str(event.get("event_type"))) != &"time_anchor":
return
if int(event.get("subdivision")) != 0:
push_warning("time_anchor events must sit on integer beats; skipping %s" % str(event.call("key")))
return
var min_grade := &"bad"
var payload: Dictionary = event.get("payload") if event.get("payload") is Dictionary else {}
if payload.has("min_grade"):
min_grade = StringName(str(payload["min_grade"]))
schedule_time_anchor(int(event.get("beat_index")), SOURCE_CHART, event, min_grade)
func _on_streak_changed(count: int) -> void:
current_streak = maxi(0, count)
func _on_chart_reset(_chart_id: StringName) -> void:
_anchors.clear()
_anchored_beats.clear()
_periodic_initialized = false
_last_periodic_origin_beat = 0
_next_periodic_window_start = 0
current_streak = 0
_last_resolved = {}
func _resolve_anchor(anchor: Dictionary, held: bool, judgement: Dictionary) -> void:
var key := StringName(str(anchor.get("key")))
_anchors.erase(key)
_anchored_beats.erase(int(anchor.get("beat", 0)))
_last_resolved = {
"beat": int(anchor.get("beat", 0)),
"held": held,
"source": StringName(str(anchor.get("source", &"-"))),
"judgement": judgement.get("label", ""),
}
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("time_anchor_resolved"):
bus.emit_signal("time_anchor_resolved", anchor, held, judgement)
if not held:
var manager := _time_phase_manager_or_null()
if manager != null and manager.has_method("toggle_time_phase"):
manager.call("toggle_time_phase", &"time_anchor_broken")
func _restart_periodic_from_beat(beat: int) -> void:
_last_periodic_origin_beat = maxi(_last_periodic_origin_beat, beat)
_next_periodic_window_start = maxi(_next_periodic_window_start, beat)
func _current_interval() -> int:
if frequency_profile != null and frequency_profile.has_method("interval_for_streak"):
return maxi(1, int(frequency_profile.call("interval_for_streak", current_streak)))
return _window_beats()
func _current_window_offsets() -> Array[int]:
if frequency_profile != null and frequency_profile.has_method("anchor_offsets_for_streak"):
var offsets = frequency_profile.call("anchor_offsets_for_streak", current_streak)
if offsets is Array and not offsets.is_empty():
var result: Array[int] = []
for offset: Variant in offsets:
result.append(clampi(int(offset), 1, _window_beats()))
return result
return [_window_beats()]
func _window_beats() -> int:
if frequency_profile != null:
var value = frequency_profile.get("window_beats")
if value != null:
return maxi(1, int(value))
return 4
func _deadline_for_beat(beat: int) -> float:
var beat_time := 0.5
var beat_offset := 0.0
var bad_window := 0.2
var judgement_scale := 1.0
var rhythm := _rhythm_manager_or_null()
if rhythm != null:
beat_time = maxf(0.001, float(rhythm.get("beat_time")))
beat_offset = float(rhythm.get("beat_offset"))
bad_window = float(rhythm.get("bad_window"))
judgement_scale = maxf(0.01, float(rhythm.get("judgement_scale")))
return float(beat) * beat_time - beat_offset + bad_window * judgement_scale + DEADLINE_EPSILON
func _grade_rank(grade: StringName) -> int:
match grade:
&"perfect":
return 3
&"good":
return 2
&"bad":
return 1
return 0
func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void:
if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback):
bus.connect(signal_name, callback)
func _rhythm_manager_or_null() -> Node:
# Reverse root scan like the bus/manager lookups: a test-added rhythm clock
# wins over the autoload, so the autoload's wall-clock never leaks into a
# test instance's periodic scheduling.
if not is_inside_tree():
return null
return _last_root_child_matching(func(child: Node) -> bool:
return child.has_method("song_position") and child.get("beat_time") != null
)
func _time_phase_manager_or_null() -> Node:
if not is_inside_tree():
return null
return _last_root_child_matching(func(child: Node) -> bool:
return child.has_method("set_time_phase") and child.get("current_time_phase") != null
)
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return _last_root_child_matching(func(child: Node) -> bool:
return child.has_signal("chart_event_triggered") and child.has_signal("time_phase_changed")
)
func _last_root_child_matching(predicate: Callable) -> Node:
var children := get_tree().root.get_children()
for index: int in range(children.size() - 1, -1, -1):
var child: Node = children[index]
if bool(predicate.call(child)):
return child
return null
+1
View File
@@ -0,0 +1 @@
uid://ck5qxcsqffc2c
+38
View File
@@ -0,0 +1,38 @@
extends Node
const PAST := &"past"
const FUTURE := &"future"
var current_time_phase: StringName = PAST
func set_time_phase(next: StringName, reason: StringName) -> void:
if next != PAST and next != FUTURE:
return
if next == current_time_phase:
return
var previous := current_time_phase
current_time_phase = next
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("time_phase_changed"):
bus.emit_signal("time_phase_changed", previous, current_time_phase, reason)
func toggle_time_phase(reason: StringName) -> void:
set_time_phase(FUTURE if current_time_phase == PAST else PAST, reason)
func reset_to_initial(initial: StringName) -> void:
var target := initial if (initial == PAST or initial == FUTURE) else PAST
set_time_phase(target, &"chart_init")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
var children := get_tree().root.get_children()
for index: int in range(children.size() - 1, -1, -1):
var child: Node = children[index]
if child.has_signal("time_phase_changed") and child.has_signal("chart_event_triggered"):
return child
return null
+1
View File
@@ -0,0 +1 @@
uid://cr44hqhem71a8