Initial project sync
This commit is contained in:
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmeiefmd38a30
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpgixq8ibqhh4
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b2tnnoksko67h
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyvknt0udm8i
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqcmps5vad0t
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://hoga4p3vm5qp
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cs4imfipdkhyn
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ck5qxcsqffc2c
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cr44hqhem71a8
|
||||
Reference in New Issue
Block a user