Initial project sync

This commit is contained in:
wxm
2026-07-06 22:55:14 -07:00
commit 8cca00d0da
1066 changed files with 58585 additions and 0 deletions
+964
View File
@@ -0,0 +1,964 @@
extends Node
## Outer game-flow state machine (AnchorV1.0 §4.5 / §19.5 / §21.7).
## Owns Title / DifficultySelect / Pause / VolumeSettings / confirm dialogs /
## Defeat / Victory / Result and the front-area → boss-room progression.
## It never judges input or resolves combat — battle systems stay untouched;
## pausing is the SceneTree pause plus the RhythmManager clock freeze.
signal flow_state_changed(previous: StringName, current: StringName)
const STATE_TITLE := &"Title"
const STATE_DIFFICULTY := &"DifficultySelect"
const STATE_FRONT := &"Gameplay_FrontArea"
const STATE_BOSS_ROOM := &"Gameplay_BossRoom"
const STATE_PAUSED := &"Paused"
const STATE_VOLUME := &"VolumeSettings"
const STATE_EXIT_CONFIRM := &"ExitGameConfirm"
const STATE_RETURN_CONFIRM := &"ReturnTitleConfirm"
const STATE_DEFEAT := &"Defeat"
const STATE_VICTORY := &"Victory"
const STATE_RESULT := &"Result"
const GAMEPLAY_STATES: Array[StringName] = [STATE_FRONT, STATE_BOSS_ROOM]
const BOSS_ENTRY_MAX_ATTACK_BUFF := 3
# 2026-07-05 策划定案:胜负后的停留时间为最初值(1.6 / 1.2)的 3 倍。
const DEFEAT_SCREEN_DELAY := 4.8
const VICTORY_SCREEN_DELAY := 3.6
const TITLE_BG_COLOR := Color(0.06, 0.05, 0.1, 1.0)
const OVERLAY_BG_COLOR := Color(0.03, 0.03, 0.06, 0.78)
const ACCENT_COLOR := Color(1.0, 0.84, 0.4)
const FUTURE_ACCENT_COLOR := Color(0.45, 0.85, 1.0)
const DIFFICULTY_ORDER: Array[StringName] = [&"easy", &"normal", &"hard"]
const DIFFICULTY_NAMES := {&"easy": "简单", &"normal": "普通", &"hard": "困难"}
## 2026-07-05 组件化菜单套件:面板边框 + 按钮底两态 + 文字贴图,不再用整张菜单截图。
const MENU_FONT_PATH := "res://assets/fonts/fzzdhjw.ttf"
const PANEL_TALL := "res://assets/ui/menu2/panel_tall.png"
const BUTTON_BASE_NORMAL := "res://assets/ui/menu2/button_base_normal.png"
const BUTTON_BASE_HOVER := "res://assets/ui/menu2/button_base_hover.png"
## Title-side states keep the rhythm music playing; entering gameplay stops it
## so the run restarts the track from beat zero (§20 双层音乐 stays untouched).
const TITLE_MUSIC_STATES: Array[StringName] = [STATE_TITLE, STATE_DIFFICULTY, STATE_EXIT_CONFIRM]
const TITLE_MUSIC_STREAM_PATH := "res://assets/audio/ev_past1.mp3"
## 2026-07-05 策划:标题背景以 2 秒为单位在过去/未来间轮流切换,渐入过渡。
const TITLE_BG_HOLD_SECONDS := 2.0
const TITLE_BG_FADE_SECONDS := 0.8
## 标题/难度背景改用无 Logo 烙印的干净关卡原画;Logo 由独立悬浮节点叠加,
## 背景渐变时 Logo 与菜单保持固定,不再出现顶部重叠感。
const TITLE_BG_PAST_ART := "res://assets/art/ground/past/past.png"
const TITLE_BG_FUTURE_ART := "res://assets/art/ground/future/future.png"
const TITLE_LOGO_ART := "res://assets/ui/menu2/title_logo.png"
const TITLE_LOGO_RECT := Rect2(390.0, 140.0, 616.0, 300.0)
## Skips scene reloads / quit in headless test runs.
var manage_scene := true
var state: StringName = STATE_TITLE
var _screens: Dictionary = {}
var _ui_layer: CanvasLayer
var _pending_difficulty: StringName = &""
var _difficulty_buttons: Dictionary = {}
var _run_finished := false
var _victory := false
var _end_timer: SceneTreeTimer
var _boss_health: Node
var _music_slider: HSlider
var _sfx_slider: HSlider
var _music_value_label: Label
var _sfx_value_label: Label
var _title_music_player: AudioStreamPlayer
var _title_bg_future: TextureRect
var _title_bg_tween: Tween
var _menu_font: Font
var _title_difficulty_value: Label
var _difficulty_footer_value: Label
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
# Headless runs (tests / CI) must never boot into the paused title overlay.
if DisplayServer.get_name() == "headless" and name == "GameFlowManager":
manage_scene = false
_build_ui()
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("player_health_changed") and not bus.is_connected("player_health_changed", _on_player_health_changed):
bus.connect("player_health_changed", _on_player_health_changed)
if manage_scene:
call_deferred("_enter_title")
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("ui_cancel"):
return
match state:
STATE_FRONT, STATE_BOSS_ROOM:
pause_game()
STATE_PAUSED:
resume_game()
STATE_VOLUME:
_set_state(STATE_PAUSED)
STATE_RETURN_CONFIRM:
_set_state(STATE_PAUSED)
STATE_EXIT_CONFIRM:
_set_state(STATE_TITLE)
func is_gameplay_state(value: StringName = &"") -> bool:
var checked := value if not value.is_empty() else state
return GAMEPLAY_STATES.has(checked)
## ------------------------------------------------------------------ actions
func start_game() -> void:
_play_ui_sound()
_run_finished = false
_victory = false
if manage_scene:
await _reload_gameplay_scene()
_reset_run_state()
_set_state(STATE_FRONT)
_resume_tree()
_connect_boss_health()
func pause_game() -> void:
if not is_gameplay_state():
return
_pause_tree()
_set_state(STATE_PAUSED)
func resume_game() -> void:
if state != STATE_PAUSED and state != STATE_VOLUME:
return
_set_state(STATE_BOSS_ROOM if _boss_room_entered else STATE_FRONT)
_resume_tree()
func return_to_title() -> void:
# Abandoning the run (spec: 回到标题视为放弃本局).
_stats_stop()
_pause_tree()
_enter_title()
func quit_game() -> void:
if manage_scene:
get_tree().quit()
var _boss_room_entered := false
func enter_boss_room() -> void:
## Boss-room entry rules (spec §4.5): combo cleared, four-slot cleared,
## attack buff capped at 3, door locked behind the player.
if _boss_room_entered or not is_gameplay_state():
return
_boss_room_entered = true
var player := _player_or_null()
if player != null:
var streak_counter := player.get_node_or_null("StreakCounter")
if streak_counter != null and streak_counter.has_method("reset"):
streak_counter.call("reset")
var combo_window := player.get_node_or_null("ComboWindow")
if combo_window != null and combo_window.has_method("clear"):
combo_window.call("clear", &"boss_room_entry")
var buff := player.get_node_or_null("AttackBuffComponent")
if buff != null and buff.has_method("cap_stacks"):
buff.call("cap_stacks", BOSS_ENTRY_MAX_ATTACK_BUFF)
var boss := _boss_or_null()
if boss != null:
boss.set("combat_enabled", true)
boss.set("stationary", false)
var stage := _stage_or_null()
if stage != null and stage.has_method("enter_boss_room_view"):
stage.call("enter_boss_room_view")
_set_state(STATE_BOSS_ROOM)
func notify_boss_defeated() -> void:
if _run_finished or not is_gameplay_state():
return
_run_finished = true
_victory = true
_stats_stop()
_end_timer = get_tree().create_timer(VICTORY_SCREEN_DELAY, false)
_end_timer.timeout.connect(func() -> void:
# 回标题 / 重开新局会把 _run_finished 清掉;被暂停冻结的旧计时器
# 恢复后不得把结算画面砸进新一局。
if not _run_finished:
return
_pause_tree()
_set_state(STATE_VICTORY)
)
func notify_player_defeated() -> void:
if _run_finished or not is_gameplay_state():
return
_run_finished = true
_victory = false
_stats_stop()
_end_timer = get_tree().create_timer(DEFEAT_SCREEN_DELAY, false)
_end_timer.timeout.connect(func() -> void:
if not _run_finished:
return
_pause_tree()
_set_state(STATE_DEFEAT)
)
## ------------------------------------------------------------ state plumbing
func _enter_title() -> void:
_boss_room_entered = false
_run_finished = false
_pause_tree()
_set_state(STATE_TITLE)
func _set_state(next: StringName) -> void:
if next == state and _screens.has(next) and (_screens[next] as Control).visible:
return
var previous := state
state = next
_refresh_screens()
flow_state_changed.emit(previous, state)
var bus := _event_bus_or_null()
if bus != null and bus.has_signal("flow_state_changed"):
bus.emit_signal("flow_state_changed", previous, state)
func _pause_tree() -> void:
if not is_inside_tree():
return
get_tree().paused = true
var rhythm := _rhythm_manager_or_null()
if rhythm != null and rhythm.has_method("pause_clock"):
rhythm.call("pause_clock")
func _resume_tree() -> void:
if not is_inside_tree():
return
var rhythm := _rhythm_manager_or_null()
if rhythm != null and rhythm.has_method("resume_clock"):
rhythm.call("resume_clock")
get_tree().paused = false
func _reload_gameplay_scene() -> void:
var tree := get_tree()
if tree.current_scene == null:
return
tree.reload_current_scene()
await tree.process_frame
await tree.process_frame
func _reset_run_state() -> void:
_boss_room_entered = false
var rhythm := _rhythm_manager_or_null()
if rhythm != null and rhythm.has_method("start"):
rhythm.call("start")
# The reloaded scene started its music layers on the stale clock position;
# snap them to the freshly zeroed clock so the run's music restarts from beat 0.
var audio_layers := _audio_layers_or_null()
if audio_layers != null and audio_layers.has_method("restart_layers"):
audio_layers.call("restart_layers")
var runner := _chart_runner_or_null()
if runner != null and runner.has_method("reset"):
runner.call("reset")
var stats := _game_stats_or_null()
if stats != null and stats.has_method("start_run"):
stats.call("start_run")
func _stats_stop() -> void:
var stats := _game_stats_or_null()
if stats != null and stats.has_method("stop_tracking"):
stats.call("stop_tracking")
func _on_player_health_changed(current: int, _maximum: int) -> void:
if current <= 0:
notify_player_defeated()
func _connect_boss_health() -> void:
_boss_health = null
var boss := _boss_or_null()
if boss == null:
return
_boss_health = boss.get_node_or_null("HealthComponent")
if _boss_health == null:
return
var callback := Callable(self, "_on_boss_health_changed")
if not _boss_health.is_connected("health_changed", callback):
_boss_health.connect("health_changed", callback)
func _on_boss_health_changed(current: int, _maximum: int) -> void:
if current <= 0:
notify_boss_defeated()
## ------------------------------------------------------------------- lookup
func _player_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("Stage/ActorsContainer/Player")
func _boss_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("Stage/ActorsContainer/Boss")
func _stage_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("Stage")
func _chart_runner_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("ChartRunner")
func _rhythm_manager_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("RhythmManager")
func _audio_layers_or_null() -> Node:
var scene := get_tree().current_scene if is_inside_tree() else null
if scene == null:
return null
return scene.get_node_or_null("AudioLayerController")
func _game_settings_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("GameSettings")
func _game_stats_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("GameStats")
func _event_bus_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("EventBus")
func _play_ui_sound() -> void:
var sfx := get_tree().root.get_node_or_null("SfxManager") if is_inside_tree() else null
if sfx != null and sfx.has_method("play_sfx"):
sfx.call("play_sfx", &"ui_click")
## ------------------------------------------------------------- title ambience
## 标题侧播放与游戏内相同的节奏音乐(Title / DifficultySelect / ExitConfirm),
## 由本节点直接持有播放器,因此标题界面暂停 SceneTree 时音乐照常播放。
func _setup_title_music() -> void:
_title_music_player = AudioStreamPlayer.new()
_title_music_player.name = "TitleMusicPlayer"
_title_music_player.process_mode = Node.PROCESS_MODE_ALWAYS
if AudioServer.get_bus_index("Music") != -1:
_title_music_player.bus = "Music"
if DisplayServer.get_name() != "headless" and ResourceLoader.exists(TITLE_MUSIC_STREAM_PATH):
_title_music_player.stream = load(TITLE_MUSIC_STREAM_PATH)
add_child(_title_music_player)
func _should_play_title_music() -> bool:
return TITLE_MUSIC_STATES.has(state)
func _update_title_music() -> void:
if _title_music_player == null:
return
if _should_play_title_music():
if not _title_music_player.playing:
_title_music_player.play(0.0)
elif _title_music_player.playing:
_title_music_player.stop()
## 标题背景在「现在」与「过去」两张场景图之间循环交叉淡入淡出。
func _start_title_background_loop() -> void:
if _title_bg_future == null:
return
if _title_bg_tween != null and _title_bg_tween.is_valid():
return
_title_bg_future.modulate.a = 1.0
_title_bg_tween = _title_bg_future.create_tween()
_title_bg_tween.set_loops()
_title_bg_tween.tween_interval(TITLE_BG_HOLD_SECONDS)
_title_bg_tween.tween_property(_title_bg_future, "modulate:a", 0.0, TITLE_BG_FADE_SECONDS)
_title_bg_tween.tween_interval(TITLE_BG_HOLD_SECONDS)
_title_bg_tween.tween_property(_title_bg_future, "modulate:a", 1.0, TITLE_BG_FADE_SECONDS)
func _stop_title_background_loop() -> void:
if _title_bg_tween != null and _title_bg_tween.is_valid():
_title_bg_tween.kill()
_title_bg_tween = null
## ----------------------------------------------------------------------- UI
## Flow screens are assembled from the modular menu kit (assets/ui/menu2/):
## panel frame + two-state button bases + text stamps, per the 2026-07-05
## designer mockups — no whole-screen menu art. State and settings logic
## stays in this manager.
func _build_ui() -> void:
_ui_layer = CanvasLayer.new()
_ui_layer.name = "FlowUI"
_ui_layer.layer = 100
_ui_layer.process_mode = Node.PROCESS_MODE_ALWAYS
add_child(_ui_layer)
_setup_title_music()
if ResourceLoader.exists(MENU_FONT_PATH):
_menu_font = load(MENU_FONT_PATH)
_screens[STATE_TITLE] = _build_title_screen()
_screens[STATE_DIFFICULTY] = _build_difficulty_screen()
_screens[STATE_PAUSED] = _build_pause_screen()
_screens[STATE_VOLUME] = _build_volume_screen()
_screens[STATE_EXIT_CONFIRM] = _build_confirm_screen(STATE_EXIT_CONFIRM, "确定要离开游戏吗?", func() -> void: quit_game(), func() -> void: _set_state(STATE_TITLE))
_screens[STATE_RETURN_CONFIRM] = _build_confirm_screen(STATE_RETURN_CONFIRM, "回到标题将放弃本局,确定吗?", func() -> void: return_to_title(), func() -> void: _set_state(STATE_PAUSED))
_screens[STATE_DEFEAT] = _build_end_splash(STATE_DEFEAT, "res://assets/ui/menu2/text_defeat.png")
_screens[STATE_VICTORY] = _build_end_splash(STATE_VICTORY, "res://assets/ui/menu2/text_victory.png")
_screens[STATE_RESULT] = _build_result_screen()
_refresh_screens()
func _refresh_screens() -> void:
for key: StringName in _screens.keys():
(_screens[key] as Control).visible = key == state
if state == STATE_VOLUME:
_sync_volume_widgets()
if state == STATE_DIFFICULTY:
_sync_difficulty_buttons()
if state == STATE_RESULT:
_populate_result_screen()
if state == STATE_TITLE or state == STATE_DIFFICULTY:
_sync_difficulty_footers()
if state == STATE_TITLE:
_start_title_background_loop()
else:
_stop_title_background_loop()
_update_title_music()
func _build_title_screen() -> Control:
var root := _make_screen_root("TitleScreen", TITLE_BG_COLOR)
# 标题背景循环动画:「过去」场景打底,「未来」场景叠在上层做透明度循环。
# 两张都是干净原画(Logo 不烙在背景里),渐变期间悬浮 Logo/菜单纹丝不动。
_add_art_background(root, TITLE_BG_PAST_ART, "SceneBackgroundPast")
_title_bg_future = _add_art_background(root, TITLE_BG_FUTURE_ART, "SceneBackgroundFuture")
_add_menu_art(root, PANEL_TALL, Vector2(38.0, 31.0))
_add_menu_texture(root, "TitleLogo", TITLE_LOGO_ART, TITLE_LOGO_RECT.position, TITLE_LOGO_RECT.size)
_make_art_button(root, "StartGameButton", Rect2(84, 230, 218, 72), func() -> void: start_game(), "res://assets/ui/menu2/text_start_game.png", Vector2(155.0, 41.0))
var open_difficulty := func() -> void:
_pending_difficulty = _current_difficulty()
_set_state(STATE_DIFFICULTY)
_make_art_button(root, "DifficultyButton", Rect2(84, 318, 218, 72), open_difficulty, "res://assets/ui/menu2/text_difficulty_select.png", Vector2(153.0, 41.0))
_make_art_button(root, "QuitGameButton", Rect2(84, 406, 218, 72), func() -> void: _set_state(STATE_EXIT_CONFIRM), "res://assets/ui/menu2/text_quit_game.png", Vector2(153.0, 40.0))
var caption := _make_title_label("当前难度:", 18, Color(0.85, 0.86, 0.96))
caption.name = "TitleDifficultyCaption"
caption.position = Vector2(84.0, 498.0)
caption.size = Vector2(218.0, 24.0)
root.add_child(caption)
_title_difficulty_value = _make_title_label("普通", 26, ACCENT_COLOR)
_title_difficulty_value.name = "TitleDifficultyValue"
_title_difficulty_value.position = Vector2(84.0, 526.0)
_title_difficulty_value.size = Vector2(218.0, 34.0)
root.add_child(_title_difficulty_value)
return root
func _build_difficulty_screen() -> Control:
var root := _make_screen_root("DifficultyScreen", TITLE_BG_COLOR)
_add_art_background(root, TITLE_BG_FUTURE_ART)
_add_menu_texture(root, "TitleLogo", TITLE_LOGO_ART, TITLE_LOGO_RECT.position, TITLE_LOGO_RECT.size)
_add_menu_art(root, PANEL_TALL, Vector2(38.0, 31.0))
_add_menu_texture(root, "DifficultyTitle", "res://assets/ui/menu2/text_difficulty_select.png", Vector2(112.0, 88.0), Vector2(153.0, 41.0))
_difficulty_buttons[&"easy"] = _make_difficulty_option(
root,
"EasyButton",
Rect2(84.0, 160.0, 218.0, 72.0),
&"easy",
"res://assets/ui/menu2/text_easy.png",
Vector2(81.0, 42.0)
)
_difficulty_buttons[&"normal"] = _make_difficulty_option(
root,
"NormalButton",
Rect2(84.0, 250.0, 218.0, 72.0),
&"normal",
"res://assets/ui/menu2/text_normal.png",
Vector2(83.0, 42.0)
)
_difficulty_buttons[&"hard"] = _make_difficulty_option(
root,
"HardButton",
Rect2(84.0, 340.0, 218.0, 72.0),
&"hard",
"res://assets/ui/menu2/text_hard.png",
Vector2(82.0, 42.0)
)
_make_art_button(root, "ConfirmStartButton", Rect2(84.0, 440.0, 218.0, 56.0), func() -> void: start_game(), "res://assets/ui/menu2/text_start_game.png", Vector2(155.0, 41.0))
_make_art_button(root, "BackTitleButton", Rect2(84.0, 504.0, 218.0, 56.0), func() -> void: _set_state(STATE_TITLE), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0))
_difficulty_footer_value = _make_title_label("当前难度:普通", 18, ACCENT_COLOR)
_difficulty_footer_value.name = "DifficultyFooterValue"
_difficulty_footer_value.position = Vector2(84.0, 572.0)
_difficulty_footer_value.size = Vector2(218.0, 26.0)
root.add_child(_difficulty_footer_value)
return root
func _build_pause_screen() -> Control:
var root := _make_screen_root("PauseScreen", OVERLAY_BG_COLOR)
_make_panel(root, "PausePanel", Rect2(425.0, 180.0, 302.0, 287.0))
_make_art_button(root, "ContinueButton", Rect2(468, 236, 216, 74), func() -> void: resume_game(), "res://assets/ui/menu2/text_continue_game.png", Vector2(156.0, 41.0))
_make_art_button(root, "ReturnTitleButton", Rect2(468, 334, 216, 74), func() -> void: _set_state(STATE_RETURN_CONFIRM), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0))
return root
func _build_volume_screen() -> Control:
var root := _make_screen_root("VolumeScreen", OVERLAY_BG_COLOR)
var box := _make_center_menu(root)
box.add_child(_make_title_label("音量设置", 40, ACCENT_COLOR))
box.add_child(_make_spacer(14))
var music_row := _make_slider_row("音乐音量")
_music_slider = music_row[1]
_music_value_label = music_row[2]
_music_slider.value_changed.connect(func(value: float) -> void:
var settings := _game_settings_or_null()
if settings != null:
settings.call("set_music_volume", int(value))
_music_value_label.text = str(int(value))
)
box.add_child(music_row[0])
var sfx_row := _make_slider_row("音效音量")
_sfx_slider = sfx_row[1]
_sfx_value_label = sfx_row[2]
_sfx_slider.value_changed.connect(func(value: float) -> void:
var settings := _game_settings_or_null()
if settings != null:
settings.call("set_sfx_volume", int(value))
_sfx_value_label.text = str(int(value))
)
_sfx_slider.drag_ended.connect(func(_changed: bool) -> void: _play_ui_sound())
box.add_child(sfx_row[0])
box.add_child(_make_spacer(16))
box.add_child(_make_menu_button("返回", func() -> void: _set_state(STATE_PAUSED)))
return root
func _build_confirm_screen(screen_state: StringName, question: String, on_confirm: Callable, on_cancel: Callable) -> Control:
var root := _make_screen_root("%sScreen" % screen_state, OVERLAY_BG_COLOR)
_make_panel(root, "ConfirmPanel", Rect2(425.0, 194.0, 302.0, 260.0))
var prompt := _make_title_label(question, 20, Color(0.92, 0.93, 1.0))
prompt.name = "ConfirmPrompt"
prompt.position = Vector2(455.0, 224.0)
prompt.size = Vector2(242.0, 64.0)
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
root.add_child(prompt)
_make_art_button(root, "ConfirmButton", Rect2(467.0, 300.0, 218.0, 56.0), on_confirm, "res://assets/ui/menu2/text_confirm.png", Vector2(88.0, 44.0))
_make_art_button(root, "CancelButton", Rect2(467.0, 366.0, 218.0, 56.0), on_cancel, "res://assets/ui/menu2/text_cancel.png", Vector2(85.0, 43.0))
return root
func _build_end_splash(splash_state: StringName, banner_texture_path: String) -> Control:
var root := _make_screen_root("%sScreen" % splash_state, OVERLAY_BG_COLOR)
_make_panel(root, "SplashPanel", Rect2(425.0, 186.0, 302.0, 276.0))
# 胜利/失败字样按 2 倍整数放大,配 NEAREST 过滤保持像素锐利。
var banner := _add_menu_texture(root, "SplashBanner", banner_texture_path, Vector2.ZERO, Vector2.ZERO)
if banner.texture != null:
var tex_size: Vector2 = banner.texture.get_size() * 2.0
banner.size = tex_size
banner.position = Vector2(425.0 + (302.0 - tex_size.x) * 0.5, 224.0)
banner.stretch_mode = TextureRect.STRETCH_SCALE
banner.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_make_art_button(root, "ShowResultButton", Rect2(467.0, 356.0, 218.0, 56.0), func() -> void: _set_state(STATE_RESULT), "", Vector2.ZERO, "查看结算")
return root
var _result_labels: Dictionary = {}
func _build_result_screen() -> Control:
var root := _make_screen_root("ResultScreen", TITLE_BG_COLOR)
_add_menu_art(root, PANEL_TALL, Vector2(425.0, 31.0))
_add_menu_texture(root, "ResultTitle", "res://assets/ui/menu2/text_result_title.png", Vector2(503.0, 62.0), Vector2(145.0, 38.0))
var row_y := 130.0
for entry: Array in [
["max_combo", "最大连击"],
["perfect", "完美"],
["good", "良好"],
["bad", "勉强"],
["miss", "失误"],
["anchor_success", "锚点成功"],
["phase_shift_count", "相位切换"],
]:
var name_label := _make_title_label(str(entry[1]), 18, Color(0.78, 0.8, 0.92))
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
name_label.position = Vector2(465.0, row_y)
name_label.size = Vector2(140.0, 26.0)
root.add_child(name_label)
var value_label := _make_title_label("0", 18, Color(1, 1, 1))
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
value_label.position = Vector2(560.0, row_y)
value_label.size = Vector2(127.0, 26.0)
root.add_child(value_label)
_result_labels[entry[0]] = value_label
row_y += 36.0
var rank_label := _make_title_label("RANK -", 40, FUTURE_ACCENT_COLOR)
rank_label.name = "RankLabel"
rank_label.position = Vector2(465.0, 396.0)
rank_label.size = Vector2(222.0, 56.0)
_result_labels["rank"] = rank_label
root.add_child(rank_label)
_make_art_button(root, "ReturnTitleButton", Rect2(467.0, 486.0, 218.0, 56.0), func() -> void: return_to_title(), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0))
return root
func _populate_result_screen() -> void:
var stats := _game_stats_or_null()
if stats == null:
return
var summary: Dictionary = stats.call("summary")
for key: String in ["max_combo", "perfect", "good", "bad", "miss", "anchor_success", "phase_shift_count"]:
if _result_labels.has(key):
(_result_labels[key] as Label).text = str(int(summary.get(key, 0)))
var rank_label := _result_labels.get("rank", null) as Label
if rank_label == null:
return
if _victory:
# Rank only on victory (§19.6); defeat shows stats without a rank.
# 2026-07-05 策划定案:rank 后只保留等级,不再附带分数。
rank_label.visible = true
rank_label.text = "RANK %s" % str(stats.call("current_rank"))
else:
rank_label.visible = false
func _sync_volume_widgets() -> void:
var settings := _game_settings_or_null()
if settings == null or _music_slider == null:
return
_music_slider.set_value_no_signal(float(int(settings.get("music_volume"))))
_sfx_slider.set_value_no_signal(float(int(settings.get("sfx_volume"))))
_music_value_label.text = str(int(settings.get("music_volume")))
_sfx_value_label.text = str(int(settings.get("sfx_volume")))
func _sync_difficulty_buttons() -> void:
if _pending_difficulty.is_empty():
_pending_difficulty = _current_difficulty()
for key: Variant in _difficulty_buttons.keys():
var button := _difficulty_buttons[key] as Button
if button == null:
continue
var selected := StringName(str(key)) == _pending_difficulty
button.set_pressed_no_signal(selected)
# 选中项换成高亮橙框底,未选中项回普通蓝框底并略压暗。
button.add_theme_stylebox_override("normal", _base_stylebox(selected))
button.modulate = Color(1.0, 1.0, 1.0, 1.0) if selected else Color(0.8, 0.8, 0.86, 1.0)
func _select_difficulty(value: StringName) -> void:
if not DIFFICULTY_ORDER.has(value):
return
_pending_difficulty = value
var settings := _game_settings_or_null()
if settings != null:
settings.call("set_difficulty", _pending_difficulty)
_sync_difficulty_buttons()
_sync_difficulty_footers()
func _sync_difficulty_footers() -> void:
var display: String = DIFFICULTY_NAMES.get(_current_difficulty(), "普通")
if _title_difficulty_value != null:
_title_difficulty_value.text = display
if _difficulty_footer_value != null:
_difficulty_footer_value.text = "当前难度:%s" % display
func _current_difficulty() -> StringName:
var settings := _game_settings_or_null()
if settings == null:
return &"normal"
return StringName(str(settings.get("difficulty")))
## --------------------------------------------------------------- UI helpers
func _make_screen_root(node_name: String, bg_color: Color) -> Control:
var root := Control.new()
root.name = node_name
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.visible = false
root.mouse_filter = Control.MOUSE_FILTER_STOP
var background := ColorRect.new()
background.name = "Background"
background.color = bg_color
background.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_child(background)
_ui_layer.add_child(root)
return root
## Backgrounds must be added right after _make_screen_root so they stack above
## the ColorRect and below the menu art, in call order.
func _add_art_background(root: Control, texture_path: String, art_name := "SceneBackground") -> TextureRect:
var art := TextureRect.new()
art.name = art_name
art.texture = load(texture_path)
art.set_anchors_preset(Control.PRESET_FULL_RECT)
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
art.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(art)
return art
func _add_menu_art(root: Control, texture_path: String, position: Vector2) -> TextureRect:
var art := TextureRect.new()
art.name = "MenuArt"
art.texture = load(texture_path)
art.position = position
if art.texture != null:
art.size = art.texture.get_size()
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_KEEP
art.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(art)
return art
func _add_menu_texture(root: Control, node_name: String, texture_path: String, position: Vector2, size: Vector2) -> TextureRect:
var art := TextureRect.new()
art.name = node_name
art.texture = load(texture_path)
# expand/stretch 必须先于 size 赋值:默认 EXPAND_KEEP_SIZE 下最小尺寸
# 等于贴图原生尺寸,会把传入的 size 钳回原生值(Logo 缩放曾因此失效)。
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_SCALE
art.position = position
art.size = size
art.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(art)
return art
func _make_panel(root: Control, node_name: String, rect: Rect2) -> NinePatchRect:
var panel := NinePatchRect.new()
panel.name = node_name
panel.texture = load(PANEL_TALL)
panel.position = rect.position
panel.size = rect.size
panel.patch_margin_left = 26
panel.patch_margin_top = 26
panel.patch_margin_right = 26
panel.patch_margin_bottom = 26
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(panel)
return panel
func _base_stylebox(highlight: bool) -> StyleBoxTexture:
var style := StyleBoxTexture.new()
style.texture = load(BUTTON_BASE_HOVER if highlight else BUTTON_BASE_NORMAL)
return style
## 组件按钮:按钮底两态贴图 + 居中的文字贴图(或字体文字)。保持 Button 类型,
## 让既有的坐标点击测试与 is Button 断言继续成立。
func _make_art_button(root: Control, node_name: String, rect: Rect2, on_pressed: Callable, text_texture_path := "", text_size := Vector2.ZERO, label_text := "") -> Button:
var button := Button.new()
button.name = node_name
button.text = ""
# Mouse-only menus: a focused button would swallow Space/Enter, and Space
# is a core combat key — menu buttons must never eat it mid-fight.
button.focus_mode = Control.FOCUS_NONE
button.position = rect.position
button.size = rect.size
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.add_theme_stylebox_override("normal", _base_stylebox(false))
var hover := _base_stylebox(true)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", _base_stylebox(true))
button.add_theme_stylebox_override("focus", _base_stylebox(true))
if not text_texture_path.is_empty():
var text_art := TextureRect.new()
text_art.name = "ButtonText"
text_art.texture = load(text_texture_path)
text_art.position = (rect.size - text_size) * 0.5
text_art.size = text_size
text_art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
text_art.stretch_mode = TextureRect.STRETCH_KEEP
text_art.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(text_art)
elif not label_text.is_empty():
var label := _make_title_label(label_text, 24, ACCENT_COLOR)
label.name = "ButtonLabel"
label.set_anchors_preset(Control.PRESET_FULL_RECT)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(label)
button.pressed.connect(func() -> void:
_play_ui_sound()
on_pressed.call()
)
root.add_child(button)
return button
func _make_difficulty_option(root: Control, node_name: String, rect: Rect2, difficulty: StringName, text_texture_path: String, text_size: Vector2) -> Button:
var button := _make_art_button(root, node_name, rect, func() -> void: _select_difficulty(difficulty), text_texture_path, text_size)
button.toggle_mode = true
return button
func _make_center_menu(root: Control) -> VBoxContainer:
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_child(center)
var panel := PanelContainer.new()
var style := StyleBoxFlat.new()
style.bg_color = Color(0.08, 0.08, 0.14, 0.92)
style.border_color = ACCENT_COLOR * Color(1, 1, 1, 0.65)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.content_margin_left = 42.0
style.content_margin_right = 42.0
style.content_margin_top = 30.0
style.content_margin_bottom = 30.0
panel.add_theme_stylebox_override("panel", style)
center.add_child(panel)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 10)
panel.add_child(box)
return box
func _make_title_label(text: String, font_size: int, color: Color) -> Label:
var label := Label.new()
label.text = text
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
if _menu_font != null:
label.add_theme_font_override("font", _menu_font)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
label.add_theme_constant_override("shadow_offset_x", 2)
label.add_theme_constant_override("shadow_offset_y", 2)
return label
func _make_menu_button(text: String, on_pressed: Callable) -> Button:
var button := _make_button(text, 22)
button.custom_minimum_size = Vector2(320, 46)
button.pressed.connect(func() -> void:
_play_ui_sound()
on_pressed.call()
)
return button
func _make_button(text: String, font_size: int) -> Button:
var button := Button.new()
button.text = text
# Mouse-only menus: a focused button would swallow Space/Enter, and Space
# is a core combat key — the pause button must never eat it mid-fight.
button.focus_mode = Control.FOCUS_NONE
if _menu_font != null:
button.add_theme_font_override("font", _menu_font)
button.add_theme_font_size_override("font_size", font_size)
var normal := StyleBoxFlat.new()
normal.bg_color = Color(0.13, 0.13, 0.22, 0.95)
normal.border_color = Color(0.5, 0.45, 0.7, 0.8)
normal.border_width_left = 1
normal.border_width_top = 1
normal.border_width_right = 1
normal.border_width_bottom = 1
normal.corner_radius_top_left = 6
normal.corner_radius_top_right = 6
normal.corner_radius_bottom_left = 6
normal.corner_radius_bottom_right = 6
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color(0.2, 0.19, 0.32, 0.98)
hover.border_color = ACCENT_COLOR
var pressed := normal.duplicate() as StyleBoxFlat
pressed.bg_color = Color(0.32, 0.27, 0.16, 1.0)
pressed.border_color = ACCENT_COLOR
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_stylebox_override("focus", hover)
return button
func _make_slider_row(label_text: String) -> Array:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(380, 0)
row.add_theme_constant_override("separation", 12)
var label := _make_title_label(label_text, 18, Color(0.85, 0.86, 0.96))
label.custom_minimum_size = Vector2(96, 0)
row.add_child(label)
var slider := HSlider.new()
slider.focus_mode = Control.FOCUS_NONE
slider.min_value = 0
slider.max_value = 100
slider.step = 1
slider.custom_minimum_size = Vector2(200, 24)
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(slider)
var value_label := _make_title_label("0", 18, Color(1, 1, 1))
value_label.custom_minimum_size = Vector2(42, 0)
row.add_child(value_label)
return [row, slider, value_label]
func _make_spacer(height: float) -> Control:
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, height)
return spacer