feat: 2.3.2

This commit is contained in:
2026-05-19 17:30:29 +08:00
parent 7ca11c6534
commit 8430d1054e
39 changed files with 6581 additions and 738 deletions
+135 -3
View File
@@ -20,6 +20,9 @@ signal closed
@onready var ban_btn := %BanBtn as Button
@onready var unban_btn := %UnbanBtn as Button
@onready var delete_btn := %DeleteBtn as Button
@onready var history_btn := %HistoryBtn as Button
@onready var history_dialog := %HistoryDialog as AcceptDialog
@onready var history_text := %HistoryText as RichTextLabel
# Tab: Leaderboards
@onready var lb_tree := %LeaderboardTree as Tree
@@ -61,6 +64,11 @@ var _resolved_user_id: String = ""
var _mail_root: TreeItem
var _all_server_mails: Array = []
# Tab: Shop (Featured Banners)
@onready var slots_vbox := %SlotsVBox as VBoxContainer
@onready var load_banners_btn := %LoadBannersBtn as Button
@onready var save_banners_btn := %SaveBannersBtn as Button
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
# -- Data --
@@ -178,6 +186,7 @@ func _connect_signals() -> void:
ban_btn.pressed.connect(_on_ban)
unban_btn.pressed.connect(_on_unban)
delete_btn.pressed.connect(_on_delete)
history_btn.pressed.connect(_on_history_pressed)
user_tree.item_edited.connect(_on_user_tree_item_edited)
user_tree.button_clicked.connect(_on_user_tree_button_clicked)
@@ -204,6 +213,10 @@ func _connect_signals() -> void:
delete_mail_server_btn.pressed.connect(_on_delete_mail_server_pressed)
_update_mail_action_btns(null)
# Shop actions
load_banners_btn.pressed.connect(func(): await _load_featured_banners())
save_banners_btn.pressed.connect(func(): await _save_featured_banners())
# =============================================================================
# Core Panel Logic
# =============================================================================
@@ -228,6 +241,8 @@ func _on_tab_changed(tab_index: int) -> void:
await _load_daily_rewards_config()
elif tab_index == 4:
await _load_mail()
elif tab_index == 5:
await _load_featured_banners()
# =============================================================================
# RPC Helper
@@ -262,7 +277,8 @@ func _load_users() -> void:
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
return
all_users = res.get("users", [])
var raw_users = res.get("users", [])
all_users = raw_users if typeof(raw_users) == TYPE_ARRAY else []
count_label.text = "%d users" % all_users.size()
for user in all_users:
@@ -464,6 +480,63 @@ func _on_unban() -> void:
_set_status("Unbanned %d/%d" % [ok, to_unban.size()], CLR_STATUS_OK)
await _load_users()
func _on_history_pressed() -> void:
var selected_data = _get_checked_user_data()
if selected_data.size() != 1:
_set_status("Please select exactly ONE user to view history.", CLR_STATUS_ERR)
return
var uid = selected_data[0].get("user_id", "")
_set_status("Fetching history for user...", CLR_STATUS_OK)
var res = await _rpc("admin_get_user_history", {"user_id": uid})
if res.has("error"):
_set_status("Failed to get history: " + str(res.error), CLR_STATUS_ERR)
return
_set_status("History loaded.", CLR_STATUS_OK)
var h = res.get("history", {})
var text = "[b]=== USER HISTORY ===[/b]\n"
text += "User ID: " + uid + "\n\n"
# Logins
text += "[b]-- Recent Logins --[/b]\n"
var logins = h.get("logins", [])
if logins.is_empty():
text += "No recent logins found.\n"
else:
for l in logins:
var time_str = Time.get_datetime_string_from_unix_time(int(l.get("time", 0)))
text += "- %s (IP: %s)\n" % [time_str, l.get("ip", "unknown")]
text += "\n"
# Wallet Ledger
text += "[b]-- Economy / Wallet Ledger --[/b]\n"
var ledger = h.get("wallet_ledger", [])
if ledger.is_empty():
text += "No transactions found.\n"
else:
for item in ledger:
var changeset = str(item.get("changeset", {}))
var c_time = item.get("create_time", "")
text += "- [%s] %s\n" % [c_time.left(19).replace("T", " "), changeset]
text += "\n"
# Matches
text += "[b]-- Matches --[/b]\n"
var matches = h.get("matches", [])
if matches.is_empty():
text += "No match history found.\n"
else:
for m in matches:
text += "- " + str(m) + "\n"
history_text.text = text
history_dialog.popup_centered()
func _on_delete() -> void:
var users := _get_checked_user_data()
if users.is_empty(): return
@@ -498,7 +571,8 @@ func _load_leaderboard() -> void:
_set_status("Failed to load scores", CLR_STATUS_ERR)
return
lb_data = res.get("leaderboard", [])
var raw_lb = res.get("leaderboard", [])
lb_data = raw_lb if typeof(raw_lb) == TYPE_ARRAY else []
count_label.text = "%d records" % lb_data.size()
lb_data.sort_custom(func(a, b): return a.get("high_score", 0) > b.get("high_score", 0))
@@ -824,7 +898,8 @@ func _load_mail() -> void:
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
return
_all_server_mails = res.get("mails", [])
var raw_mails = res.get("mails", [])
_all_server_mails = raw_mails if typeof(raw_mails) == TYPE_ARRAY else []
count_label.text = "%d mails" % _all_server_mails.size()
var now_str = Time.get_datetime_string_from_system(true)
@@ -1065,3 +1140,60 @@ func _on_delete_mail_server_pressed() -> void:
await _load_mail()
confirm.queue_free()
)
# =============================================================================
# TAB 6: SHOP — FEATURED BANNERS
# =============================================================================
var _slot_nodes: Array = [] # cached references to the 3 slot HBoxContainers
func _get_slot_nodes() -> Array:
if _slot_nodes.is_empty():
for child in slots_vbox.get_children():
if child is HBoxContainer:
_slot_nodes.append(child)
return _slot_nodes
func _load_featured_banners() -> void:
_set_status("Loading banners...")
var res := await _rpc("admin_get_featured_banners", {})
if res.has("error"):
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
return
var raw_banners = res.get("banners", [])
var banners: Array = raw_banners if typeof(raw_banners) == TYPE_ARRAY else []
var slots := _get_slot_nodes()
for i in range(slots.size()):
var slot: HBoxContainer = slots[i]
var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit
var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit
if i < banners.size():
var b: Dictionary = banners[i] if banners[i] is Dictionary else {}
id_edit.text = b.get("item_id", "")
lbl_edit.text = b.get("label", "")
else:
id_edit.text = ""
lbl_edit.text = ""
count_label.text = "%d banners configured" % banners.size()
_set_status("Banners loaded", CLR_STATUS_OK)
func _save_featured_banners() -> void:
var banners: Array = []
var slots := _get_slot_nodes()
for slot in slots:
var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit
var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit
var item_id: String = id_edit.text.strip_edges()
var label: String = lbl_edit.text.strip_edges()
if not item_id.is_empty():
banners.append({"item_id": item_id, "label": label})
_set_status("Saving banners...")
var res := await _rpc("admin_set_featured_banners", {"banners": banners})
if res.has("error"):
_set_status("Save failed: " + str(res.error), CLR_STATUS_ERR)
elif res.has("success"):
_set_status("Banners saved! (%d slots)" % banners.size(), CLR_STATUS_OK)
+1 -1
View File
@@ -75,7 +75,7 @@ func _on_update_check_completed(has_update: bool, info: Dictionary) -> void:
button_container.visible = true
update_button.visible = true
skip_button.visible = true
skip_button_label.text = "Play without updating"
skip_button_label.text = "Force Play"
else:
status_label.text = "Game up to date."
button_container.visible = true
+31 -11
View File
@@ -5,10 +5,12 @@ extends Control
signal closed
# ─── Node refs ───────────────────────────────────────────────────────────────
@onready var back_btn := %BackBtn as Button
@onready var recipe_list := %RecipeList as VBoxContainer
@onready var status_label := %StatusLabel as Label
@onready var frag_balance := %FragBalance as Label
@onready var back_btn := %BackBtn as Button
@onready var recipe_list := %RecipeList as VBoxContainer
@onready var status_label := %StatusLabel as Label
@onready var common_label := %CommonLabel as Label
@onready var uncommon_label := %UncommonLabel as Label
@onready var rare_label := %RareLabel as Label
const FRAG_ICONS := {
"frag_common": "",
@@ -31,12 +33,9 @@ func _refresh() -> void:
# ─── Fragment balance header ──────────────────────────────────────────────────
func _update_frag_balance() -> void:
var frags: Dictionary = UserProfileManager.fragments
var parts: Array = []
for fid in ["frag_common", "frag_uncommon", "frag_rare"]:
var icon: String = FRAG_ICONS.get(fid, "?")
var count: int = frags.get(fid, 0)
parts.append("%s ×%d" % [icon, count])
frag_balance.text = " ".join(parts)
common_label.text = str(frags.get("frag_common", 0))
uncommon_label.text = str(frags.get("frag_uncommon", 0))
rare_label.text = str(frags.get("frag_rare", 0))
# ─── Recipe cards ─────────────────────────────────────────────────────────────
func _rebuild_recipe_list() -> void:
@@ -55,6 +54,18 @@ func _make_recipe_card(recipe_id: String, recipe: Dictionary) -> PanelContainer:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# Apply Tekton panel style
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1)
panel_style.corner_radius_top_left = 12
panel_style.corner_radius_top_right = 12
panel_style.corner_radius_bottom_right = 12
panel_style.corner_radius_bottom_left = 12
panel_style.shadow_color = Color(0, 0, 0, 0.3529412)
panel_style.shadow_size = 4
panel_style.shadow_offset = Vector2(-2, 2)
panel.add_theme_stylebox_override("panel", panel_style)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 14)
@@ -103,11 +114,20 @@ func _make_recipe_card(recipe_id: String, recipe: Dictionary) -> PanelContainer:
Color(0.4, 1.0, 0.5) if have >= needed else Color(1.0, 0.4, 0.4))
cost_hbox.add_child(cost_lbl)
# Craft button
# Craft button with Tekton dark style
var craft_btn := Button.new()
craft_btn.text = "🔨 Craft"
craft_btn.custom_minimum_size = Vector2(100, 40)
craft_btn.disabled = not can_craft
var btn_style := StyleBoxFlat.new()
btn_style.bg_color = Color(0.15, 0.15, 0.15, 1)
btn_style.corner_radius_top_left = 8
btn_style.corner_radius_top_right = 8
btn_style.corner_radius_bottom_right = 8
btn_style.corner_radius_bottom_left = 8
craft_btn.add_theme_stylebox_override("normal", btn_style)
if not can_craft:
craft_btn.modulate = Color(0.5, 0.5, 0.5, 0.7)
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id, panel))
+41 -4
View File
@@ -10,7 +10,8 @@ signal closed
@onready var star_tab_btn := %StarTabBtn as Button
@onready var gold_tab_btn := %GoldTabBtn as Button
@onready var banner_label := %BannerLabel as Label
@onready var balance_label := %BalanceLabel as Label
@onready var gold_label := %GoldLabel as Label
@onready var star_label := %StarLabel as Label
@onready var pity_label := %PityLabel as Label
@onready var pull_1_btn := %Pull1Btn as Button
@onready var pull_10_btn := %Pull10Btn as Button
@@ -88,8 +89,40 @@ func _ensure_dummy_wallet() -> void:
# ─── Banner switching ─────────────────────────────────────────────────────────
func _switch_banner(id: String) -> void:
_current_banner = id
star_tab_btn.modulate = Color(1.3, 1.1, 0.3) if id == "star" else Color.WHITE
gold_tab_btn.modulate = Color(1.3, 1.1, 0.3) if id == "gold" else Color.WHITE
# 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 = 16.0
active_style.content_margin_top = 14.0
active_style.content_margin_right = 16.0
active_style.content_margin_bottom = 14.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 = 16.0
inactive_style.content_margin_top = 14.0
inactive_style.content_margin_right = 16.0
inactive_style.content_margin_bottom = 14.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
# Apply styles
star_tab_btn.add_theme_stylebox_override("normal", active_style if id == "star" else inactive_style)
star_tab_btn.add_theme_stylebox_override("hover", active_style if id == "star" else inactive_style)
star_tab_btn.add_theme_stylebox_override("pressed", active_style if id == "star" else inactive_style)
gold_tab_btn.add_theme_stylebox_override("normal", active_style if id == "gold" else inactive_style)
gold_tab_btn.add_theme_stylebox_override("hover", active_style if id == "gold" else inactive_style)
gold_tab_btn.add_theme_stylebox_override("pressed", active_style if id == "gold" else inactive_style)
_refresh_ui()
func _refresh_ui() -> void:
@@ -107,7 +140,11 @@ func _refresh_ui() -> void:
var rates: Dictionary = banner.get("rates", {})
banner_label.text = banner.get("name", "Banner")
balance_label.text = "%s %d" % [icon, bal]
# Update both gold and star labels
star_label.text = str(UserProfileManager.wallet.get("star", 0))
gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
pity_label.text = "Pity: %d / %d" % [pity, pity_at]
cost_1_label.text = "%s %d" % [icon, c1]
cost_10_label.text = "%s %d" % [icon, c10]
+65 -74
View File
@@ -10,17 +10,19 @@ signal closed
# -------------------------------------------------------------------------
@onready var back_btn := %BackBtn as Button
@onready var refresh_btn := %RefreshBtn as Button
@onready var sync_btn := %SyncBtn as Button
@onready var sort_score_btn := %SortScoreBtn as Button
@onready var sort_win_rate_btn := %SortWinRateBtn as Button
@onready var sort_games_btn := %SortGamesBtn as Button
@onready var leaderboard_list := %LeaderboardList as VBoxContainer
@onready var status_label := %StatusLabel as Label
@onready var item_template := %ItemTemplate as PanelContainer
# 3D Preview
@onready var character_root := %CharacterRoot as Node3D
@onready var selected_name_label := %SelectedNameLabel as Label
@onready var selected_rank_label := %SelectedRankLabel as Label
@onready var selected_score_label := %SelectedScoreLabel as Label
@onready var selected_avatar_rect := %SelectedAvatarRect as TextureRect
# -------------------------------------------------------------------------
# State
@@ -43,13 +45,15 @@ const AVATAR_TO_CHAR: Array[String] = ["Pip", "Gatot", "Dabro", "Copper"]
func _ready() -> void:
back_btn.pressed.connect(_on_close_pressed)
refresh_btn.pressed.connect(_fetch_leaderboard_data)
sync_btn.pressed.connect(_on_sync_pressed)
sort_score_btn.pressed.connect(func(): _sort_by("high_score"))
sort_win_rate_btn.pressed.connect(func(): _sort_by("win_rate"))
sort_games_btn.pressed.connect(func(): _sort_by("games_played"))
_update_tab_visuals()
_setup_3d_preview()
if item_template:
item_template.hide()
# Listen to profile and stats changes to keep the panel updated
UserProfileManager.profile_updated.connect(_on_profile_or_stats_changed)
@@ -79,17 +83,6 @@ func _on_close_pressed() -> void:
hide()
emit_signal("closed")
func _on_sync_pressed() -> void:
"""Push the current player's stored stats up to the native Nakama leaderboard."""
if not NakamaManager.session or AuthManager.is_guest:
status_label.text = "Must be logged in to sync"
return
status_label.text = "Syncing your score..."
await UserProfileManager.submit_to_leaderboard()
status_label.text = "Synced! Refreshing..."
await get_tree().create_timer(0.5).timeout
_fetch_leaderboard_data()
# -------------------------------------------------------------------------
# Data
# -------------------------------------------------------------------------
@@ -99,8 +92,10 @@ func _fetch_leaderboard_data() -> void:
return
status_label.text = "Fetching Leaderboard..."
status_label.show()
for child in leaderboard_list.get_children():
child.queue_free()
if child != item_template:
child.queue_free()
# Try native Nakama leaderboard first (fastest, ranked already)
var native_data = await _fetch_native_leaderboard()
@@ -231,90 +226,76 @@ func _update_tab_visuals() -> void:
func _populate_list() -> void:
for child in leaderboard_list.get_children():
child.queue_free()
if child != item_template:
child.queue_free()
if leaderboard_data.size() == 0:
status_label.text = "No players found.\nPlay a match to appear here!"
status_label.show()
return
status_label.hide()
for i in range(leaderboard_data.size()):
var entry = leaderboard_data[i]
_create_leaderboard_item(i + 1, entry, i)
func _create_leaderboard_item(rank: int, entry: Dictionary, index: int) -> void:
var item = PanelContainer.new()
var style = StyleBoxFlat.new()
var item = item_template.duplicate()
item.show()
var style: StyleBoxFlat
if item.has_theme_stylebox_override("panel"):
style = item.get_theme_stylebox("panel").duplicate()
else:
style = StyleBoxFlat.new()
style.bg_color = Color(0.25, 0.3, 0.35, 1.0)
style.set_corner_radius_all(8)
if index == current_selected_index:
# Highlight color for the currently selected player row
style.bg_color = Color(0.25, 0.35, 0.20, 1.0)
elif rank <= 3:
style.bg_color = Color(0.2, 0.2, 0.15, 1.0)
style.bg_color = Color(0.35, 0.45, 0.30, 1.0)
else:
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
style.bg_color = Color(0.25, 0.3, 0.35, 1.0)
style.set_corner_radius_all(4)
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 8
style.content_margin_bottom = 8
item.add_theme_stylebox_override("panel", style)
var hbox = HBoxContainer.new()
hbox.add_theme_constant_override("separation", 16)
item.add_child(hbox)
var rank_label = item.get_node("HBoxContainer/RankLabel") as Label
if rank_label:
var rank_suffix = "th"
if rank % 10 == 1 and rank % 100 != 11: rank_suffix = "st"
elif rank % 10 == 2 and rank % 100 != 12: rank_suffix = "nd"
elif rank % 10 == 3 and rank % 100 != 13: rank_suffix = "rd"
rank_label.text = str(rank) + rank_suffix
# Rank
var rank_label = Label.new()
rank_label.text = "#" + str(rank)
rank_label.custom_minimum_size = Vector2(40, 0)
match rank:
1: rank_label.add_theme_color_override("font_color", Color.GOLD)
2: rank_label.add_theme_color_override("font_color", Color.SILVER)
3: rank_label.add_theme_color_override("font_color", Color.DARK_ORANGE)
_: rank_label.add_theme_color_override("font_color", Color.LIGHT_GRAY)
hbox.add_child(rank_label)
match rank:
1: rank_label.add_theme_color_override("font_color", Color.GOLD)
2: rank_label.add_theme_color_override("font_color", Color.SILVER)
3: rank_label.add_theme_color_override("font_color", Color.DARK_ORANGE)
_: rank_label.add_theme_color_override("font_color", Color.WHITE)
# Avatar
var avatar_rect = TextureRect.new()
avatar_rect.custom_minimum_size = Vector2(32, 32)
avatar_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
var avatar_url = entry.get("avatar_url", "")
if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url):
if not avatar_url.is_empty():
print("[Leaderboard] Avatar URL not found or invalid: ", avatar_url)
avatar_url = UserProfileManager.AVATARS[0]
avatar_rect.texture = load(avatar_url)
hbox.add_child(avatar_rect)
var avatar_rect = item.get_node("HBoxContainer/Margin/InnerHBox/AvatarRect") as TextureRect
if avatar_rect:
var avatar_url = entry.get("avatar_url", "")
if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url):
avatar_url = UserProfileManager.AVATARS[0]
avatar_rect.texture = load(avatar_url)
# Name
var name_label = Label.new()
name_label.text = entry.get("display_name", "Unknown")
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.add_theme_color_override("font_color", Color.WHITE)
hbox.add_child(name_label)
var name_label = item.get_node("HBoxContainer/Margin/InnerHBox/NameLabel") as Label
if name_label:
name_label.text = entry.get("display_name", "Unknown")
# Value
var value_label = Label.new()
var color = Color(0.647, 0.996, 0.224, 1)
match current_sort_key:
"high_score": value_label.text = str(entry.get("high_score", 0))
"win_rate": value_label.text = "%.1f%%" % entry.get("win_rate", 0.0)
"games_played": value_label.text = str(entry.get("games_played", 0))
value_label.add_theme_color_override("font_color", color)
value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
value_label.custom_minimum_size = Vector2(80, 0)
hbox.add_child(value_label)
var value_label = item.get_node("HBoxContainer/ValueLabel") as Label
if value_label:
match current_sort_key:
"high_score": value_label.text = str(entry.get("high_score", 0))
"win_rate": value_label.text = "%.1f%%" % entry.get("win_rate", 0.0)
"games_played": value_label.text = str(entry.get("games_played", 0))
leaderboard_list.add_child(item)
# Make row clickable to update 3D preview
item.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
current_selected_index = index
_populate_list() # Re-draw list to apply the new active highlight colors visually
_populate_list()
_show_entry_preview(index)
)
item.mouse_filter = Control.MOUSE_FILTER_STOP
@@ -347,8 +328,18 @@ func _show_entry_preview(index: int) -> void:
var display_name: String = entry.get("display_name", "Unknown")
var rank := index + 1
selected_name_label.text = display_name
selected_rank_label.text = "#%d" % rank
if selected_name_label: selected_name_label.text = display_name
if selected_rank_label: selected_rank_label.text = "Rank #%d" % rank
if selected_score_label:
match current_sort_key:
"high_score": selected_score_label.text = str(entry.get("high_score", 0))
"win_rate": selected_score_label.text = "%.1f%%" % entry.get("win_rate", 0.0)
"games_played": selected_score_label.text = str(entry.get("games_played", 0))
if selected_avatar_rect:
var avatar_url2 = entry.get("avatar_url", "")
if avatar_url2.is_empty() or not ResourceLoader.exists(avatar_url2):
avatar_url2 = UserProfileManager.AVATARS[0]
selected_avatar_rect.texture = load(avatar_url2)
func _update_3d_preview(character_name: String) -> void:
if not character_root:
+53 -3
View File
@@ -247,6 +247,30 @@ func _on_category_tab_pressed(category: String) -> void:
_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,
@@ -255,7 +279,13 @@ func _highlight_active_tab() -> void:
"fragment": frag_tab_btn
}
for cat: String in map:
(map[cat] as Button).modulate = Color(1.3, 1.3, 0.4, 1) if cat == _current_category else Color.WHITE
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()
@@ -306,6 +336,8 @@ func _populate_item_grid() -> void:
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]
@@ -313,11 +345,22 @@ func _populate_item_grid() -> void:
if idx < total:
var item_id: String = _category_items[idx]
var info: Dictionary = ITEM_CATALOG.get(item_id, {})
slot.text = info.get("name", item_id)
slot.tooltip_text = 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
@@ -331,6 +374,12 @@ func _on_slot_pressed(slot_index: int) -> void:
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")
@@ -353,6 +402,7 @@ func _show_item_info(item_id: String) -> void:
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"
+106 -22
View File
@@ -8,6 +8,9 @@ signal closed
@onready var item_grid: GridContainer = %ItemGrid
@onready var back_btn: Button = %BackBtn
@onready var status_label: Label = %StatusLabel
@onready var banner1: Button = %Banner1
@onready var banner2: Button = %Banner2
@onready var banner3: Button = %Banner3
# Tabs
@onready var tab_head: Button = %TabHead
@@ -17,6 +20,9 @@ signal closed
@onready var tab_gold: Button = %TabGold
@onready var tab_star: Button = %TabStar
# Maps category -> tab button (populated in _ready)
var _tab_map: Dictionary = {}
# 3D Preview
@onready var character_root: Node3D = %CharacterRoot
@onready var anim_player: AnimationPlayer = %AnimationPlayer
@@ -72,6 +78,14 @@ const STAR_PACKS: Array = [
# _ready
# -----------------------------------------------------------------------
func _ready() -> void:
_tab_map = {
"head": tab_head,
"costume": tab_costume,
"glove": tab_glove,
"accessory": tab_acc,
"gold_packs": tab_gold,
"star_packs": tab_star,
}
back_btn.pressed.connect(_on_close)
tab_head.pressed.connect(_on_tab_selected.bind("head"))
tab_costume.pressed.connect(_on_tab_selected.bind("costume"))
@@ -85,6 +99,7 @@ func _ready() -> void:
if UserProfileManager.profile_updated.connect(_refresh_wallet) != OK:
pass
_set_active_tab(current_category)
_setup_3d_preview()
if UserProfileManager.shop_catalog.is_empty():
@@ -175,12 +190,78 @@ func _fetch_and_build() -> void:
func _build_shop() -> void:
_refresh_wallet()
_populate_banners()
_populate_current_tab()
func _on_tab_selected(category: String) -> void:
current_category = category
_set_active_tab(category)
_populate_current_tab()
func _set_active_tab(active_category: String) -> void:
var style_active := StyleBoxFlat.new()
style_active.bg_color = Color(1, 1, 1, 1)
style_active.content_margin_left = 16.0
style_active.content_margin_top = 10.0
style_active.content_margin_right = 16.0
style_active.content_margin_bottom = 10.0
style_active.corner_radius_top_left = 6
style_active.corner_radius_top_right = 6
style_active.corner_radius_bottom_right = 6
style_active.corner_radius_bottom_left = 6
var style_inactive := StyleBoxFlat.new()
style_inactive.bg_color = Color(0.15, 0.18, 0.22, 1)
style_inactive.content_margin_left = 16.0
style_inactive.content_margin_top = 10.0
style_inactive.content_margin_right = 16.0
style_inactive.content_margin_bottom = 10.0
style_inactive.corner_radius_top_left = 6
style_inactive.corner_radius_top_right = 6
style_inactive.corner_radius_bottom_right = 6
style_inactive.corner_radius_bottom_left = 6
var style_hover := StyleBoxFlat.new()
style_hover.bg_color = Color(0.22, 0.26, 0.30, 1)
style_hover.content_margin_left = 16.0
style_hover.content_margin_top = 10.0
style_hover.content_margin_right = 16.0
style_hover.content_margin_bottom = 10.0
style_hover.corner_radius_top_left = 6
style_hover.corner_radius_top_right = 6
style_hover.corner_radius_bottom_right = 6
style_hover.corner_radius_bottom_left = 6
for cat in _tab_map:
var btn: Button = _tab_map[cat]
if cat == active_category:
btn.add_theme_stylebox_override("normal", style_active)
btn.add_theme_stylebox_override("hover", style_active)
btn.add_theme_stylebox_override("pressed", style_active)
btn.add_theme_color_override("font_color", Color(0.08, 0.09, 0.12))
btn.add_theme_color_override("font_hover_color", Color(0.08, 0.09, 0.12))
else:
btn.add_theme_stylebox_override("normal", style_inactive)
btn.add_theme_stylebox_override("hover", style_hover)
btn.add_theme_stylebox_override("pressed", style_inactive)
btn.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
btn.add_theme_color_override("font_hover_color", Color(1, 1, 1))
# -----------------------------------------------------------------------
# Banner population — promotional / featured items
# -----------------------------------------------------------------------
func _populate_banners() -> void:
var banners: Array[Button] = [banner1, banner2, banner3]
var promos: Array = UserProfileManager.shop_catalog.get("banners", [])
for i in banners.size():
var btn: Button = banners[i]
if i < promos.size():
btn.text = promos[i].get("label", "")
btn.tooltip_text = promos[i].get("id", "")
btn.show()
else:
btn.hide()
# -----------------------------------------------------------------------
# Grid population — builds cards dynamically from localized templates
# -----------------------------------------------------------------------
@@ -214,8 +295,8 @@ func _make_gold_card(pack: Dictionary) -> Control:
var card: Control = template_gold_card.duplicate()
card.visible = true
var amount_lbl: Label = card.find_child("AmountLabel", true, false) as Label
if amount_lbl: amount_lbl.text = "%d" % pack.amount
var amount_lbl: RichTextLabel = card.find_child("AmountLabel", true, false) as RichTextLabel
if amount_lbl: amount_lbl.text = "[right][img=24x24]res://assets/graphics/gui/lobby/gold.png[/img] %d[/right]" % pack.amount
var bonus_lbl: Label = card.find_child("BonusLabel", true, false) as Label
if bonus_lbl:
@@ -237,11 +318,11 @@ func _make_star_card(pack: Dictionary) -> Control:
var card: Control = template_star_card.duplicate()
card.visible = true
var amount_lbl: Label = card.find_child("AmountLabel", true, false) as Label
if amount_lbl: amount_lbl.text = "%d" % pack.amount
var amount_lbl: RichTextLabel = card.find_child("AmountLabel", true, false) as RichTextLabel
if amount_lbl: amount_lbl.text = "[right][img=24x24]res://assets/graphics/gui/lobby/star.png[/img] %d[/right]" % pack.amount
var cost_lbl: Label = card.find_child("CostLabel", true, false) as Label
if cost_lbl: cost_lbl.text = "Cost: ⭐ %d Gold" % pack.gold_cost
var cost_lbl: RichTextLabel = card.find_child("CostLabel", true, false) as RichTextLabel
if cost_lbl: cost_lbl.text = "[center]Cost: [img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] %d[/center]" % pack.gold_cost
var buy_btn: Button = card.find_child("BuyBtn", true, false) as Button
if buy_btn: buy_btn.pressed.connect(_on_buy_star_pressed.bind(pack))
@@ -270,13 +351,16 @@ func _make_cosmetic_card(item: Dictionary) -> Control:
}.get(rarity, Color(0.50, 0.50, 0.50))
rarity_lbl.add_theme_color_override("font_color", rarity_col)
var price_lbl: Label = card.find_child("PriceLabel", true, false) as Label
var price_lbl: RichTextLabel = card.find_child("PriceLabel", true, false) as RichTextLabel
if price_lbl:
var g: int = int(item.get("gold", 0))
var s: int = int(item.get("star", 0))
if g > 0 and s > 0: price_lbl.text = "%d%d" % [g, s]
elif g > 0: price_lbl.text = "%d" % g
else: price_lbl.text = "%d" % s
if g > 0 and s > 0:
price_lbl.text = "[center][img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] %d [img=20x20]res://assets/graphics/gui/lobby/star.png[/img] %d[/center]" % [g, s]
elif g > 0:
price_lbl.text = "[center][img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] %d[/center]" % g
else:
price_lbl.text = "[center][img=20x20]res://assets/graphics/gui/lobby/star.png[/img] %d[/center]" % s
var try_btn: Button = card.find_child("TryBtn", true, false) as Button
if try_btn: try_btn.pressed.connect(_on_try_pressed.bind(item))
@@ -300,8 +384,8 @@ func _make_cosmetic_card(item: Dictionary) -> Control:
func _refresh_wallet() -> void:
var g: int = UserProfileManager.wallet.get("gold", 0)
var s: int = UserProfileManager.wallet.get("star", 0)
gold_label.text = "%d" % g
star_label.text = "%d" % s
gold_label.text = str(g)
star_label.text = str(s)
status_label.text = ""
# -----------------------------------------------------------------------
@@ -351,23 +435,23 @@ func _on_buy_cosmetic_pressed(item: Dictionary) -> void:
if UserProfileManager.inventory.has(item.id):
status_label.text = "Already owned: " + item.get("name", item.id)
return
var price_gold: int = item.get("gold", 0)
var price_star: int = item.get("star", 0)
if UserProfileManager.wallet.get("gold", 0) < price_gold \
or UserProfileManager.wallet.get("star", 0) < price_star:
status_label.text = "Not enough currency."
return
status_label.text = "Purchasing..."
var success: bool = await UserProfileManager.purchase_item(
item.id, price_gold, price_star, current_category)
status_label.text = ("Purchased: " + item.get("name", item.id)) if success else "Purchase failed."
if success:
var err: String = await UserProfileManager.purchase_item(item.id)
if err == "":
status_label.text = "Purchased: " + item.get("name", item.id)
_refresh_wallet()
# Refresh preview to show newly purchased skin's materials
if _preview_revert.is_valid():
_preview_revert.call()
_preview_revert = Callable()
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
else:
if "NotEnoughFunds" in err or "funds" in err.to_lower() or "wallet" in err.to_lower():
status_label.text = "Not enough currency."
else:
status_label.text = "Purchase failed."
func _on_close() -> void:
# Clean up any open preview when closing the shop