class_name MallPanel extends Control # ============================================================================ # MallPanel.gd - 鲸鱼商城页面 # ============================================================================ # 面板式商城 UI:分类、商品槽位、详情、购买确认和提示反馈。 # ============================================================================ const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"] const PANEL_SIZE: Vector2 = Vector2(1640, 1160) const MIN_MARGIN: Vector2 = Vector2(24, 24) const TEXT_COLOR: Color = Color(0.102, 0.224, 0.376) const MUTED_COLOR: Color = Color(0.480, 0.569, 0.678) const ACCENT_COLOR: Color = Color(0.086, 0.608, 0.922) const WARNING_COLOR: Color = Color(0.906, 0.392, 0.310) const SOFT_SURFACE: Color = Color(0.945, 0.977, 1.0, 0.72) const MALL_BADGE_TEXTURE: Texture2D = preload("res://assets/ui/mall/branding/branding_mall_badge.png") const COIN_TEXTURE: Texture2D = preload("res://assets/ui/mall/branding/branding_whale_coin.png") const CATALOG_SCRIPT: Script = preload("res://Config/MallCatalog.gd") const ITEM_CARD_SCRIPT: Script = preload("res://scenes/ui/mall/MallItemCard.gd") var _overlay: ColorRect var _panel: Control var _categoryList: VBoxContainer var _grid: GridContainer var _detailIcon: TextureRect var _detailName: Label var _detailDescription: Label var _detailStatus: Label var _detailPrice: Label var _tagRow: HBoxContainer var _purchaseButton: Button var _previewButton: Button var _toast: Control var _toastLabel: Label var _confirmDialog: Control var _confirmLabel: Label var _currencyLabel: Label var _categoryButtons: Dictionary = {} var _cards: Array[Button] = [] var _currentCategory: String = "recommended" var _selectedItem: Dictionary = {} var _catalogItems: Array[Dictionary] = [] var _balance: int = 0 var _walletLoaded: bool = false var _walletLoading: bool = false var _walletError: String = "" var _catalogLoaded: bool = false var _catalogLoading: bool = false var _catalogError: String = "" var _catalogRequestAccountGeneration: int = -1 var _purchaseRequestAccountGeneration: int = -1 var _accountGeneration: int = -1 var _isOpen: bool = false var _transitionTween: Tween var _toastTween: Tween func _ready() -> void: add_to_group("whaletown_escape_dismissible") visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE set_process(false) _build_ui() _connect_auth_state() _render_categories() _select_category(_currentCategory) func _exit_tree() -> void: var authManager := get_node_or_null("/root/AuthManager") if authManager != null and authManager.has_signal("auth_state_changed"): var callback := Callable(self, "_on_auth_state_changed") if authManager.is_connected("auth_state_changed", callback): authManager.disconnect("auth_state_changed", callback) func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED and is_instance_valid(_panel): _position_panel() func _input(event: InputEvent) -> void: if get_viewport().is_input_handled(): return if not _isOpen: return if event is InputEventKey: var keyEvent := event as InputEventKey if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE: hide_panel() get_viewport().set_input_as_handled() func _process(_delta: float) -> void: if _isOpen: _release_movement_actions() func show_panel() -> void: if _isOpen: return _isOpen = true visible = true mouse_filter = Control.MOUSE_FILTER_STOP set_process(true) _release_movement_input_state() _prepare_wallet_state() _prepare_catalog_state() _fetch_catalog() _emit_event(EventNames.MALL_OPENED, {}) _animate_panel(true) func hide_panel() -> void: if not _isOpen: return _isOpen = false _release_movement_input_state() _hide_confirm_dialog() _emit_event(EventNames.MALL_CLOSED, {}) _animate_panel(false) func is_panel_open() -> bool: return _isOpen func is_escape_dismissible() -> bool: return _isOpen func get_escape_priority() -> int: return 700 func request_escape_close() -> void: if is_instance_valid(_confirmDialog) and _confirmDialog.visible: _hide_confirm_dialog() return hide_panel() func _build_ui() -> void: _overlay = ColorRect.new() _overlay.name = "mallDimOverlay" _overlay.set_anchors_preset(Control.PRESET_FULL_RECT) _overlay.color = Color(0.026, 0.052, 0.092, 0.66) _overlay.mouse_filter = Control.MOUSE_FILTER_STOP _overlay.gui_input.connect(_on_overlay_gui_input) add_child(_overlay) _panel = Control.new() _panel.name = "mallPanel" _panel.custom_minimum_size = PANEL_SIZE _panel.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_panel) _position_panel() var frame := PanelContainer.new() frame.name = "mainFrame" frame.set_anchors_preset(Control.PRESET_FULL_RECT) frame.add_theme_stylebox_override("panel", _create_panel_style(Color(0.972, 0.992, 1.0, 1.0), 34, true)) frame.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.add_child(frame) var contentMargin := MarginContainer.new() contentMargin.set_anchors_preset(Control.PRESET_FULL_RECT) contentMargin.add_theme_constant_override("margin_left", 38) contentMargin.add_theme_constant_override("margin_top", 30) contentMargin.add_theme_constant_override("margin_right", 38) contentMargin.add_theme_constant_override("margin_bottom", 34) _panel.add_child(contentMargin) var root := VBoxContainer.new() root.add_theme_constant_override("separation", 18) contentMargin.add_child(root) root.add_child(_build_header()) root.add_child(_build_body()) root.add_child(_build_bottom_bar()) _build_toast() _build_confirm_dialog() _update_panel_alpha(0.0, 0.0) func _build_header() -> Control: var header := PanelContainer.new() header.custom_minimum_size = Vector2(0, 108) header.add_theme_stylebox_override("panel", _create_header_style()) var row := HBoxContainer.new() row.custom_minimum_size = Vector2(0, 96) row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 18) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 28) margin.add_theme_constant_override("margin_top", 10) margin.add_theme_constant_override("margin_right", 22) margin.add_theme_constant_override("margin_bottom", 10) header.add_child(margin) margin.add_child(row) var badge := TextureRect.new() badge.texture = MALL_BADGE_TEXTURE badge.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR badge.custom_minimum_size = Vector2(92, 92) badge.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL badge.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED row.add_child(badge) var titleBox := VBoxContainer.new() titleBox.size_flags_horizontal = Control.SIZE_EXPAND_FILL titleBox.add_theme_constant_override("separation", 2) row.add_child(titleBox) var title := Label.new() title.text = "鲸鱼商城" title.add_theme_font_size_override("font_size", 38) title.add_theme_color_override("font_color", Color.WHITE) title.add_theme_color_override("font_shadow_color", Color(0.0, 0.145, 0.318, 0.72)) title.add_theme_constant_override("shadow_offset_x", 0) title.add_theme_constant_override("shadow_offset_y", 3) titleBox.add_child(title) var subtitle := Label.new() subtitle.text = "当前开放角色皮肤,其他商品分类陆续筹备中" subtitle.add_theme_font_size_override("font_size", 17) subtitle.add_theme_color_override("font_color", Color(0.847, 0.949, 1.0, 0.94)) titleBox.add_child(subtitle) row.add_child(_build_currency_bar()) row.add_child(_build_close_button()) return header func _build_currency_bar() -> Control: var holder := PanelContainer.new() holder.custom_minimum_size = Vector2(276, 76) holder.add_theme_stylebox_override("panel", _create_panel_style(Color(0.038, 0.254, 0.505, 0.92), 34, false)) var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 8) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 18) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_right", 18) margin.add_theme_constant_override("margin_bottom", 8) holder.add_child(margin) margin.add_child(row) var coin := TextureRect.new() coin.texture = COIN_TEXTURE coin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR coin.custom_minimum_size = Vector2(42, 42) coin.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED row.add_child(coin) _currencyLabel = Label.new() _currencyLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL _currencyLabel.add_theme_font_size_override("font_size", 24) _currencyLabel.add_theme_color_override("font_color", Color.WHITE) row.add_child(_currencyLabel) var plus := Label.new() plus.text = "+" plus.add_theme_font_size_override("font_size", 30) plus.add_theme_color_override("font_color", Color.WHITE) row.add_child(plus) return holder func _build_close_button() -> Button: var button := Button.new() button.custom_minimum_size = Vector2(76, 76) button.focus_mode = Control.FOCUS_NONE button.tooltip_text = "关闭商城" button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.text = "×" button.add_theme_font_size_override("font_size", 42) button.add_theme_color_override("font_color", Color.WHITE) button.add_theme_stylebox_override("normal", _create_panel_style(Color(0.150, 0.510, 0.925, 0.95), 28, false)) button.add_theme_stylebox_override("hover", _create_panel_style(Color(0.210, 0.650, 1.0, 1.0), 28, false)) button.add_theme_stylebox_override("pressed", _create_panel_style(Color(0.080, 0.380, 0.780, 1.0), 28, false)) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) button.pressed.connect(hide_panel) return button func _build_body() -> Control: var body := HBoxContainer.new() body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_constant_override("separation", 22) body.add_child(_build_sidebar()) body.add_child(_build_grid_panel()) body.add_child(_build_detail_panel()) return body func _build_sidebar() -> Control: var sidebar := PanelContainer.new() sidebar.custom_minimum_size = Vector2(176, 0) sidebar.add_theme_stylebox_override("panel", _create_panel_style(Color(1, 1, 1, 0.96), 24, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 18) margin.add_theme_constant_override("margin_top", 22) margin.add_theme_constant_override("margin_right", 18) margin.add_theme_constant_override("margin_bottom", 22) sidebar.add_child(margin) _categoryList = VBoxContainer.new() _categoryList.alignment = BoxContainer.ALIGNMENT_CENTER _categoryList.add_theme_constant_override("separation", 14) margin.add_child(_categoryList) return sidebar func _build_grid_panel() -> Control: var gridPanel := PanelContainer.new() gridPanel.custom_minimum_size = Vector2(760, 0) gridPanel.size_flags_horizontal = Control.SIZE_EXPAND_FILL gridPanel.size_flags_vertical = Control.SIZE_EXPAND_FILL gridPanel.add_theme_stylebox_override("panel", _create_panel_style(Color(0.955, 0.986, 1.0, 0.98), 28, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 32) margin.add_theme_constant_override("margin_top", 34) margin.add_theme_constant_override("margin_right", 32) margin.add_theme_constant_override("margin_bottom", 34) gridPanel.add_child(margin) var scroll := ScrollContainer.new() scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL margin.add_child(scroll) _grid = GridContainer.new() _grid.columns = 3 _grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL _grid.add_theme_constant_override("h_separation", 26) _grid.add_theme_constant_override("v_separation", 30) scroll.add_child(_grid) return gridPanel func _build_detail_panel() -> Control: var detail := PanelContainer.new() detail.custom_minimum_size = Vector2(440, 0) detail.size_flags_vertical = Control.SIZE_EXPAND_FILL detail.add_theme_stylebox_override("panel", _create_panel_style(Color(0.970, 0.992, 1.0, 0.99), 28, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 28) margin.add_theme_constant_override("margin_top", 28) margin.add_theme_constant_override("margin_right", 28) margin.add_theme_constant_override("margin_bottom", 28) detail.add_child(margin) var box := VBoxContainer.new() box.add_theme_constant_override("separation", 12) margin.add_child(box) _detailIcon = TextureRect.new() _detailIcon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR _detailIcon.custom_minimum_size = Vector2(0, 260) _detailIcon.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL _detailIcon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED box.add_child(_detailIcon) _detailName = Label.new() _detailName.add_theme_font_size_override("font_size", 28) _detailName.add_theme_color_override("font_color", TEXT_COLOR) _detailName.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART box.add_child(_detailName) _detailStatus = Label.new() _detailStatus.add_theme_font_size_override("font_size", 16) _detailStatus.add_theme_color_override("font_color", ACCENT_COLOR) box.add_child(_detailStatus) _detailDescription = Label.new() _detailDescription.custom_minimum_size = Vector2(0, 104) _detailDescription.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _detailDescription.add_theme_font_size_override("font_size", 17) _detailDescription.add_theme_color_override("font_color", MUTED_COLOR) box.add_child(_detailDescription) _tagRow = HBoxContainer.new() _tagRow.add_theme_constant_override("separation", 8) box.add_child(_tagRow) var priceBox := HBoxContainer.new() priceBox.add_theme_constant_override("separation", 8) priceBox.alignment = BoxContainer.ALIGNMENT_CENTER box.add_child(priceBox) var coin := TextureRect.new() coin.texture = COIN_TEXTURE coin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR coin.custom_minimum_size = Vector2(34, 34) coin.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED priceBox.add_child(coin) _detailPrice = Label.new() _detailPrice.add_theme_font_size_override("font_size", 24) _detailPrice.add_theme_color_override("font_color", TEXT_COLOR) priceBox.add_child(_detailPrice) var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL box.add_child(spacer) _previewButton = _create_action_button("预览", false) _previewButton.pressed.connect(func() -> void: _preview_selected_item() ) box.add_child(_previewButton) _purchaseButton = _create_action_button("购买", true) _purchaseButton.pressed.connect(_on_purchase_pressed) box.add_child(_purchaseButton) return detail func _build_bottom_bar() -> Control: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(0, 62) row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 14) for label in ["购买记录", "商城消息", "设置"]: var button := _create_pill_button(label) button.pressed.connect(func() -> void: _show_toast("%s入口稍后接入" % label) ) row.add_child(button) return row func _render_categories() -> void: _clear_children(_categoryList) _categoryButtons.clear() for category in CATALOG_SCRIPT.get_categories(): var categoryId := str(category.get("id", "")) var button := Button.new() button.custom_minimum_size = Vector2(132, 116) button.focus_mode = Control.FOCUS_NONE button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.add_theme_stylebox_override("normal", _create_category_button_style(false, false)) button.add_theme_stylebox_override("hover", _create_category_button_style(false, true)) button.add_theme_stylebox_override("pressed", _create_category_button_style(true, true)) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) button.pressed.connect(func() -> void: _select_category(categoryId) ) _categoryList.add_child(button) _categoryButtons[categoryId] = button var content := VBoxContainer.new() content.set_anchors_preset(Control.PRESET_FULL_RECT) content.alignment = BoxContainer.ALIGNMENT_CENTER content.add_theme_constant_override("separation", 4) content.mouse_filter = Control.MOUSE_FILTER_IGNORE button.add_child(content) var icon := TextureRect.new() icon.texture = load(CATALOG_SCRIPT.get_category_icon_path(categoryId)) as Texture2D icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR icon.custom_minimum_size = Vector2(64, 64) icon.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.mouse_filter = Control.MOUSE_FILTER_IGNORE content.add_child(icon) var label := Label.new() label.text = str(category.get("label", "")) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", 17) label.add_theme_color_override("font_color", MUTED_COLOR) content.add_child(label) func _select_category(categoryId: String) -> void: _currentCategory = categoryId _emit_event(EventNames.MALL_CATEGORY_CHANGED, {"categoryId": categoryId}) _update_category_buttons() _render_grid(_get_catalog_items_for_category(categoryId)) func _update_category_buttons() -> void: for categoryId in _categoryButtons.keys(): var button := _categoryButtons[categoryId] as Button button.add_theme_stylebox_override("normal", _create_category_button_style(categoryId == _currentCategory, false)) func _render_grid(items: Array[Dictionary]) -> void: _clear_children(_grid) _cards.clear() _selectedItem = {} for index in range(items.size()): var item := items[index] var card := ITEM_CARD_SCRIPT.new() as Button card.call("setup", item, index == 0) card.get("item_selected").connect(_on_item_selected) _grid.add_child(card) _cards.append(card) if index == 0: _selectedItem = item.duplicate(true) if _selectedItem.is_empty(): _render_empty_detail() else: _render_detail(_selectedItem) func _on_item_selected(item: Dictionary) -> void: _selectedItem = item.duplicate(true) for card in _cards: var cardData: Dictionary = card.get("itemData") card.call("set_selected", str(cardData.get("id", "")) == str(_selectedItem.get("id", ""))) _emit_event(EventNames.MALL_ITEM_SELECTED, {"item": _selectedItem}) _render_detail(_selectedItem) func _render_detail(item: Dictionary) -> void: var iconPath := str(item.get("icon", "")) _detailIcon.texture = _load_texture(iconPath, MALL_BADGE_TEXTURE) _detailIcon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR _detailName.text = str(item.get("name", "商品槽位")) _detailDescription.text = str(item.get("description", "")) var status := str(item.get("status", CATALOG_SCRIPT.STATUS_COMING_SOON)) _detailStatus.text = CATALOG_SCRIPT.get_status_label(status) _detailStatus.add_theme_color_override("font_color", ACCENT_COLOR if status == CATALOG_SCRIPT.STATUS_AVAILABLE or status == CATALOG_SCRIPT.STATUS_OWNED else WARNING_COLOR) _detailPrice.text = _format_item_price(item) _render_tags(item) _update_purchase_button(item) func _render_empty_detail() -> void: _detailIcon.texture = MALL_BADGE_TEXTURE _detailIcon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR if _catalogLoading: _detailName.text = "读取商城" _detailDescription.text = "正在从服务器读取当前账号的钱包和皮肤拥有状态。" _detailStatus.text = "读取中" elif not _catalogError.is_empty(): _detailName.text = "读取失败" _detailDescription.text = _catalogError _detailStatus.text = "不可用" else: _detailName.text = "暂未开放" _detailDescription.text = "这个分类暂时没有商品。" _detailStatus.text = "未开放" _detailPrice.text = "-" _clear_children(_tagRow) _purchaseButton.text = "不可购买" _purchaseButton.disabled = true _previewButton.disabled = true func _render_tags(item: Dictionary) -> void: _clear_children(_tagRow) var tags: Array = item.get("tags", []) for tag in tags: var label := Label.new() label.text = str(tag) label.add_theme_font_size_override("font_size", 14) label.add_theme_color_override("font_color", ACCENT_COLOR) label.add_theme_stylebox_override("normal", _create_panel_style(Color(0.896, 0.960, 1.0, 0.84), 13, false)) _tagRow.add_child(label) func _update_purchase_button(item: Dictionary) -> void: var status := str(item.get("status", CATALOG_SCRIPT.STATUS_COMING_SOON)) _previewButton.disabled = not _is_skin_item(item) match status: CATALOG_SCRIPT.STATUS_AVAILABLE: if _walletLoading: _purchaseButton.text = "读取数据" _purchaseButton.disabled = true elif not _walletLoaded: _purchaseButton.text = "钱包不可用" _purchaseButton.disabled = true else: _purchaseButton.disabled = not CATALOG_SCRIPT.can_purchase(item, _balance) _purchaseButton.text = "购买" if _balance >= int(item.get("price", 0)) else "余额不足" CATALOG_SCRIPT.STATUS_OWNED: _purchaseButton.text = "已购买" _purchaseButton.disabled = true CATALOG_SCRIPT.STATUS_LOCKED: _purchaseButton.text = "未解锁" _purchaseButton.disabled = true _: _purchaseButton.text = "暂未开放" _purchaseButton.disabled = true func _preview_selected_item() -> void: if _selectedItem.is_empty() or not _is_skin_item(_selectedItem): _show_toast("当前商品暂不支持预览") return var skinId := str(_selectedItem.get("skinId", "")).strip_edges() var appearanceManager := get_node_or_null("/root/AppearanceManager") if appearanceManager == null or not appearanceManager.has_method("set_selected_skin"): _show_toast("外观系统暂不可用") return if bool(appearanceManager.call("set_selected_skin", skinId)): _emit_event(EventNames.MALL_PREVIEW_REQUESTED, {"item": _selectedItem, "skinId": skinId}) _show_toast("已预览「%s」" % str(_selectedItem.get("name", ""))) else: _show_toast("皮肤预览失败") func _on_purchase_pressed() -> void: if _selectedItem.is_empty(): return if _catalogLoading: _show_toast("正在读取商城数据") return if not _catalogLoaded: _show_toast(_catalogError if not _catalogError.is_empty() else "商城数据不可用") return if _walletLoading: _show_toast("正在读取钱包") return if not _walletLoaded: _show_toast(_walletError if not _walletError.is_empty() else "请先登录后再购买") return if not CATALOG_SCRIPT.can_purchase(_selectedItem, _balance): _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "unavailable"}) _show_toast("当前商品暂不可购买") return _emit_event(EventNames.MALL_PURCHASE_REQUESTED, {"item": _selectedItem}) _show_confirm_dialog(_selectedItem) func _show_confirm_dialog(item: Dictionary) -> void: _confirmLabel.text = "确认购买「%s」?\n将消耗 %d 鲸币。" % [str(item.get("name", "")), int(item.get("price", 0))] _confirmDialog.visible = true _confirmDialog.modulate.a = 0.0 var tween := create_tween() tween.tween_property(_confirmDialog, "modulate:a", 1.0, 0.12) func _hide_confirm_dialog() -> void: if is_instance_valid(_confirmDialog): _confirmDialog.visible = false func _confirm_purchase() -> void: if _selectedItem.is_empty(): return var price := int(_selectedItem.get("price", 0)) if not _is_backend_purchase_available(): _hide_confirm_dialog() _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "unauthenticated"}) _show_toast("请先登录后再购买") return if price > _balance: _hide_confirm_dialog() _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "insufficient_balance"}) _show_toast("鲸币余额不足") return _submit_backend_purchase(_selectedItem) func _get_catalog_items_for_category(categoryId: String) -> Array[Dictionary]: var result: Array[Dictionary] = [] for item in _catalogItems: var nextItem := item.duplicate(true) var itemCategory := str(nextItem.get("category", "")) if categoryId == "recommended" or itemCategory == categoryId: result.append(nextItem) result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.get("sortOrder", 0)) < int(b.get("sortOrder", 0)) ) return result func _build_confirm_dialog() -> void: _confirmDialog = Control.new() _confirmDialog.name = "purchaseConfirmDialog" _confirmDialog.set_anchors_preset(Control.PRESET_CENTER) _confirmDialog.custom_minimum_size = Vector2(520, 320) _confirmDialog.offset_left = -260 _confirmDialog.offset_top = -160 _confirmDialog.offset_right = 260 _confirmDialog.offset_bottom = 160 _confirmDialog.visible = false _confirmDialog.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_confirmDialog) var bg := PanelContainer.new() bg.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_theme_stylebox_override("panel", _create_panel_style(Color(0.982, 0.995, 1.0, 0.98), 30, true)) bg.mouse_filter = Control.MOUSE_FILTER_IGNORE _confirmDialog.add_child(bg) var margin := MarginContainer.new() margin.set_anchors_preset(Control.PRESET_FULL_RECT) margin.add_theme_constant_override("margin_left", 40) margin.add_theme_constant_override("margin_top", 54) margin.add_theme_constant_override("margin_right", 40) margin.add_theme_constant_override("margin_bottom", 40) _confirmDialog.add_child(margin) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 22) margin.add_child(box) _confirmLabel = Label.new() _confirmLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _confirmLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _confirmLabel.add_theme_font_size_override("font_size", 22) _confirmLabel.add_theme_color_override("font_color", TEXT_COLOR) box.add_child(_confirmLabel) var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 14) box.add_child(row) var cancel := _create_action_button("取消", false) cancel.custom_minimum_size = Vector2(150, 54) cancel.pressed.connect(_hide_confirm_dialog) row.add_child(cancel) var confirm := _create_action_button("确认购买", true) confirm.custom_minimum_size = Vector2(170, 54) confirm.pressed.connect(_confirm_purchase) row.add_child(confirm) func _build_toast() -> void: _toast = Control.new() _toast.name = "mallToast" _toast.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) _toast.offset_left = 560 _toast.offset_right = -560 _toast.offset_top = -122 _toast.offset_bottom = -52 _toast.visible = false _toast.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_toast) var bg := PanelContainer.new() bg.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_theme_stylebox_override("panel", _create_panel_style(Color(0.028, 0.160, 0.310, 0.92), 28, false)) bg.mouse_filter = Control.MOUSE_FILTER_IGNORE _toast.add_child(bg) _toastLabel = Label.new() _toastLabel.set_anchors_preset(Control.PRESET_FULL_RECT) _toastLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _toastLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _toastLabel.add_theme_font_size_override("font_size", 19) _toastLabel.add_theme_color_override("font_color", Color.WHITE) _toast.add_child(_toastLabel) func _show_toast(message: String) -> void: if is_instance_valid(_toastTween): _toastTween.kill() _toastLabel.text = message _toast.visible = true _toast.modulate.a = 0.0 _toastTween = create_tween() _toastTween.tween_property(_toast, "modulate:a", 1.0, 0.12) _toastTween.tween_interval(1.45) _toastTween.tween_property(_toast, "modulate:a", 0.0, 0.18) _toastTween.finished.connect(func() -> void: _toast.visible = false ) func _create_action_button(labelText: String, primary: bool) -> Button: var button := Button.new() button.text = labelText button.custom_minimum_size = Vector2(0, 68) button.focus_mode = Control.FOCUS_NONE button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.add_theme_font_size_override("font_size", 22) button.add_theme_color_override("font_color", Color.WHITE if primary else TEXT_COLOR) if not primary: button.add_theme_color_override("font_hover_color", ACCENT_COLOR) button.add_theme_color_override("font_pressed_color", Color(0.060, 0.270, 0.540, 1.0)) button.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55)) if primary: button.add_theme_stylebox_override("normal", _create_panel_style(Color(0.960, 0.310, 0.245, 0.96), 28, false)) button.add_theme_stylebox_override("hover", _create_panel_style(Color(1.000, 0.390, 0.300, 1.0), 28, false)) button.add_theme_stylebox_override("pressed", _create_panel_style(Color(0.820, 0.220, 0.180, 1.0), 28, false)) else: button.add_theme_stylebox_override("normal", _create_panel_style(Color(0.936, 0.982, 1.0, 0.94), 28, false)) button.add_theme_stylebox_override("hover", _create_panel_style(Color(0.860, 0.955, 1.0, 1.0), 28, false)) button.add_theme_stylebox_override("pressed", _create_panel_style(Color(0.760, 0.905, 0.980, 1.0), 28, false)) button.add_theme_stylebox_override("disabled", _create_panel_style(Color(0.580, 0.650, 0.730, 0.62), 28, false)) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) return button func _create_pill_button(labelText: String) -> Button: var button := Button.new() button.text = labelText button.custom_minimum_size = Vector2(154, 48) button.focus_mode = Control.FOCUS_NONE button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND button.add_theme_font_size_override("font_size", 16) button.add_theme_color_override("font_color", TEXT_COLOR) button.add_theme_color_override("font_hover_color", ACCENT_COLOR) button.add_theme_color_override("font_pressed_color", Color(0.060, 0.270, 0.540, 1.0)) button.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55)) button.add_theme_stylebox_override("normal", _create_panel_style(Color(1, 1, 1, 0.70), 22, false)) button.add_theme_stylebox_override("hover", _create_panel_style(Color(0.900, 0.965, 1.0, 0.94), 22, false)) button.add_theme_stylebox_override("pressed", _create_panel_style(Color(0.806, 0.925, 1.0, 1.0), 22, false)) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) return button func _on_overlay_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mouseEvent := event as InputEventMouseButton if mouseEvent.pressed and mouseEvent.button_index == MOUSE_BUTTON_LEFT: hide_panel() func _position_panel() -> void: var viewportSize := get_viewport_rect().size var width := minf(PANEL_SIZE.x, maxf(1080.0, viewportSize.x - MIN_MARGIN.x * 2.0)) var height := minf(PANEL_SIZE.y, maxf(820.0, viewportSize.y - MIN_MARGIN.y * 2.0)) _panel.position = (viewportSize - Vector2(width, height)) * 0.5 _panel.size = Vector2(width, height) _panel.pivot_offset = _panel.size * 0.5 func _animate_panel(opening: bool) -> void: if is_instance_valid(_transitionTween): _transitionTween.kill() _transitionTween = create_tween() _transitionTween.set_parallel(true) if opening: _update_panel_alpha(0.0, 0.0) _panel.scale = Vector2(0.985, 0.985) _transitionTween.tween_property(_overlay, "modulate:a", 1.0, 0.16) _transitionTween.tween_property(_panel, "modulate:a", 1.0, 0.18) _transitionTween.tween_property(_panel, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) return _transitionTween.tween_property(_overlay, "modulate:a", 0.0, 0.14) _transitionTween.tween_property(_panel, "modulate:a", 0.0, 0.14) _transitionTween.tween_property(_panel, "scale", Vector2(0.985, 0.985), 0.14) _transitionTween.finished.connect(func() -> void: visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE set_process(false) ) func _update_panel_alpha(overlayAlpha: float, panelAlpha: float) -> void: _overlay.modulate.a = overlayAlpha _panel.modulate.a = panelAlpha func _update_currency() -> void: if is_instance_valid(_currencyLabel): if _walletLoading: _currencyLabel.text = "读取中" elif not _walletLoaded: _currencyLabel.text = "未登录" if _walletError == "请先登录后再购买" else "读取失败" _currencyLabel.tooltip_text = _walletError else: _currencyLabel.text = _format_number(_balance) _currencyLabel.tooltip_text = "鲸币余额" func _format_number(value: int) -> String: var text := str(value) var result := "" var count := 0 for i in range(text.length() - 1, -1, -1): if count > 0 and count % 3 == 0: result = "," + result result = text[i] + result count += 1 return result func _format_item_price(item: Dictionary) -> String: var status := str(item.get("status", CATALOG_SCRIPT.STATUS_COMING_SOON)) if status == CATALOG_SCRIPT.STATUS_OWNED: return "已购买" if status == CATALOG_SCRIPT.STATUS_COMING_SOON or status == CATALOG_SCRIPT.STATUS_LOCKED: return "-" return str(int(item.get("price", 0))) func _is_skin_item(item: Dictionary) -> bool: return not str(item.get("skinId", "")).strip_edges().is_empty() func _connect_auth_state() -> void: var authManager := get_node_or_null("/root/AuthManager") if authManager == null or not authManager.has_signal("auth_state_changed"): return _accountGeneration = _current_account_generation() var callback := Callable(self, "_on_auth_state_changed") if not authManager.is_connected("auth_state_changed", callback): authManager.connect("auth_state_changed", callback) func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void: var currentGeneration := _current_account_generation() if currentGeneration == _accountGeneration: return _accountGeneration = currentGeneration _cancel_account_bound_requests() _reset_account_bound_state() if _isOpen: _prepare_wallet_state() _prepare_catalog_state() _fetch_catalog() else: _update_currency() func _is_backend_purchase_available() -> bool: var authManager := get_node_or_null("/root/AuthManager") return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated")) and authManager.has_method("get_access_token") func _current_account_generation() -> int: var authManager := get_node_or_null("/root/AuthManager") if authManager != null and authManager.has_method("get_account_generation"): return int(authManager.call("get_account_generation")) return -1 func _cancel_account_bound_requests() -> void: _catalogRequestAccountGeneration = -1 _purchaseRequestAccountGeneration = -1 func _reset_account_bound_state() -> void: _balance = 0 _walletLoaded = false _walletLoading = false _walletError = "" _catalogLoaded = false _catalogLoading = false _catalogError = "" _catalogItems = [] _selectedItem = {} _hide_confirm_dialog() func _submit_backend_purchase(item: Dictionary) -> void: var authManager := get_node_or_null("/root/AuthManager") var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null else "" if accessToken.is_empty(): _hide_confirm_dialog() _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": item, "reason": "unauthenticated"}) _show_toast("请先登录后再购买") _update_purchase_button(_selectedItem) return _purchaseButton.disabled = true _purchaseButton.text = "购买中" var payload := { "item_id": str(item.get("id", "")), } _purchaseRequestAccountGeneration = _current_account_generation() var api_client := get_node_or_null("/root/ApiClient") if api_client == null or not api_client.has_method("post_json"): _purchaseRequestAccountGeneration = -1 _hide_confirm_dialog() _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": item, "reason": "request_failed"}) _show_toast("购买服务不可用") _update_purchase_button(_selectedItem) return api_client.call("post_json", "/shop/purchases", payload, _on_purchase_request_completed, true) func _on_purchase_request_completed(success: bool, response: Dictionary, error_info: Dictionary) -> void: if _purchaseRequestAccountGeneration != _current_account_generation(): return _purchaseRequestAccountGeneration = -1 if not success: _hide_confirm_dialog() _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "backend_rejected"}) _show_toast(str(error_info.get("message", "购买失败"))) _update_purchase_button(_selectedItem) return var dataVariant: Variant = response.get("data", {}) var data: Dictionary = dataVariant as Dictionary if dataVariant is Dictionary else {} if not data.is_empty(): var ownedVariant: Variant = data.get("owned_skin_ids", []) var appearanceManager := get_node_or_null("/root/AppearanceManager") if ownedVariant is Array and appearanceManager != null and appearanceManager.has_method("set_owned_skin_ids"): appearanceManager.call("set_owned_skin_ids", ownedVariant) var playerStateManager := get_node_or_null("/root/PlayerStateManager") if playerStateManager != null and playerStateManager.has_method("apply_purchase_payload"): playerStateManager.call("apply_purchase_payload", data) _balance = int(data.get("balance", _balance)) _walletLoaded = true _walletLoading = false _walletError = "" _update_currency() _hide_confirm_dialog() var successMessage := "购买成功,已加入家具背包" if _is_room_decor_item(_selectedItem) else "购买成功,已加入你的皮肤" _show_toast(successMessage) _emit_event(EventNames.MALL_PURCHASE_SUCCEEDED, {"item": _selectedItem, "balance": _balance}) _fetch_catalog() func _fetch_catalog() -> void: if not _is_backend_purchase_available(): _catalogLoading = false _catalogLoaded = false _catalogError = "请先登录后查看商城" _walletLoading = false _walletLoaded = false _walletError = "请先登录后再购买" _catalogItems = [] _catalogRequestAccountGeneration = -1 _update_currency() _select_category(_currentCategory) return _catalogLoading = true _catalogLoaded = false _catalogError = "" _walletLoading = true _walletLoaded = false _walletError = "" _update_currency() _catalogRequestAccountGeneration = _current_account_generation() var api_client := get_node_or_null("/root/ApiClient") if api_client == null or not api_client.has_method("get_json"): _catalogRequestAccountGeneration = -1 _apply_catalog_error("商城服务不可用") return api_client.call("get_json", "/shop/catalog", _on_catalog_request_completed, true) func _on_catalog_request_completed(success: bool, response: Dictionary, error_info: Dictionary) -> void: if _catalogRequestAccountGeneration != _current_account_generation(): return _catalogRequestAccountGeneration = -1 if not success: _apply_catalog_error(str(error_info.get("message", "商城数据读取失败"))) return var dataVariant: Variant = response.get("data", {}) if not (dataVariant is Dictionary): _apply_catalog_error("商城数据格式错误") return _apply_catalog_payload(dataVariant as Dictionary) func _apply_catalog_payload(data: Dictionary) -> void: _balance = int(data.get("balance", 0)) _walletLoaded = true _walletLoading = false _walletError = "" _catalogItems.clear() var itemsVariant: Variant = data.get("items", []) if itemsVariant is Array: for itemVariant in itemsVariant: if itemVariant is Dictionary: var item: Dictionary = (itemVariant as Dictionary).duplicate(true) var itemId := str(item.get("id", item.get("item_id", ""))).strip_edges() if not item.has("id") and not itemId.is_empty(): item["id"] = itemId item["icon"] = _icon_path_for_item(item) _catalogItems.append(item) var ownedVariant: Variant = data.get("owned_skin_ids", []) var appearanceManager := get_node_or_null("/root/AppearanceManager") if ownedVariant is Array and appearanceManager != null and appearanceManager.has_method("set_owned_skin_ids"): appearanceManager.call("set_owned_skin_ids", ownedVariant) var playerStateManager := get_node_or_null("/root/PlayerStateManager") if playerStateManager != null and playerStateManager.has_method("apply_wallet"): playerStateManager.call("apply_wallet", { "balance": _balance, "currency": str(data.get("currency", "whale_coin")), }) _catalogLoaded = true _catalogLoading = false _catalogError = "" _update_currency() _select_category(_currentCategory) func _apply_catalog_error(message: String) -> void: _catalogItems = [] _catalogLoaded = false _catalogLoading = false _catalogError = message _walletLoaded = false _walletLoading = false _walletError = message _update_currency() _show_toast(message) _select_category(_currentCategory) func _icon_path_for_item(item: Dictionary) -> String: var itemId := str(item.get("id", "")) var directIcon := str(item.get("icon", "")) if not directIcon.is_empty(): return directIcon match itemId: "skin_classic_whale": return "res://assets/ui/mall/skins/classic_whale_product.png" "skin_human_whale_directional_v2_8x4": return "res://assets/ui/mall/skins/human_whale_directional_v2_8x4_product.png" "skin_girl_sailor_turnaround_v2_8x4": return "res://assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_product.png" "skin_panda_hero_8x4": return "res://assets/ui/mall/skins/panda_hero_8x4_product.png" "skin_ordinary_man_male_8x4": return "res://assets/ui/mall/skins/ordinary_man_male_8x4_product.png" _: return "" func _prepare_wallet_state() -> void: _balance = 0 _walletLoaded = false _walletError = "" _walletLoading = _is_backend_purchase_available() if not _walletLoading: _walletError = "请先登录后再购买" _update_currency() func _prepare_catalog_state() -> void: _catalogItems = [] _catalogLoaded = false _catalogError = "" _catalogLoading = _is_backend_purchase_available() _select_category(_currentCategory) func _is_room_decor_item(item: Dictionary) -> bool: return str(item.get("itemType", "")) == CATALOG_SCRIPT.ITEM_TYPE_ROOM_DECOR or not str(item.get("decorId", "")).strip_edges().is_empty() func _load_texture(path: String, fallback: Texture2D = null) -> Texture2D: if path.is_empty(): return fallback if FileAccess.file_exists("%s.import" % path): var texture := load(path) as Texture2D if texture != null: return texture var image := Image.load_from_file(ProjectSettings.globalize_path(path)) if image == null or image.is_empty(): return fallback return ImageTexture.create_from_image(image) func _release_movement_input_state() -> void: Input.flush_buffered_events() _release_movement_actions() func _release_movement_actions() -> void: for action in MOVEMENT_ACTIONS: Input.action_release(action) func _clear_children(node: Node) -> void: for child in node.get_children(): child.queue_free() func _create_panel_style(color: Color, radius: int, shadow: bool) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color style.corner_radius_top_left = radius style.corner_radius_top_right = radius style.corner_radius_bottom_left = radius style.corner_radius_bottom_right = radius style.content_margin_left = 12 style.content_margin_top = 8 style.content_margin_right = 12 style.content_margin_bottom = 8 if shadow: style.shadow_color = Color(0.082, 0.243, 0.380, 0.18) style.shadow_size = 16 style.shadow_offset = Vector2(0, 5) return style func _create_category_button_style(selected: bool, hover: bool) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0.818, 0.936, 0.982, 1.0) if selected else Color(1.0, 1.0, 1.0, 0.92) if hover and not selected: style.bg_color = Color(0.916, 0.976, 1.0, 1.0) style.corner_radius_top_left = 20 style.corner_radius_top_right = 20 style.corner_radius_bottom_left = 20 style.corner_radius_bottom_right = 20 style.border_width_left = 2 style.border_width_top = 2 style.border_width_right = 2 style.border_width_bottom = 2 style.border_color = Color(0.386, 0.745, 0.925, 0.92) if selected else Color(0.742, 0.870, 0.940, 0.72) style.content_margin_left = 10 style.content_margin_top = 8 style.content_margin_right = 10 style.content_margin_bottom = 8 style.shadow_color = Color(0.090, 0.290, 0.430, 0.12 if selected else 0.06) style.shadow_size = 4 if selected else 2 style.shadow_offset = Vector2(0, 2) return style func _create_header_style() -> StyleBoxFlat: var style := _create_panel_style(Color(0.020, 0.505, 0.895, 0.96), 30, false) style.border_width_bottom = 3 style.border_color = Color(0.690, 0.920, 1.0, 0.86) return style func _emit_event(eventName: String, data: Dictionary) -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("emit_event", eventName, data)