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