Files
whale-town-front-v2/scenes/ui/mall/MallPanel.gd
2026-07-23 00:59:00 +08:00

1159 lines
44 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)