diff --git a/godot/scripts/ui/chat_window.gd b/godot/scripts/ui/chat_window.gd new file mode 100644 index 0000000..952155b --- /dev/null +++ b/godot/scripts/ui/chat_window.gd @@ -0,0 +1,462 @@ +extends Window +## Miku-themed chat window for text-based discussions. +## Displays voice transcripts and LLM responses; accepts typed input. + +const EMOTION_COLORS: Dictionary = { + "happy": Color("#4ECDC4"), + "sad": Color("#7B9EBD"), + "angry": Color("#D45F5F"), + "surprised": Color("#00E5FF"), + "relaxed": Color("#80CBC4"), + "neutral": Color("#39C5BB"), +} + +const STATE_LABELS: Dictionary = { + "idle": "", + "listening": "listening...", + "processing": "thinking...", + "speaking": "speaking", + "interrupted": "", +} + +const BG_DARK := Color("#0D1117") +const BG_PANEL := Color("#111822") +const MIKU_TEAL := Color("#39C5BB") +const USER_BUBBLE_COLOR := Color("#1E2D45") +const MIKU_BUBBLE_COLOR := Color("#0D2F2D") +const TEXT_PRIMARY := Color("#E8F4F3") +const TEXT_MUTED := Color("#6B8E8B") +const BORDER_COLOR := Color("#1A3330") + +var _message_list: VBoxContainer +var _scroll: ScrollContainer +var _status_label: Label +var _input: LineEdit +var _current_miku_label: RichTextLabel +var _current_miku_panel: PanelContainer +var _drag_start: Vector2i +var _dragging: bool = false + + +func setup() -> void: + force_native = true + EventBus.transcript_ready.connect(_on_transcript_ready) + EventBus.text_submitted.connect(_on_text_submitted_display) + EventBus.sentence_ready.connect(_on_sentence_ready) + EventBus.state_changed.connect(_on_state_changed) + EventBus.emotion_changed.connect(_on_emotion_changed) + close_requested.connect(hide) + + +func _ready() -> void: + title = "" + borderless = true + always_on_top = true + min_size = Vector2i(340, 480) + size = Vector2i(380, 560) + _build_ui() + _position_beside_companion() + + +func _position_beside_companion() -> void: + var companion_pos := DisplayServer.window_get_position(0) + var companion_size := DisplayServer.window_get_size(0) + var screen_idx := DisplayServer.get_primary_screen() + var screen_rect := DisplayServer.screen_get_usable_rect(screen_idx) + + var chat_width := 380 + var chat_height := 560 + var gap := 8 + + # Calculate available space on right and left + var space_right := screen_rect.position.x + screen_rect.size.x - (companion_pos.x + companion_size.x + gap) + var space_left := companion_pos.x - screen_rect.position.x - gap + + var x: int + if space_right >= chat_width: + # Enough space on right + x = companion_pos.x + companion_size.x + gap + elif space_left >= chat_width: + # Enough space on left + x = companion_pos.x - chat_width - gap + else: + # No ideal placement, prefer right and clamp + x = companion_pos.x + companion_size.x + gap + x = clampi(x, screen_rect.position.x, screen_rect.position.x + screen_rect.size.x - chat_width) + + # Vertical: align bottom of chat with bottom of companion + var y := companion_pos.y + companion_size.y - chat_height + y = clampi(y, screen_rect.position.y, screen_rect.position.y + screen_rect.size.y - chat_height) + + position = Vector2i(x, y) + + +func _build_ui() -> void: + var bg := PanelContainer.new() + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var bg_style := StyleBoxFlat.new() + bg_style.bg_color = BG_DARK + bg_style.set_border_width_all(1) + bg_style.border_color = BORDER_COLOR + bg_style.set_corner_radius_all(10) + bg.add_theme_stylebox_override("panel", bg_style) + add_child(bg) + + var root := VBoxContainer.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_theme_constant_override("separation", 0) + bg.add_child(root) + + root.add_child(_build_title_bar()) + root.add_child(_build_divider()) + + _scroll = ScrollContainer.new() + _scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + root.add_child(_scroll) + + var list_margin := MarginContainer.new() + list_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + list_margin.add_theme_constant_override("margin_left", 12) + list_margin.add_theme_constant_override("margin_right", 12) + list_margin.add_theme_constant_override("margin_top", 12) + list_margin.add_theme_constant_override("margin_bottom", 12) + _scroll.add_child(list_margin) + + _message_list = VBoxContainer.new() + _message_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _message_list.add_theme_constant_override("separation", 10) + list_margin.add_child(_message_list) + + root.add_child(_build_divider()) + root.add_child(_build_input_bar()) + + +func _build_title_bar() -> Control: + var bar := PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = BG_PANEL + style.corner_radius_top_left = 10 + style.corner_radius_top_right = 10 + bar.add_theme_stylebox_override("panel", style) + bar.custom_minimum_size.y = 40 + bar.gui_input.connect(_on_titlebar_input) + + var margin := MarginContainer.new() + margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + margin.mouse_filter = Control.MOUSE_FILTER_PASS + margin.add_theme_constant_override("margin_left", 14) + margin.add_theme_constant_override("margin_right", 8) + margin.add_theme_constant_override("margin_top", 0) + margin.add_theme_constant_override("margin_bottom", 0) + bar.add_child(margin) + + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hbox.mouse_filter = Control.MOUSE_FILTER_PASS + margin.add_child(hbox) + + var title_lbl := Label.new() + title_lbl.text = "✦ MIKU" + title_lbl.add_theme_color_override("font_color", MIKU_TEAL) + title_lbl.add_theme_font_size_override("font_size", 13) + hbox.add_child(title_lbl) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hbox.add_child(spacer) + + _status_label = Label.new() + _status_label.text = "" + _status_label.add_theme_color_override("font_color", TEXT_MUTED) + _status_label.add_theme_font_size_override("font_size", 11) + hbox.add_child(_status_label) + + var close_btn := Button.new() + close_btn.text = "×" + close_btn.flat = true + close_btn.custom_minimum_size = Vector2(32, 32) + close_btn.add_theme_color_override("font_color", TEXT_MUTED) + close_btn.add_theme_color_override("font_hover_color", TEXT_PRIMARY) + close_btn.add_theme_font_size_override("font_size", 18) + close_btn.pressed.connect(hide) + hbox.add_child(close_btn) + + return bar + + +func _build_divider() -> Control: + var line := ColorRect.new() + line.color = BORDER_COLOR + line.custom_minimum_size.y = 1 + line.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return line + + +func _build_input_bar() -> Control: + var bar := PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = BG_PANEL + style.corner_radius_bottom_left = 10 + style.corner_radius_bottom_right = 10 + bar.add_theme_stylebox_override("panel", style) + bar.custom_minimum_size.y = 52 + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 12) + margin.add_theme_constant_override("margin_right", 12) + margin.add_theme_constant_override("margin_top", 8) + margin.add_theme_constant_override("margin_bottom", 8) + bar.add_child(margin) + + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + margin.add_child(hbox) + + _input = LineEdit.new() + _input.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _input.placeholder_text = "Type a message..." + _input.add_theme_color_override("font_color", TEXT_PRIMARY) + _input.add_theme_color_override("font_placeholder_color", TEXT_MUTED) + _input.add_theme_color_override("caret_color", MIKU_TEAL) + + var input_normal := StyleBoxFlat.new() + input_normal.bg_color = Color("#0A1628") + input_normal.set_border_width_all(1) + input_normal.border_color = BORDER_COLOR + input_normal.set_corner_radius_all(6) + input_normal.content_margin_left = 10 + input_normal.content_margin_right = 10 + input_normal.content_margin_top = 6 + input_normal.content_margin_bottom = 6 + _input.add_theme_stylebox_override("normal", input_normal) + + var input_focus := input_normal.duplicate() as StyleBoxFlat + input_focus.border_color = MIKU_TEAL + _input.add_theme_stylebox_override("focus", input_focus) + _input.text_submitted.connect(_on_input_text_submitted) + hbox.add_child(_input) + + var send_btn := Button.new() + send_btn.text = "→" + send_btn.custom_minimum_size = Vector2(40, 36) + send_btn.add_theme_color_override("font_color", BG_DARK) + send_btn.add_theme_font_size_override("font_size", 18) + + var btn_normal := StyleBoxFlat.new() + btn_normal.bg_color = MIKU_TEAL + btn_normal.set_corner_radius_all(6) + send_btn.add_theme_stylebox_override("normal", btn_normal) + + var btn_hover := btn_normal.duplicate() as StyleBoxFlat + btn_hover.bg_color = Color("#4ECDC4") + send_btn.add_theme_stylebox_override("hover", btn_hover) + + var btn_pressed := btn_normal.duplicate() as StyleBoxFlat + btn_pressed.bg_color = Color("#2BA8A0") + send_btn.add_theme_stylebox_override("pressed", btn_pressed) + + send_btn.pressed.connect(_on_send_pressed) + hbox.add_child(send_btn) + + return bar + + +func _add_user_bubble(text: String) -> void: + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 0) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(spacer) + + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_SHRINK_END + + var style := StyleBoxFlat.new() + style.bg_color = USER_BUBBLE_COLOR + style.set_corner_radius_all(8) + style.content_margin_left = 12 + style.content_margin_right = 12 + style.content_margin_top = 8 + style.content_margin_bottom = 8 + panel.add_theme_stylebox_override("panel", style) + + var label := RichTextLabel.new() + label.bbcode_enabled = false + label.fit_content = true + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.custom_minimum_size.x = 40 + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.add_theme_color_override("default_color", TEXT_PRIMARY) + label.add_theme_font_size_override("normal_font_size", 13) + label.selection_enabled = true + label.text = text + panel.add_child(label) + + row.add_child(panel) + _message_list.add_child(row) + _scroll_to_bottom() + + +func _add_miku_bubble(emotion: String) -> void: + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 0) + + _current_miku_panel = PanelContainer.new() + _current_miku_panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + + var style := StyleBoxFlat.new() + style.bg_color = MIKU_BUBBLE_COLOR + style.set_corner_radius_all(8) + style.content_margin_left = 12 + style.content_margin_right = 12 + style.content_margin_top = 8 + style.content_margin_bottom = 8 + style.border_width_left = 3 + style.border_color = EMOTION_COLORS.get(emotion, MIKU_TEAL) + _current_miku_panel.add_theme_stylebox_override("panel", style) + + _current_miku_label = RichTextLabel.new() + _current_miku_label.bbcode_enabled = false + _current_miku_label.fit_content = true + _current_miku_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _current_miku_label.custom_minimum_size.x = 40 + _current_miku_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _current_miku_label.add_theme_color_override("default_color", TEXT_PRIMARY) + _current_miku_label.add_theme_font_size_override("normal_font_size", 13) + _current_miku_label.selection_enabled = true + _current_miku_panel.add_child(_current_miku_label) + + row.add_child(_current_miku_panel) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(spacer) + + _message_list.add_child(row) + _scroll_to_bottom() + + +func _update_miku_accent(emotion: String) -> void: + if _current_miku_panel == null: + return + var style := _current_miku_panel.get_theme_stylebox("panel") as StyleBoxFlat + if style == null: + return + style.border_color = EMOTION_COLORS.get(emotion, MIKU_TEAL) + + +func _scroll_to_bottom() -> void: + call_deferred("_do_scroll") + + +func _do_scroll() -> void: + _scroll.scroll_vertical = _scroll.get_v_scroll_bar().max_value + + +func _on_titlebar_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT: + _dragging = mb.pressed + if _dragging: + _drag_start = DisplayServer.mouse_get_position() - position + elif event is InputEventMouseMotion and _dragging: + position = DisplayServer.mouse_get_position() - _drag_start + + +func _on_input_text_submitted(text: String) -> void: + text = text.strip_edges() + if text.is_empty(): + return + _input.text = "" + EventBus.text_submitted.emit(text) + + +func _on_send_pressed() -> void: + _on_input_text_submitted(_input.text) + + +func _on_transcript_ready(text: String) -> void: + _add_user_bubble(text) + _current_miku_label = null + _current_miku_panel = null + + +func _on_text_submitted_display(text: String) -> void: + _add_user_bubble(text) + _current_miku_label = null + _current_miku_panel = null + + +func _on_sentence_ready(text: String, emotion: String) -> void: + if _current_miku_label == null: + _add_miku_bubble(emotion) + else: + var trimmed := text.strip_edges() + if not trimmed.is_empty(): + _current_miku_label.text += " " + trimmed + _scroll_to_bottom() + + +func _on_state_changed(_from: String, to: String) -> void: + if _status_label != null: + _status_label.text = STATE_LABELS.get(to, "") + if to == "idle": + _current_miku_label = null + _current_miku_panel = null + + +func _on_emotion_changed(emotion: String) -> void: + _update_miku_accent(emotion) + + +func show_error(message: String) -> void: + _add_error_bubble(message) + + +func _add_error_bubble(message: String) -> void: + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 0) + + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + + var style := StyleBoxFlat.new() + style.bg_color = Color("#3D1F1F") + style.set_corner_radius_all(8) + style.content_margin_left = 12 + style.content_margin_right = 12 + style.content_margin_top = 8 + style.content_margin_bottom = 8 + style.border_width_left = 3 + style.border_color = Color("#D45F5F") + panel.add_theme_stylebox_override("panel", style) + + var label := RichTextLabel.new() + label.bbcode_enabled = false + label.fit_content = true + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.custom_minimum_size.x = 40 + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.add_theme_color_override("default_color", Color("#FF9999")) + label.add_theme_font_size_override("normal_font_size", 13) + label.selection_enabled = true + label.text = "⚠ " + message + panel.add_child(label) + + row.add_child(panel) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(spacer) + + _message_list.add_child(row) + _scroll_to_bottom() + _current_miku_label = null + _current_miku_panel = null diff --git a/godot/scripts/ui/chat_window.gd.uid b/godot/scripts/ui/chat_window.gd.uid new file mode 100644 index 0000000..41d05ad --- /dev/null +++ b/godot/scripts/ui/chat_window.gd.uid @@ -0,0 +1 @@ +uid://5fo765xukpeo diff --git a/godot/scripts/ui/context_menu.gd b/godot/scripts/ui/context_menu.gd new file mode 100644 index 0000000..fc4cecc --- /dev/null +++ b/godot/scripts/ui/context_menu.gd @@ -0,0 +1,78 @@ +extends PopupMenu +## Right-click context menu for the companion avatar. +## Appears at cursor position with Miku dark theme styling. + +const BG_DARK := Color("#0D1117") +const BG_PANEL := Color("#111822") +const MIKU_TEAL := Color("#39C5BB") +const BORDER_COLOR := Color("#1A3330") +const TEXT_PRIMARY := Color("#E8F4F3") +const TEXT_MUTED := Color("#6B8E8B") + +enum Item { + SOUND_SETTINGS = 0, + CHAT = 1, + QUIT = 3, +} + + +func _ready() -> void: + borderless = true + always_on_top = true + _build_menu() + _apply_theme() + id_pressed.connect(_on_item_pressed) + + +func _build_menu() -> void: + add_item("Sound Settings", Item.SOUND_SETTINGS) + add_item("Chat", Item.CHAT) + add_separator() + add_item("Quit", Item.QUIT) + + +func _apply_theme() -> void: + var panel := StyleBoxFlat.new() + panel.bg_color = BG_DARK + panel.set_border_width_all(1) + panel.border_color = BORDER_COLOR + panel.set_corner_radius_all(8) + panel.content_margin_left = 4 + panel.content_margin_right = 4 + panel.content_margin_top = 4 + panel.content_margin_bottom = 4 + add_theme_stylebox_override("panel", panel) + + var hover := StyleBoxFlat.new() + hover.bg_color = BG_PANEL + hover.set_corner_radius_all(5) + hover.content_margin_left = 8 + hover.content_margin_right = 8 + hover.content_margin_top = 4 + hover.content_margin_bottom = 4 + add_theme_stylebox_override("hover", hover) + + add_theme_color_override("font_color", TEXT_PRIMARY) + add_theme_color_override("font_hover_color", MIKU_TEAL) + add_theme_color_override("font_separator_color", BORDER_COLOR) + add_theme_font_size_override("font_size", 13) + + var separator := StyleBoxFlat.new() + separator.bg_color = BORDER_COLOR + separator.content_margin_top = 1 + separator.content_margin_bottom = 1 + add_theme_stylebox_override("separator", separator) + + +func show_at(pos: Vector2i) -> void: + popup(Rect2i(pos, Vector2i.ZERO)) + + +func _on_item_pressed(id: int) -> void: + match id: + Item.SOUND_SETTINGS: + EventBus.sound_settings_opened.emit() + Item.CHAT: + EventBus.chat_opened.emit() + Item.QUIT: + get_tree().quit() diff --git a/godot/scripts/ui/sound_settings_window.gd b/godot/scripts/ui/sound_settings_window.gd new file mode 100644 index 0000000..3234d73 --- /dev/null +++ b/godot/scripts/ui/sound_settings_window.gd @@ -0,0 +1,269 @@ +extends Window +## Sound settings panel — maps companion events to sounds. +## Miku dark theme, built entirely in GDScript. + +const BG_DARK := Color("#0D1117") +const BG_PANEL := Color("#111822") +const MIKU_TEAL := Color("#39C5BB") +const BORDER_COLOR := Color("#1A3330") +const TEXT_PRIMARY := Color("#E8F4F3") +const TEXT_MUTED := Color("#6B8E8B") + +var _sound_config: Node +var _sound_engine: Node + +var _drag_start: Vector2i +var _dragging: bool = false + +# Maps slot key → OptionButton, so we can read current selection for ▶ +var _option_buttons: Dictionary = {} + + +func setup(sound_config: Node, sound_engine: Node) -> void: + _sound_config = sound_config + _sound_engine = sound_engine + close_requested.connect(hide) + + +func _ready() -> void: + title = "" + borderless = true + always_on_top = true + force_native = true + min_size = Vector2i(420, 300) + size = Vector2i(460, 380) + _build_ui() + _position_beside_companion() + + +func _position_beside_companion() -> void: + var companion_pos := DisplayServer.window_get_position(0) + var companion_size := DisplayServer.window_get_size(0) + var screen_idx := DisplayServer.get_primary_screen() + var screen_rect := DisplayServer.screen_get_usable_rect(screen_idx) + + var x := companion_pos.x + companion_size.x + 8 + var y := companion_pos.y + x = clampi(x, screen_rect.position.x, screen_rect.position.x + screen_rect.size.x - 460) + y = clampi(y, screen_rect.position.y, screen_rect.position.y + screen_rect.size.y - 380) + position = Vector2i(x, y) + + +func _build_ui() -> void: + var bg := PanelContainer.new() + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var bg_style := StyleBoxFlat.new() + bg_style.bg_color = BG_DARK + bg_style.set_border_width_all(1) + bg_style.border_color = BORDER_COLOR + bg_style.set_corner_radius_all(10) + bg.add_theme_stylebox_override("panel", bg_style) + add_child(bg) + + var root := VBoxContainer.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_theme_constant_override("separation", 0) + bg.add_child(root) + + root.add_child(_build_title_bar()) + root.add_child(_build_divider()) + root.add_child(_build_slot_list()) + + +func _build_title_bar() -> Control: + var bar := PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = BG_PANEL + style.corner_radius_top_left = 10 + style.corner_radius_top_right = 10 + bar.add_theme_stylebox_override("panel", style) + bar.custom_minimum_size.y = 40 + bar.gui_input.connect(_on_titlebar_input) + + var margin := MarginContainer.new() + margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + margin.add_theme_constant_override("margin_left", 14) + margin.add_theme_constant_override("margin_right", 8) + margin.add_theme_constant_override("margin_top", 0) + margin.add_theme_constant_override("margin_bottom", 0) + bar.add_child(margin) + + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + margin.add_child(hbox) + + var title_lbl := Label.new() + title_lbl.text = "✦ SOUND SETTINGS" + title_lbl.add_theme_color_override("font_color", MIKU_TEAL) + title_lbl.add_theme_font_size_override("font_size", 13) + hbox.add_child(title_lbl) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hbox.add_child(spacer) + + var close_btn := Button.new() + close_btn.text = "×" + close_btn.flat = true + close_btn.custom_minimum_size = Vector2(32, 32) + close_btn.add_theme_color_override("font_color", TEXT_MUTED) + close_btn.add_theme_color_override("font_hover_color", TEXT_PRIMARY) + close_btn.add_theme_font_size_override("font_size", 18) + close_btn.pressed.connect(hide) + hbox.add_child(close_btn) + + return bar + + +func _build_divider() -> Control: + var line := ColorRect.new() + line.color = BORDER_COLOR + line.custom_minimum_size.y = 1 + line.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return line + + +func _build_slot_list() -> Control: + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + + var outer := MarginContainer.new() + outer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + outer.add_theme_constant_override("margin_left", 12) + outer.add_theme_constant_override("margin_right", 12) + outer.add_theme_constant_override("margin_top", 12) + outer.add_theme_constant_override("margin_bottom", 12) + scroll.add_child(outer) + + var vbox := VBoxContainer.new() + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.add_theme_constant_override("separation", 8) + outer.add_child(vbox) + + # Guard: if SoundConfig not yet wired (e.g., opened before setup() called) + if _sound_config == null: + var warn := Label.new() + warn.text = "SoundConfig not available." + warn.add_theme_color_override("font_color", TEXT_MUTED) + vbox.add_child(warn) + return scroll + + var slots: Dictionary = _sound_config.get_slots() + var sound_names: Array[String] = [] + if _sound_engine != null: + sound_names = _sound_engine.get_sound_names() + + for slot_key: String in slots.keys(): + vbox.add_child(_build_row(slot_key, slots[slot_key], sound_names)) + + return scroll + + +func _build_row(slot_key: String, label_text: String, sound_names: Array[String]) -> Control: + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 8) + + # Label + var lbl := Label.new() + lbl.text = label_text + lbl.custom_minimum_size.x = 140 + lbl.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + lbl.add_theme_color_override("font_color", TEXT_PRIMARY) + lbl.add_theme_font_size_override("font_size", 13) + row.add_child(lbl) + + # OptionButton + var opt := OptionButton.new() + opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _style_option_button(opt) + + # Populate choices: "(none)" first, then all sounds + opt.add_item("(none)", 0) + for i: int in range(sound_names.size()): + opt.add_item(sound_names[i], i + 1) + + # Select current value + var current: String = _sound_config.get_sound(slot_key) + if current.is_empty(): + opt.select(0) + else: + var idx: int = sound_names.find(current) + opt.select(idx + 1 if idx >= 0 else 0) + + # Capture slot_key in closure via a bound callable + opt.item_selected.connect(_on_option_changed.bind(slot_key, opt, sound_names)) + row.add_child(opt) + _option_buttons[slot_key] = opt + + # Play button + var play_btn := Button.new() + play_btn.text = "▶" + play_btn.flat = false + play_btn.custom_minimum_size = Vector2(36, 30) + play_btn.add_theme_color_override("font_color", BG_DARK) + play_btn.add_theme_font_size_override("font_size", 13) + _style_play_button(play_btn) + play_btn.pressed.connect(_on_play_pressed.bind(slot_key, opt, sound_names)) + row.add_child(play_btn) + + return row + + +func _on_option_changed(_index: int, slot_key: String, opt: OptionButton, sound_names: Array[String]) -> void: + var selected_idx: int = opt.selected + var sound: String = "" if selected_idx == 0 else sound_names[selected_idx - 1] + if _sound_config != null: + _sound_config.set_sound(slot_key, sound) + + +func _on_play_pressed(slot_key: String, opt: OptionButton, sound_names: Array[String]) -> void: + var selected_idx: int = opt.selected + if selected_idx == 0 or _sound_engine == null: + return + var sound: String = sound_names[selected_idx - 1] + _sound_engine.play_sound(sound) + + +func _on_titlebar_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT: + _dragging = mb.pressed + if _dragging: + _drag_start = DisplayServer.mouse_get_position() - position + elif event is InputEventMouseMotion and _dragging: + position = DisplayServer.mouse_get_position() - _drag_start + + +func _style_option_button(opt: OptionButton) -> void: + var style := StyleBoxFlat.new() + style.bg_color = Color("#0A1628") + style.set_border_width_all(1) + style.border_color = BORDER_COLOR + style.set_corner_radius_all(5) + style.content_margin_left = 8 + style.content_margin_right = 8 + style.content_margin_top = 4 + style.content_margin_bottom = 4 + opt.add_theme_stylebox_override("normal", style) + opt.add_theme_color_override("font_color", TEXT_PRIMARY) + var hover := style.duplicate() as StyleBoxFlat + hover.border_color = MIKU_TEAL + opt.add_theme_stylebox_override("hover", hover) + opt.add_theme_stylebox_override("focus", hover) + + +func _style_play_button(btn: Button) -> void: + var normal := StyleBoxFlat.new() + normal.bg_color = MIKU_TEAL + normal.set_corner_radius_all(5) + btn.add_theme_stylebox_override("normal", normal) + var hover := normal.duplicate() as StyleBoxFlat + hover.bg_color = Color("#4ECDC4") + btn.add_theme_stylebox_override("hover", hover) + var pressed := normal.duplicate() as StyleBoxFlat + pressed.bg_color = Color("#2BA8A0") + btn.add_theme_stylebox_override("pressed", pressed) diff --git a/godot/scripts/ui/sound_settings_window.gd.uid b/godot/scripts/ui/sound_settings_window.gd.uid new file mode 100644 index 0000000..dcc5f97 --- /dev/null +++ b/godot/scripts/ui/sound_settings_window.gd.uid @@ -0,0 +1 @@ +uid://bcasxf0b3usgv