769 lines
35 KiB
GDScript
769 lines
35 KiB
GDScript
extends Control
|
||
## Profile panel — 3-column layout.
|
||
## Left : profile card, currencies, stats, account buttons.
|
||
## Center: category tabs overlay, 3D rotatable preview, char-selector overlay.
|
||
## Right : item info card, 3×3 owned-item grid, pagination.
|
||
|
||
signal closed
|
||
signal profile_updated
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# UI references (all resolved via %UniqueName — must match .tscn)
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
# Left column
|
||
@onready var avatar_display := %AvatarDisplay as TextureRect
|
||
@onready var display_name_input := %DisplayNameInput as LineEdit
|
||
@onready var save_name_btn := %SaveNameBtn as Button
|
||
@onready var change_avatar_btn := %ChangeAvatarBtn as Button
|
||
@onready var account_type_label := %AccountType as Label
|
||
@onready var link_account_btn := %LinkAccountBtn as Button
|
||
@onready var gold_label := %GoldLabel as Label
|
||
@onready var star_label := %StarLabel as Label
|
||
@onready var games_played_label := %GamesPlayed as Label
|
||
@onready var win_rate_label := %WinRate as Label
|
||
@onready var high_score_label := %HighScore as Label
|
||
@onready var acc_settings_btn := %AccSettingsBtn as Button
|
||
@onready var admin_panel_btn := %AdminPanelBtn as Button
|
||
@onready var logout_btn := %LogoutBtn as Button
|
||
@onready var status_label := %StatusLabel as Label
|
||
|
||
# Center column (overlays inside CenterWrapper)
|
||
@onready var head_tab_btn := %HeadTabBtn as Button
|
||
@onready var costume_tab_btn := %CostumeTabBtn as Button
|
||
@onready var glove_tab_btn := %GloveTabBtn as Button
|
||
@onready var acc_tab_btn := %AccTabBtn as Button
|
||
@onready var frag_tab_btn := %FragTabBtn as Button
|
||
@onready var drag_zone := %DragZone as Control
|
||
@onready var character_root := %CharacterRoot as Node3D
|
||
@onready var char_left_btn := %CharLeftBtn as Button
|
||
@onready var char_right_btn := %CharRightBtn as Button
|
||
@onready var loadout_char_name := %LoadoutCharName as Label
|
||
@onready var set_default_btn := %SetDefaultBtn as Button
|
||
@onready var anim_player := %AnimationPlayer as AnimationPlayer
|
||
# Right column
|
||
@onready var item_preview := %ItemPreview as TextureRect
|
||
@onready var item_name_label := %ItemNameLabel as Label
|
||
@onready var item_rarity_label := %ItemRarityLabel as Label
|
||
@onready var item_price_label := %ItemPriceLabel as Label
|
||
@onready var equip_btn := %EquipBtn as Button
|
||
@onready var dismantle_btn := %DismantleBtn as Button
|
||
@onready var item_grid := %ItemGrid as GridContainer
|
||
@onready var prev_page_btn := %PrevPageBtn as Button
|
||
@onready var page_label := %PageLabel as Label
|
||
@onready var next_page_btn := %NextPageBtn as Button
|
||
|
||
# Back button + popups
|
||
@onready var back_btn := %BackBtn as Button
|
||
@onready var topup_gold_btn := %TopupGoldBtn as Button
|
||
@onready var avatar_popup := %AvatarSelectionPopup as PopupPanel
|
||
@onready var avatar_grid := %GridContainer as GridContainer
|
||
@onready var acc_settings_dialog := %AccountSettingsDialog as AcceptDialog
|
||
@onready var old_pass_input := %OldPassInput as LineEdit
|
||
@onready var new_email_input := %NewEmailInput as LineEdit
|
||
@onready var new_pass_input := %NewPassInput as LineEdit
|
||
@onready var submit_cred_btn := %SubmitCredBtn as Button
|
||
@onready var tz_dropdown := %TzDropdown as OptionButton
|
||
@onready var save_tz_btn := %SaveTzBtn as Button
|
||
@onready var reset_stats_btn := %ResetStatsBtn as Button
|
||
|
||
# New static popups
|
||
@onready var dismantle_dialog := %DismantleConfirmDialog as ConfirmationDialog
|
||
@onready var link_email_dialog := %LinkEmailDialog as AcceptDialog
|
||
@onready var link_email_input := %LinkEmailInput as LineEdit
|
||
@onready var link_pass_input := %LinkPassInput as LineEdit
|
||
@onready var reset_stats_dialog := %ResetStatsConfirmDialog as ConfirmationDialog
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Constants
|
||
# ─────────────────────────────────────────────────────────────
|
||
const CHARACTERS: Array[String] = ["Copper", "Dabro", "Gatot", "Pip"]
|
||
const CHAR_NODE_MAP: Dictionary = {
|
||
"Copper": "Oldpop",
|
||
"Dabro": "Masbro",
|
||
"Gatot": "Gatot",
|
||
"Pip": "Bob"
|
||
}
|
||
const RARITY_COLORS: Dictionary = {
|
||
"Common": Color(0.80, 0.80, 0.80, 1),
|
||
"Rare": Color(0.30, 0.60, 1.00, 1),
|
||
"Epic": Color(0.70, 0.30, 1.00, 1),
|
||
"Legendary": Color(1.00, 0.70, 0.10, 1)
|
||
}
|
||
## Client-side item catalog: item_id -> { name, rarity, star_value }
|
||
## Add new entries here whenever a new item is added to the shop.
|
||
const ITEM_CATALOG: Dictionary = {
|
||
# ── Generic items ──────────────────────────────────────────────────────
|
||
"head_hat1": {"name": "Cap", "rarity": "Common", "star_value": 50},
|
||
"head_crown": {"name": "Crown", "rarity": "Epic", "star_value": 1500},
|
||
"costume_red": {"name": "Red Suit", "rarity": "Rare", "star_value": 200},
|
||
"costume_gold": {"name": "Gold Suit", "rarity": "Epic", "star_value": 1000},
|
||
"glove_leather": {"name": "Leather Gloves", "rarity": "Common", "star_value": 50},
|
||
"acc_glasses": {"name": "Sunglasses", "rarity": "Rare", "star_value": 300},
|
||
# ── Oldpop (Copper) — Hat ─────────────────────────────────────────────
|
||
"oldpop_hat1": {"name": "Oldpop Hat I", "rarity": "Common", "star_value": 100},
|
||
"oldpop_hat2": {"name": "Oldpop Hat II", "rarity": "Rare", "star_value": 250},
|
||
"oldpop_hat3": {"name": "Oldpop Hat III", "rarity": "Epic", "star_value": 800},
|
||
# ── Oldpop (Copper) — Costume ─────────────────────────────────────────
|
||
"oldpop_body": {"name": "Oldpop Body", "rarity": "Rare", "star_value": 200},
|
||
"oldpop_arms": {"name": "Oldpop Arms", "rarity": "Common", "star_value": 100},
|
||
"oldpop-grey-pant": {"name": "Grey Pants", "rarity": "Common", "star_value": 80},
|
||
"oldpop-clothing-original": {"name": "Original Pants", "rarity": "Common", "star_value": 80},
|
||
# kept for backward compat with older purchases
|
||
"oldpop_clothing_original": {"name": "Original Pants", "rarity": "Common", "star_value": 80},
|
||
# ── Oldpop (Copper) — Gloves ──────────────────────────────────────────
|
||
"oldpop_gloves": {"name": "Oldpop Gloves", "rarity": "Common", "star_value": 60},
|
||
}
|
||
|
||
const ITEMS_PER_PAGE: int = 9
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# State
|
||
# ─────────────────────────────────────────────────────────────
|
||
var _loadout_index: int = 0
|
||
var _default_character: String = "Copper"
|
||
|
||
var _current_category: String = "head"
|
||
var _category_items: Array = []
|
||
var _current_page: int = 0
|
||
var _selected_item_id: String = ""
|
||
var _item_slots: Array[Button] = []
|
||
|
||
# Drag-to-rotate
|
||
var _drag_active: bool = false
|
||
var _drag_start_x: float = 0.0
|
||
var _model_yaw: float = 0.0
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Lifecycle
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _ready() -> void:
|
||
_build_slot_refs()
|
||
_connect_signals()
|
||
_setup_avatar_grid()
|
||
_setup_account_settings_ui()
|
||
_load_profile_data()
|
||
_load_loadout()
|
||
_setup_3d_preview()
|
||
_on_category_tab_pressed("head")
|
||
|
||
func _build_slot_refs() -> void:
|
||
_item_slots.clear()
|
||
for child in item_grid.get_children():
|
||
if child is Button:
|
||
_item_slots.append(child as Button)
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Signal wiring
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _connect_signals() -> void:
|
||
back_btn.pressed.connect(_on_close_pressed)
|
||
change_avatar_btn.pressed.connect(_on_change_avatar_pressed)
|
||
save_name_btn.pressed.connect(_on_save_name_pressed)
|
||
link_account_btn.pressed.connect(_on_link_account_pressed)
|
||
acc_settings_btn.pressed.connect(_open_account_settings)
|
||
admin_panel_btn.pressed.connect(_on_admin_panel_pressed)
|
||
logout_btn.pressed.connect(_on_logout_pressed)
|
||
char_left_btn.pressed.connect(func(): _cycle_loadout_char(-1))
|
||
char_right_btn.pressed.connect(func(): _cycle_loadout_char(1))
|
||
set_default_btn.pressed.connect(_on_set_default_pressed)
|
||
topup_gold_btn.pressed.connect(_on_topup_gold_pressed)
|
||
|
||
# Category tabs
|
||
head_tab_btn.pressed.connect(func(): _on_category_tab_pressed("head"))
|
||
costume_tab_btn.pressed.connect(func(): _on_category_tab_pressed("costume"))
|
||
glove_tab_btn.pressed.connect(func(): _on_category_tab_pressed("glove"))
|
||
acc_tab_btn.pressed.connect(func(): _on_category_tab_pressed("accessory"))
|
||
frag_tab_btn.pressed.connect(func(): _on_category_tab_pressed("fragment"))
|
||
|
||
# Item grid slot buttons
|
||
for i in _item_slots.size():
|
||
_item_slots[i].pressed.connect(_on_slot_pressed.bind(i))
|
||
|
||
equip_btn.pressed.connect(_on_equip_pressed)
|
||
dismantle_btn.pressed.connect(_on_dismantle_pressed)
|
||
dismantle_dialog.confirmed.connect(_on_dismantle_confirmed)
|
||
link_email_dialog.confirmed.connect(_on_link_account_confirmed)
|
||
reset_stats_dialog.confirmed.connect(_on_reset_stats_confirmed)
|
||
prev_page_btn.pressed.connect(_on_prev_page)
|
||
next_page_btn.pressed.connect(_on_next_page)
|
||
|
||
drag_zone.gui_input.connect(_on_drag_input)
|
||
|
||
if UserProfileManager.profile_loaded.connect(func(_p): _on_profile_updated()) != OK:
|
||
push_warning("[ProfilePanel] Could not connect profile_loaded")
|
||
UserProfileManager.profile_updated.connect(_on_profile_updated)
|
||
UserProfileManager.profile_update_failed.connect(_on_profile_update_failed)
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Profile data
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _load_profile_data() -> void:
|
||
var prof := UserProfileManager.profile
|
||
var stats := UserProfileManager.stats
|
||
|
||
display_name_input.text = prof.get("display_name", "")
|
||
display_name_input.max_length = 6
|
||
|
||
var url: String = UserProfileManager.get_avatar_url()
|
||
if ResourceLoader.exists(url):
|
||
avatar_display.texture = load(url) as Texture2D
|
||
|
||
games_played_label.text = "Games Played: %d" % stats.get("games_played", 0)
|
||
win_rate_label.text = "Win Rate: %.1f%%" % UserProfileManager.get_win_rate()
|
||
high_score_label.text = "High Score: %d" % stats.get("high_score", 0)
|
||
|
||
gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
|
||
star_label.text = str(UserProfileManager.wallet.get("star", 0))
|
||
|
||
if AuthManager.is_guest:
|
||
account_type_label.text = "Account : Guest"
|
||
link_account_btn.visible = true
|
||
else:
|
||
account_type_label.text = "Account : %s" % _auth_mode_name(AuthManager.auth_mode)
|
||
link_account_btn.visible = false
|
||
|
||
status_label.text = ""
|
||
|
||
func _auth_mode_name(mode: int) -> String:
|
||
match mode:
|
||
AuthManager.AuthMode.EMAIL: return "Email"
|
||
AuthManager.AuthMode.GOOGLE: return "Google"
|
||
AuthManager.AuthMode.APPLE: return "Apple"
|
||
AuthManager.AuthMode.FACEBOOK: return "Facebook"
|
||
AuthManager.AuthMode.STEAM: return "Steam"
|
||
_: return "Guest"
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Category tabs
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _on_category_tab_pressed(category: String) -> void:
|
||
_current_category = category
|
||
_current_page = 0
|
||
_selected_item_id = ""
|
||
_clear_item_info()
|
||
_rebuild_category_items()
|
||
_populate_item_grid()
|
||
_highlight_active_tab()
|
||
|
||
func _highlight_active_tab() -> void:
|
||
# Create active tab style (dark blue)
|
||
var active_style := StyleBoxFlat.new()
|
||
active_style.bg_color = Color(0.1, 0.19, 0.27, 1)
|
||
active_style.content_margin_left = 12.0
|
||
active_style.content_margin_top = 8.0
|
||
active_style.content_margin_right = 12.0
|
||
active_style.content_margin_bottom = 8.0
|
||
active_style.corner_radius_top_left = 8
|
||
active_style.corner_radius_top_right = 8
|
||
active_style.corner_radius_bottom_right = 8
|
||
active_style.corner_radius_bottom_left = 8
|
||
|
||
# Create inactive tab style (cyan)
|
||
var inactive_style := StyleBoxFlat.new()
|
||
inactive_style.bg_color = Color(0.33, 0.62, 0.78, 1)
|
||
inactive_style.content_margin_left = 12.0
|
||
inactive_style.content_margin_top = 8.0
|
||
inactive_style.content_margin_right = 12.0
|
||
inactive_style.content_margin_bottom = 8.0
|
||
inactive_style.corner_radius_top_left = 8
|
||
inactive_style.corner_radius_top_right = 8
|
||
inactive_style.corner_radius_bottom_right = 8
|
||
inactive_style.corner_radius_bottom_left = 8
|
||
|
||
var map := {
|
||
"head": head_tab_btn,
|
||
"costume": costume_tab_btn,
|
||
"glove": glove_tab_btn,
|
||
"accessory": acc_tab_btn,
|
||
"fragment": frag_tab_btn
|
||
}
|
||
for cat: String in map:
|
||
var btn: Button = map[cat]
|
||
var is_active := (cat == _current_category)
|
||
var style := active_style if is_active else inactive_style
|
||
btn.add_theme_stylebox_override("normal", style)
|
||
btn.add_theme_stylebox_override("hover", style)
|
||
btn.add_theme_stylebox_override("pressed", style)
|
||
btn.add_theme_color_override("font_color", Color.WHITE)
|
||
|
||
func _rebuild_category_items() -> void:
|
||
_category_items.clear()
|
||
# Fragment tab handled separately
|
||
if _current_category == "fragment":
|
||
_rebuild_fragment_items()
|
||
return
|
||
var prefix := _current_category + "_"
|
||
# Resolve the current character's node name (e.g. "Copper" → "Oldpop")
|
||
var current_char_display: String = CHARACTERS[_loadout_index]
|
||
var current_char_node: String = CHAR_NODE_MAP.get(current_char_display, current_char_display)
|
||
|
||
for item_id: String in UserProfileManager.inventory:
|
||
# Look up the skin data from SkinManager first (handles all id formats)
|
||
var skin_data: Dictionary = SkinManager.SKIN_CATALOG.get(item_id, {})
|
||
|
||
if not skin_data.is_empty():
|
||
# Only show items that match the current category
|
||
if skin_data.get("category", "") != _current_category:
|
||
continue
|
||
# Only show items that belong to this character (or have no character restriction)
|
||
var item_char: String = skin_data.get("character", "")
|
||
if item_char.is_empty() or item_char == current_char_node:
|
||
if not _category_items.has(item_id):
|
||
_category_items.append(item_id)
|
||
else:
|
||
# Fallback: generic prefix-based match (e.g. "head_hat1" under "head" tab)
|
||
if item_id.begins_with(prefix):
|
||
if not _category_items.has(item_id):
|
||
_category_items.append(item_id)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Item grid
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _rebuild_fragment_items() -> void:
|
||
var frags: Dictionary = UserProfileManager.fragments
|
||
for fid in ["frag_common", "frag_uncommon", "frag_rare"]:
|
||
var count: int = frags.get(fid, 0)
|
||
if count > 0: _category_items.append(fid)
|
||
|
||
func _populate_item_grid() -> void:
|
||
var start: int = _current_page * ITEMS_PER_PAGE
|
||
var total: int = _category_items.size()
|
||
var total_pages: int = max(1, ceili(float(total) / float(ITEMS_PER_PAGE)))
|
||
|
||
page_label.text = "%d / %d" % [_current_page + 1, total_pages]
|
||
prev_page_btn.disabled = (_current_page == 0)
|
||
next_page_btn.disabled = ((_current_page + 1) >= total_pages)
|
||
|
||
var placeholder_tex = preload("res://assets/graphics/gui/inventory/item_placeholder.png")
|
||
|
||
var equipped: String = UserProfileManager.loadout.get(_current_category, "")
|
||
for i in _item_slots.size():
|
||
var slot: Button = _item_slots[i]
|
||
var idx: int = start + i
|
||
if idx < total:
|
||
var item_id: String = _category_items[idx]
|
||
var info: Dictionary = ITEM_CATALOG.get(item_id, {})
|
||
|
||
var tex_path = "res://assets/graphics/gui/inventory/%s.png" % item_id
|
||
if ResourceLoader.exists(tex_path):
|
||
slot.icon = load(tex_path)
|
||
else:
|
||
slot.icon = placeholder_tex
|
||
|
||
slot.text = ""
|
||
slot.tooltip_text = info.get("name", item_id)
|
||
slot.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
slot.expand_icon = true
|
||
|
||
slot.modulate = Color(0.4, 1.0, 0.4, 1) if item_id == equipped else Color.WHITE
|
||
else:
|
||
slot.text = ""
|
||
slot.icon = null
|
||
slot.tooltip_text = ""
|
||
slot.modulate = Color.WHITE
|
||
|
||
func _on_slot_pressed(slot_index: int) -> void:
|
||
var idx: int = _current_page * ITEMS_PER_PAGE + slot_index
|
||
if idx >= _category_items.size():
|
||
return
|
||
_selected_item_id = _category_items[idx]
|
||
_show_item_info(_selected_item_id)
|
||
|
||
func _show_item_info(item_id: String) -> void:
|
||
var info: Dictionary = ITEM_CATALOG.get(item_id, {})
|
||
|
||
var tex_path = "res://assets/graphics/gui/inventory/%s.png" % item_id
|
||
if ResourceLoader.exists(tex_path):
|
||
item_preview.texture = load(tex_path)
|
||
else:
|
||
item_preview.texture = preload("res://assets/graphics/gui/inventory/item_placeholder.png")
|
||
|
||
item_name_label.text = info.get("name", item_id)
|
||
|
||
var rarity: String = info.get("rarity", "Common")
|
||
item_rarity_label.text = rarity
|
||
item_rarity_label.add_theme_color_override(
|
||
"font_color", RARITY_COLORS.get(rarity, Color.WHITE)
|
||
)
|
||
|
||
var sv: int = info.get("star_value", 0)
|
||
item_price_label.text = str(sv) if sv > 0 else ""
|
||
|
||
var equipped: String = UserProfileManager.loadout.get(_current_category, "")
|
||
if equipped == item_id:
|
||
equip_btn.text = "Unequip"
|
||
equip_btn.disabled = false
|
||
else:
|
||
equip_btn.text = "Equip"
|
||
equip_btn.disabled = false
|
||
dismantle_btn.disabled = false
|
||
|
||
func _clear_item_info() -> void:
|
||
item_name_label.text = "Select an item"
|
||
item_preview.texture = null
|
||
item_rarity_label.text = ""
|
||
item_price_label.text = ""
|
||
equip_btn.text = "Equip"
|
||
equip_btn.disabled = true
|
||
dismantle_btn.disabled = true
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Item actions
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _on_equip_pressed() -> void:
|
||
if _selected_item_id.is_empty(): return
|
||
var equipped: String = UserProfileManager.loadout.get(_current_category, "")
|
||
|
||
if equipped == _selected_item_id:
|
||
# ── UNEQUIP ──────────────────────────────────────────────
|
||
var ok: bool = await UserProfileManager.update_loadout(_current_category, "")
|
||
if ok:
|
||
_set_status("Unequipped: " + _selected_item_id, Color(1.0, 0.7, 0.3))
|
||
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
|
||
_populate_item_grid()
|
||
_show_item_info(_selected_item_id)
|
||
else:
|
||
_set_status("Failed to unequip.", Color.RED)
|
||
else:
|
||
# ── EQUIP ────────────────────────────────────────────────
|
||
var ok: bool = await UserProfileManager.update_loadout(_current_category, _selected_item_id)
|
||
if ok:
|
||
_set_status("Equipped: " + _selected_item_id, Color(0.4, 1.0, 0.4))
|
||
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
|
||
_populate_item_grid()
|
||
_show_item_info(_selected_item_id)
|
||
else:
|
||
_set_status("Failed to equip item.", Color.RED)
|
||
|
||
func _on_dismantle_pressed() -> void:
|
||
if _selected_item_id.is_empty(): return
|
||
dismantle_dialog.dialog_text = "Dismantle '%s'?\nThis will remove it from your inventory." % _selected_item_id
|
||
dismantle_dialog.popup_centered()
|
||
|
||
func _on_dismantle_confirmed() -> void:
|
||
UserProfileManager.inventory.erase(_selected_item_id)
|
||
if UserProfileManager.loadout.get(_current_category, "") == _selected_item_id:
|
||
UserProfileManager.update_loadout(_current_category, "")
|
||
_selected_item_id = ""
|
||
_clear_item_info()
|
||
_rebuild_category_items()
|
||
_populate_item_grid()
|
||
_set_status("Item dismantled.", Color(1, 0.5, 0.3))
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Pagination
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _on_prev_page() -> void:
|
||
if _current_page > 0:
|
||
_current_page -= 1
|
||
_populate_item_grid()
|
||
|
||
func _on_next_page() -> void:
|
||
var total_pages: int = max(1, ceili(float(_category_items.size()) / float(ITEMS_PER_PAGE)))
|
||
if _current_page + 1 < total_pages:
|
||
_current_page += 1
|
||
_populate_item_grid()
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Character loadout
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _load_loadout() -> void:
|
||
var saved: String = UserProfileManager.profile.get("loadout_character", "Copper")
|
||
var idx: int = CHARACTERS.find(saved)
|
||
_loadout_index = max(idx, 0)
|
||
_default_character = CHARACTERS[_loadout_index]
|
||
_refresh_loadout_ui()
|
||
|
||
func _cycle_loadout_char(direction: int) -> void:
|
||
_loadout_index = wrapi(_loadout_index + direction, 0, CHARACTERS.size())
|
||
_refresh_loadout_ui()
|
||
|
||
func _refresh_loadout_ui() -> void:
|
||
var char_name := CHARACTERS[_loadout_index]
|
||
loadout_char_name.text = char_name
|
||
var is_default := char_name == _default_character
|
||
set_default_btn.text = "✓ DEFAULT" if is_default else "Set as Default"
|
||
set_default_btn.disabled = is_default
|
||
_update_3d_preview(char_name)
|
||
|
||
func _on_set_default_pressed() -> void:
|
||
var char_name := CHARACTERS[_loadout_index]
|
||
_default_character = char_name
|
||
UserProfileManager.profile["loadout_character"] = char_name
|
||
if LobbyManager.available_characters.has(char_name):
|
||
LobbyManager.local_character_index = LobbyManager.available_characters.find(char_name)
|
||
_set_status("Default set to: " + char_name, Color(0.4, 0.9, 0.4))
|
||
_refresh_loadout_ui()
|
||
_save_loadout_to_profile()
|
||
UserProfileManager.submit_to_leaderboard()
|
||
# Notify lobby (and any other listeners) to refresh their 3D preview
|
||
UserProfileManager.profile_updated.emit()
|
||
|
||
func _save_loadout_to_profile() -> void:
|
||
if not NakamaManager.session: return
|
||
var data := {
|
||
"avatar_index": UserProfileManager.profile.get("avatar_index", 0),
|
||
"bio": UserProfileManager.profile.get("bio", ""),
|
||
"country": UserProfileManager.profile.get("country", ""),
|
||
"language": UserProfileManager.profile.get("language", "en"),
|
||
"loadout_character": _default_character,
|
||
"loadout": UserProfileManager.loadout
|
||
}
|
||
var write_obj := NakamaWriteStorageObject.new("profiles", "profile", 2, 1, JSON.stringify(data), "")
|
||
await NakamaManager.client.write_storage_objects_async(NakamaManager.session, [write_obj])
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# 3D Preview
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _setup_3d_preview() -> void:
|
||
_update_3d_preview(_default_character)
|
||
|
||
func _update_3d_preview(character_name: String) -> void:
|
||
if not character_root: return
|
||
var node_name: String = CHAR_NODE_MAP.get(character_name, "Masbro")
|
||
for child in character_root.get_children():
|
||
if child is Node3D:
|
||
child.visible = (child.name == node_name)
|
||
if anim_player:
|
||
var new_root: Node3D = character_root.get_node_or_null(node_name) as Node3D
|
||
if new_root:
|
||
anim_player.root_node = new_root.get_path()
|
||
if anim_player.has_animation("animation-pack/idle"):
|
||
anim_player.play("animation-pack/idle")
|
||
elif anim_player.get_animation_list().size() > 0:
|
||
anim_player.play(anim_player.get_animation_list()[0])
|
||
# Apply equipped skins on the newly-visible character
|
||
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Drag-to-rotate
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _on_drag_input(event: InputEvent) -> void:
|
||
if event is InputEventMouseButton:
|
||
var mb := event as InputEventMouseButton
|
||
if mb.button_index == MOUSE_BUTTON_LEFT:
|
||
_drag_active = mb.pressed
|
||
_drag_start_x = mb.position.x
|
||
elif event is InputEventMouseMotion and _drag_active:
|
||
var mm := event as InputEventMouseMotion
|
||
var delta: float = mm.position.x - _drag_start_x
|
||
_drag_start_x = mm.position.x
|
||
_model_yaw += delta * 0.8
|
||
if character_root:
|
||
character_root.rotation_degrees.y = _model_yaw
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Avatar
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _setup_avatar_grid() -> void:
|
||
# Assume .tscn has precisely instantiated Button nodes in AvatarGrid
|
||
var children = avatar_grid.get_children()
|
||
for i in range(min(children.size(), UserProfileManager.AVATARS.size())):
|
||
var btn = children[i] as Button
|
||
var path: String = UserProfileManager.AVATARS[i]
|
||
if ResourceLoader.exists(path):
|
||
btn.icon = load(path) as Texture2D
|
||
btn.expand_icon = true
|
||
else:
|
||
btn.text = str(i + 1)
|
||
|
||
# Prevent double connection if re-initializing
|
||
if btn.pressed.is_connected(_on_avatar_selected):
|
||
btn.pressed.disconnect(_on_avatar_selected)
|
||
|
||
btn.pressed.connect(_on_avatar_selected.bind(i))
|
||
|
||
func _on_change_avatar_pressed() -> void:
|
||
avatar_popup.popup_centered()
|
||
|
||
func _on_avatar_selected(index: int) -> void:
|
||
avatar_popup.hide()
|
||
_set_status("Saving avatar...", Color.WHITE)
|
||
var ok: bool = await UserProfileManager.update_avatar(index)
|
||
if ok:
|
||
var url: String = UserProfileManager.get_avatar_url()
|
||
if ResourceLoader.exists(url):
|
||
avatar_display.texture = load(url) as Texture2D
|
||
_set_status("Avatar updated!", Color(0.4, 1.0, 0.4))
|
||
else:
|
||
_set_status("Failed to update avatar.", Color.RED)
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Display name
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _on_save_name_pressed() -> void:
|
||
var new_name := display_name_input.text.strip_edges()
|
||
if new_name.is_empty():
|
||
_set_status("Name cannot be empty.", Color.RED)
|
||
return
|
||
_set_status("Saving...", Color.WHITE)
|
||
save_name_btn.disabled = true
|
||
var ok: bool = await UserProfileManager.update_display_name(new_name)
|
||
save_name_btn.disabled = false
|
||
if ok:
|
||
_set_status("Name updated!", Color(0.4, 1.0, 0.4))
|
||
emit_signal("profile_updated")
|
||
await get_tree().create_timer(3.0).timeout
|
||
status_label.text = ""
|
||
else:
|
||
_set_status("Failed to update name.", Color.RED)
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Account
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _on_link_account_pressed() -> void:
|
||
link_email_input.text = ""
|
||
link_pass_input.text = ""
|
||
link_email_dialog.popup_centered()
|
||
|
||
func _on_link_account_confirmed() -> void:
|
||
var e := link_email_input.text.strip_edges()
|
||
var p := link_pass_input.text
|
||
if e.is_empty() or p.is_empty():
|
||
_set_status("Fill in all fields.", Color.RED); return
|
||
_set_status("Linking account...", Color.WHITE)
|
||
var ok: bool = await AuthManager.link_email(e, p)
|
||
_set_status(
|
||
"Account linked!" if ok else "Failed to link account.",
|
||
Color(0.4, 1.0, 0.4) if ok else Color.RED
|
||
)
|
||
if ok:
|
||
link_account_btn.visible = false
|
||
account_type_label.text = "Account: Email"
|
||
|
||
func _setup_account_settings_ui() -> void:
|
||
tz_dropdown.clear()
|
||
for i in range(-12, 15):
|
||
tz_dropdown.add_item("GMT %s%d" % ["+" if i >= 0 else "", i])
|
||
|
||
submit_cred_btn.pressed.connect(func():
|
||
_set_status("Updating credentials...", Color.WHITE)
|
||
var payload := {
|
||
"current_password": old_pass_input.text,
|
||
"new_email": new_email_input.text,
|
||
"new_password": new_pass_input.text
|
||
}
|
||
var r = await BackendService.api_rpc_async("change_credentials", JSON.stringify(payload))
|
||
if r.get("success", false) == false:
|
||
_set_status("Error: " + str(r.get("message", "Unknown error")), Color.RED)
|
||
else:
|
||
_set_status("Credentials updated!", Color(0.4, 1.0, 0.4))
|
||
acc_settings_dialog.hide()
|
||
)
|
||
|
||
save_tz_btn.pressed.connect(func():
|
||
var sel := tz_dropdown.get_item_text(tz_dropdown.selected)
|
||
var r = await NakamaManager.client.update_account_async(
|
||
NakamaManager.session, null, null, null, null, null, sel
|
||
)
|
||
_set_status(
|
||
"Timezone saved!" if not r.is_exception() else "TZ error: " + r.get_exception().message,
|
||
Color(0.4, 1.0, 0.4) if not r.is_exception() else Color.RED
|
||
)
|
||
)
|
||
|
||
reset_stats_btn.pressed.connect(func():
|
||
reset_stats_dialog.popup_centered()
|
||
)
|
||
|
||
func _on_reset_stats_confirmed() -> void:
|
||
var r = await BackendService.api_rpc_async("reset_stats", "{}")
|
||
if r.get("success", false) == true:
|
||
UserProfileManager.stats = {
|
||
"games_played": 0, "games_won": 0, "games_lost": 0,
|
||
"total_score": 0, "high_score": 0, "play_time_minutes": 0
|
||
}
|
||
_load_profile_data()
|
||
_set_status("Stats wiped.", Color(1, 0.5, 0.3))
|
||
acc_settings_dialog.hide()
|
||
|
||
func _open_account_settings() -> void:
|
||
old_pass_input.visible = not AuthManager.is_guest
|
||
old_pass_input.text = ""; new_email_input.text = ""; new_pass_input.text = ""
|
||
acc_settings_dialog.popup_centered()
|
||
|
||
func _on_logout_pressed() -> void:
|
||
AuthManager.logout()
|
||
get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn")
|
||
|
||
func _on_admin_panel_pressed() -> void:
|
||
AdminManager.toggle_admin_panel()
|
||
|
||
func _on_topup_gold_pressed() -> void:
|
||
_set_status("Topping up gold...", Color.WHITE)
|
||
topup_gold_btn.disabled = true
|
||
var ok: bool = await UserProfileManager.admin_topup_gold()
|
||
topup_gold_btn.disabled = false
|
||
if ok:
|
||
gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
|
||
_set_status("Top-up successful!", Color(0.4, 1.0, 0.4))
|
||
else:
|
||
_set_status("Top-up failed.", Color.RED)
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Show / Close
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _on_close_pressed() -> void:
|
||
hide()
|
||
emit_signal("closed")
|
||
|
||
func show_panel() -> void:
|
||
if not UserProfileManager.is_profile_loaded:
|
||
_set_status("Loading profile...", Color.YELLOW)
|
||
_load_profile_data()
|
||
_load_loadout()
|
||
_check_admin_visibility()
|
||
show()
|
||
if AuthManager.is_guest:
|
||
_on_link_account_pressed()
|
||
_set_status("Link an email to save progress permanently!", Color.YELLOW)
|
||
# Reload inventory from server to guarantee fresh data, then refresh the grid
|
||
_set_status("Refreshing inventory...", Color.WHITE)
|
||
await UserProfileManager.load_inventory()
|
||
# Auto-select the first tab that actually has items (avoids confusing empty grid)
|
||
var found_tab := false
|
||
for cat in ["head", "costume", "glove", "accessory"]:
|
||
_current_category = cat
|
||
_rebuild_category_items()
|
||
if not _category_items.is_empty():
|
||
found_tab = true
|
||
break
|
||
if not found_tab:
|
||
_current_category = "head"
|
||
_rebuild_category_items()
|
||
_current_page = 0
|
||
_populate_item_grid()
|
||
_highlight_active_tab()
|
||
_set_status("", Color.WHITE)
|
||
|
||
func _check_admin_visibility() -> void:
|
||
admin_panel_btn.hide()
|
||
topup_gold_btn.hide()
|
||
if not NakamaManager.client or not NakamaManager.session: return
|
||
var account = await NakamaManager.client.get_account_async(NakamaManager.session)
|
||
if account.is_exception(): return
|
||
var raw: String = account.user.metadata if account.user.metadata else "{}"
|
||
var meta = JSON.parse_string(raw)
|
||
if meta is Dictionary and meta.get("role", "") in ["owner", "admin"]:
|
||
admin_panel_btn.show()
|
||
topup_gold_btn.show()
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Helpers
|
||
# ─────────────────────────────────────────────────────────────
|
||
func _set_status(msg: String, color: Color = Color.WHITE) -> void:
|
||
status_label.add_theme_color_override("font_color", color)
|
||
status_label.text = msg
|
||
|
||
func _on_profile_updated() -> void:
|
||
_load_profile_data()
|
||
_rebuild_category_items()
|
||
_populate_item_grid()
|
||
|
||
func _on_profile_update_failed(error: String) -> void:
|
||
_set_status(error, Color.RED)
|
||
await get_tree().create_timer(3.0).timeout
|
||
status_label.text = ""
|