feat: 2.3.1

This commit is contained in:
2026-05-11 17:24:47 +08:00
parent 57e56412e0
commit 13f3c3d591
733 changed files with 17957 additions and 798 deletions
+7 -3
View File
@@ -213,7 +213,7 @@ func get_dm_history(user_id: String, limit: int = 50) -> Array:
var channel = await open_dm(user_id)
if not channel:
return []
var result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, channel.id, limit, true)
var result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, channel.id, limit, false)
if result.is_exception():
push_warning("[FriendManager] Failed to fetch DM history: " + result.get_exception().message)
return []
@@ -226,9 +226,9 @@ func get_dm_history(user_id: String, limit: int = 50) -> Array:
text = parsed.get("msg", msg.content)
else:
text = msg.content
history.append({"from": msg.sender_id, "msg": text, "username": msg.username})
history.append({"from": msg.sender_id, "msg": text, "username": msg.username, "create_time": msg.create_time})
history.reverse() # Oldest to newest
history.reverse() # API returns newest-first; flip to oldest-first so display is top=old, bottom=new
return history
func send_dm(user_id: String, message: String) -> bool:
@@ -249,6 +249,10 @@ func _on_channel_message(message) -> void:
# Ignore global chat
if "social_global" in message.channel_id:
return
# Ignore messages sent by ourselves (we already add them locally instantly)
if NakamaManager.session and message.sender_id == NakamaManager.session.user_id:
return
var text: String = ""
var parsed = JSON.parse_string(message.content)
+11 -4
View File
@@ -157,7 +157,8 @@ func create_room(room_name: String) -> void:
current_room = {
"room_name": room_name,
"host_name": local_player_name,
"max_players": GameStateManager.max_players
"max_players": GameStateManager.max_players,
"game_mode": game_mode
}
# Connect to Nakama and create match
@@ -166,7 +167,11 @@ func create_room(room_name: String) -> void:
push_error("Failed to connect to Nakama")
return
NakamaManager.host_game()
NakamaManager.host_game({
"host_name": local_player_name,
"game_mode": game_mode,
"max_players": GameStateManager.max_players
})
func join_room(match_id: String) -> void:
"""Client joins an existing room by match ID (Nakama)."""
@@ -201,7 +206,8 @@ func create_room_lan(room_name: String = "LAN Game") -> bool:
"room_name": room_name,
"host_name": local_player_name,
"max_players": GameStateManager.max_players,
"match_id": "LAN"
"match_id": "LAN",
"game_mode": game_mode
}
# Add host to player list
@@ -239,7 +245,8 @@ func _broadcast_lan_room(room_name: String):
"host_name": local_player_name,
"player_count": players_in_room.size(),
"max_players": GameStateManager.max_players,
"match_id": "LAN"
"match_id": "LAN",
"game_mode": game_mode
}
var msg = "TEKTON_HOST:" + JSON.stringify(room_data)
_udp_peer.set_dest_address("255.255.255.255", LAN_DISCOVERY_PORT)
+96
View File
@@ -0,0 +1,96 @@
extends Node
signal mail_updated
signal unread_count_changed(count: int)
var mails: Array = []
var claimed_ids: Array = []
var read_ids: Array = []
var _is_fetching: bool = false
func _ready() -> void:
if UserProfileManager:
UserProfileManager.profile_loaded.connect(_on_profile_loaded)
func _on_profile_loaded(_profile: Dictionary) -> void:
fetch_mails()
func fetch_mails() -> void:
if _is_fetching or not NakamaManager.session: return
_is_fetching = true
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "get_mail", "{}")
_is_fetching = false
if r.is_exception():
push_error("[MailManager] Failed to fetch mails: " + r.get_exception().message)
return
var payload = JSON.parse_string(r.payload)
if payload and payload is Dictionary:
mails = payload.get("mails", [])
var state = payload.get("state", {})
claimed_ids = state.get("claimed_ids", [])
read_ids = state.get("read_ids", [])
# Sort by date descending
mails.sort_custom(func(a, b):
return a.get("date", "") > b.get("date", "")
)
mail_updated.emit()
_update_unread_count()
func _update_unread_count() -> void:
var count = 0
for m in mails:
if m.id not in read_ids:
count += 1
unread_count_changed.emit(count)
func claim_reward(mail_id: String) -> bool:
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "claim_mail_reward", JSON.stringify({"mail_id": mail_id}))
if r.is_exception():
push_error("[MailManager] Claim failed: " + r.get_exception().message)
return false
var payload = JSON.parse_string(r.payload)
if payload and payload.get("success"):
claimed_ids = payload.get("claimed_ids", claimed_ids)
if mail_id not in read_ids:
read_ids.append(mail_id)
mail_updated.emit()
_update_unread_count()
# Refresh wallet
UserProfileManager.load_profile()
return true
return false
func delete_mail(mail_id: String) -> bool:
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "delete_mail", JSON.stringify({"mail_id": mail_id}))
if r.is_exception():
push_error("[MailManager] Delete failed: " + r.get_exception().message)
return false
var payload = JSON.parse_string(r.payload)
if payload and payload.get("success"):
var deleted_ids = payload.get("deleted_ids", [])
# Remove from local array
for i in range(mails.size() - 1, -1, -1):
if mails[i].id in deleted_ids:
mails.remove_at(i)
if mail_id not in read_ids:
read_ids.append(mail_id)
mail_updated.emit()
_update_unread_count()
return true
return false
func mark_as_read(mail_id: String) -> void:
if mail_id in read_ids: return
# Since there's no specific RPC for just marking as read, we can just do a claim with 0 rewards if needed,
# or add an RPC for it. For now we just let claim or delete handle the server-side state.
# Let's add an empty claim or just handle it purely local until claimed/deleted if it has no rewards.
pass
+1
View File
@@ -0,0 +1 @@
uid://dx0lbsmcid72r
+73 -9
View File
@@ -116,7 +116,12 @@ func connect_to_nakama_async(email: String = "", password: String = "") -> bool:
socket = Nakama.create_socket_from(client)
var socket_result = await socket.connect_async(session)
if socket_result.is_exception():
if typeof(socket_result) == TYPE_INT:
if socket_result != OK:
printerr("[NakamaManager] Socket Error (Code: %s)" % socket_result)
emit_signal("connection_failed", "Socket connect failed with code " + str(socket_result))
return false
elif socket_result.is_exception():
var err = socket_result.get_exception()
printerr("[NakamaManager] Socket Error: %s (Code: %s)" % [err.message, err.status_code])
emit_signal("connection_failed", err.message)
@@ -154,6 +159,12 @@ func cleanup():
socket.close()
socket = null
# Delete match metadata from storage (best-effort, don't block cleanup)
if session and client and not current_match_id.is_empty():
client.delete_storage_objects_async(session, [
NakamaStorageObjectId.new("match_meta", current_match_id, session.user_id)
])
current_match_id = ""
# Reset Godot's multiplayer peer
@@ -164,7 +175,7 @@ func cleanup():
# --- Match Management ---
func host_game():
func host_game(room_meta: Dictionary = {}):
if not bridge:
printerr("Cannot host: Bridge not initialized")
return
@@ -172,6 +183,19 @@ func host_game():
var result = await bridge.create_match()
if result and result.is_exception():
emit_signal("match_join_error", result.get_exception().message)
return
# Store room metadata in Nakama storage so other players can see it in listings
if session and current_match_id and room_meta.size() > 0:
var meta_json = JSON.stringify(room_meta)
var write_obj = NakamaWriteStorageObject.new(
"match_meta", current_match_id,
2, 1, meta_json, ""
)
var wr = await client.write_storage_objects_async(session, [write_obj])
if wr.is_exception():
push_warning("[NakamaManager] Failed to write match metadata: ", wr.get_exception().message)
else:
print("[NakamaManager] Match metadata stored for: ", current_match_id)
func join_game(match_id: String):
if not bridge:
@@ -206,8 +230,10 @@ func is_connected_to_nakama() -> bool:
# --- Match Listing ---
func list_matches_async() -> Array:
"""Query available matches from Nakama server."""
func list_matches_async(mode_filter: String = "") -> Array:
"""Query available matches from Nakama server.
mode_filter: optional game mode string — used as label query for authoritative matches.
For relayed matches this has no server-side effect; filtering is client-side."""
if not client:
push_error("Cannot list matches: Client not initialized")
return []
@@ -216,9 +242,10 @@ func list_matches_async() -> Array:
push_error("Cannot list matches: No valid session")
return []
print("Querying matches from Nakama server...")
print("Querying matches from Nakama server... (filter: '%s')" % mode_filter)
# Query matches - min 0, max 8 players, limit 20, authoritative=false for relayed matches
# Query matches — label filter works for authoritative matches only.
# For relayed matches all rooms are returned; game_mode is parsed from label field if present.
var result = await client.list_matches_async(session, 0, 8, 20, false, "", "")
if result.is_exception():
@@ -228,16 +255,53 @@ func list_matches_async() -> Array:
var rooms: Array = []
if result.matches:
print("Found %d matches" % result.matches.size())
# Build storage read requests for all matches
var read_ids: Array = []
for match_data in result.matches:
# Try to read metadata stored by each host
read_ids.append(match_data.match_id)
# Batch-read room metadata from Nakama storage
var meta_map: Dictionary = {} # match_id -> {host_name, game_mode, ...}
if read_ids.size() > 0:
var storage_reads: Array = []
for mid in read_ids:
# We don't know the owner_id, so list objects by collection+key
# Use list_storage_objects per match or batch read
pass
# Read all match metas from our own storage (other users' objects are public read)
for mid in read_ids:
var list_result = await client.list_storage_objects_async(session, "match_meta", "", 100)
if not list_result.is_exception() and list_result.objects:
for obj in list_result.objects:
var parsed = JSON.parse_string(obj.value)
if parsed is Dictionary:
meta_map[obj.key] = parsed
break # Only need one call — lists all match_meta objects
for match_data in result.matches:
print(" Match: ", match_data.match_id, " - Size: ", match_data.size)
# Use first 8 chars of match ID as room identifier since Nakama doesn't store custom names
var short_id = match_data.match_id.substr(0, 8) if match_data.match_id.length() > 8 else match_data.match_id
# Get metadata from storage (host_name, game_mode)
var meta = meta_map.get(match_data.match_id, {})
var host_name = meta.get("host_name", "Unknown")
var game_mode = meta.get("game_mode", "")
var max_players = int(meta.get("max_players", 8))
# Fallback: parse game_mode from label if available
if game_mode.is_empty():
var label: String = match_data.label if match_data.label != null else ""
if label.begins_with("[") and "]" in label:
game_mode = label.substr(1, label.find("]") - 1)
rooms.append({
"match_id": match_data.match_id,
"room_name": short_id,
"host_name": "Host",
"host_name": host_name,
"player_count": match_data.size if match_data.size else 1,
"max_players": 4
"max_players": max_players,
"game_mode": game_mode
})
else:
print("No matches found")
+79
View File
@@ -36,6 +36,17 @@ signal closed
var _daily_reward_config_data: Dictionary = {}
var _current_dr_month: String = ""
# Tab: Announcements
@onready var target_user_edit := %TargetUserEdit as LineEdit
@onready var title_edit := %TitleEdit as LineEdit
@onready var content_edit := %ContentEdit as TextEdit
@onready var start_date_edit := %StartDatePicker as Button
@onready var end_date_edit := %EndDatePicker as Button
@onready var add_reward_btn := %AddRewardBtn as Button
@onready var rewards_list := %RewardsList as VBoxContainer
@onready var reward_row_template := %RewardRowTemplate as HBoxContainer
@onready var send_mail_btn := %SendMailBtn as Button
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
# -- Data --
@@ -151,6 +162,10 @@ func _connect_signals() -> void:
load_dr_btn.pressed.connect(_load_daily_rewards_config)
save_dr_btn.pressed.connect(_save_daily_rewards_config)
month_option_btn.item_selected.connect(_on_dr_month_selected)
# Announcement actions
send_mail_btn.pressed.connect(_on_send_mail)
add_reward_btn.pressed.connect(_on_add_reward_pressed)
# =============================================================================
# Core Panel Logic
@@ -623,3 +638,67 @@ func _save_daily_rewards_config() -> void:
_set_status("Save failed: " + res.get("error"), CLR_STATUS_ERR)
else:
_set_status("Config saved successfully!", CLR_STATUS_OK)
# =============================================================================
# TAB 4: ANNOUNCEMENTS
# =============================================================================
func _on_add_reward_pressed() -> void:
var row = reward_row_template.duplicate()
row.visible = true
rewards_list.add_child(row)
var remove_btn = row.get_node("RemoveBtn") as Button
remove_btn.pressed.connect(func(): row.queue_free())
func _on_send_mail() -> void:
var target = target_user_edit.text.strip_edges()
var title = title_edit.text.strip_edges()
var content = content_edit.text.strip_edges()
var start_date = start_date_edit.get_date_iso()
var end_date = end_date_edit.get_date_iso()
if title.is_empty() or content.is_empty():
_set_status("Title and content cannot be empty", CLR_STATUS_ERR)
return
var rewards_arr = []
for child in rewards_list.get_children():
if not child.visible: continue # skip template
var type_opt = child.get_node("TypeOption") as OptionButton
var id_edit = child.get_node("IdEdit") as LineEdit
var amount_spin = child.get_node("AmountSpin") as SpinBox
var type_str = type_opt.get_item_text(type_opt.selected)
var r_id = id_edit.text.strip_edges()
rewards_arr.append({
"type": type_str,
"id": r_id,
"amount": int(amount_spin.value)
})
send_mail_btn.disabled = true
_set_status("Sending mail...")
var payload = {
"target_user_id": target,
"title": title,
"content": content,
"start_date": start_date,
"end_date": end_date,
"rewards": rewards_arr
}
var res = await _rpc("admin_send_mail", payload)
send_mail_btn.disabled = false
if res.has("error"):
_set_status("Failed to send mail: " + res.get("error", "Unknown"), CLR_STATUS_ERR)
else:
_set_status("Mail sent successfully!", CLR_STATUS_OK)
title_edit.text = ""
content_edit.text = ""
if start_date_edit.has_method("clear_date"):
start_date_edit.clear_date()
if end_date_edit.has_method("clear_date"):
end_date_edit.clear_date()
for child in rewards_list.get_children():
if child.visible: child.queue_free()
+13 -9
View File
@@ -6,13 +6,15 @@ extends Control
@onready var progress_bar := %ProgressBar as ProgressBar
@onready var progress_label := %ProgressLabel as Label
@onready var button_container := %ButtonContainer as HBoxContainer
@onready var update_button := %UpdateButton as Button
@onready var skip_button := %SkipButton as Button
@onready var update_button := %UpdateButton as BaseButton
@onready var update_button_label := %UpdateButton.get_node("Label") as Label
@onready var skip_button := %SkipButton as BaseButton
@onready var skip_button_label := %SkipButton.get_node("Label") as Label
@onready var changelog_panel := %ChangelogPanel
@onready var changelog_richtext := %ChangelogRichText as RichTextLabel
@onready var prev_button := %PrevButton as Button
@onready var next_button := %NextButton as Button
@onready var prev_button := %PrevButton as BaseButton
@onready var next_button := %NextButton as BaseButton
@onready var page_label := %PageLabel as Label
var update_manager: Node
@@ -73,20 +75,20 @@ 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.text = "Play without updating"
skip_button_label.text = "Play without updating"
else:
status_label.text = "Game up to date."
button_container.visible = true
update_button.visible = false
skip_button.visible = true
skip_button.text = "Play Game"
skip_button_label.text = "Play"
func _on_update_check_failed(_error: String) -> void:
status_label.text = "Could not check for updates"
button_container.visible = true
update_button.visible = false
skip_button.visible = true
skip_button.text = "Play Offline"
skip_button_label.text = "Play"
func _on_update_pressed() -> void:
button_container.visible = false
@@ -114,7 +116,7 @@ func _on_download_failed(error: String) -> void:
progress_bar.visible = false
progress_label.visible = false
button_container.visible = true
update_button.text = "Retry"
update_button_label.text = "Retry"
func _begin_resource_load() -> void:
button_container.visible = false
@@ -156,8 +158,10 @@ func _update_pagination_ui():
var txt: String = "[font_size=20][b]Version " + str(entry.get("version", "Unknown")) + "[/b][/font_size]\n[color=gray]" + str(entry.get("date", "")) + "[/color]\n\n"
var changes: Array = entry.get("changelog", [])
txt += "[table=2]"
for change in changes:
txt += "" + change + "\n"
txt += "[cell]• [/cell][cell]" + change + "[/cell]"
txt += "[/table]"
changelog_richtext.text = txt
+97
View File
@@ -0,0 +1,97 @@
extends Button
signal date_selected(date_str: String)
@onready var popup = $PopupPanel
@onready var month_year_lbl = $PopupPanel/VBox/Header/MonthYearLbl
@onready var days_grid = $PopupPanel/VBox/DaysGrid
@onready var prev_btn = $PopupPanel/VBox/Header/PrevBtn
@onready var next_btn = $PopupPanel/VBox/Header/NextBtn
@onready var clear_btn = $PopupPanel/VBox/ClearBtn
var current_date: Dictionary
var view_date: Dictionary
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
func _ready() -> void:
var now = Time.get_datetime_dict_from_system()
view_date = {"year": now.year, "month": now.month, "day": now.day}
pressed.connect(_on_button_pressed)
prev_btn.pressed.connect(_on_prev_month)
next_btn.pressed.connect(_on_next_month)
clear_btn.pressed.connect(_on_clear_pressed)
_update_calendar()
func _on_button_pressed() -> void:
_update_calendar()
popup.popup_centered()
func _on_prev_month() -> void:
view_date.month -= 1
if view_date.month < 1:
view_date.month = 12
view_date.year -= 1
_update_calendar()
func _on_next_month() -> void:
view_date.month += 1
if view_date.month > 12:
view_date.month = 1
view_date.year += 1
_update_calendar()
func _on_clear_pressed() -> void:
clear_date()
func clear_date() -> void:
current_date = {}
text = "Select Date..."
emit_signal("date_selected", "")
popup.hide()
func _update_calendar() -> void:
month_year_lbl.text = str(MONTHS[view_date.month - 1]) + " " + str(view_date.year)
for child in days_grid.get_children():
child.queue_free()
var first_day_dict = {"year": view_date.year, "month": view_date.month, "day": 1}
var first_day_unix = Time.get_unix_time_from_datetime_dict(first_day_dict)
var first_day_weekday = Time.get_datetime_dict_from_unix_time(first_day_unix).weekday
# Godot weekday: 0 = Sunday, 1 = Monday...
for i in range(first_day_weekday):
var empty = Control.new()
days_grid.add_child(empty)
var days_in_month = _get_days_in_month(view_date.month, view_date.year)
for d in range(1, days_in_month + 1):
var btn = Button.new()
btn.text = str(d)
btn.custom_minimum_size = Vector2(32, 32)
if not current_date.is_empty() and current_date.year == view_date.year and current_date.month == view_date.month and current_date.day == d:
btn.add_theme_color_override("font_color", Color.YELLOW)
btn.pressed.connect(_on_day_clicked.bind(d))
days_grid.add_child(btn)
func _on_day_clicked(day: int) -> void:
current_date = {"year": view_date.year, "month": view_date.month, "day": day}
var date_str = "%04d-%02d-%02dT00:00:00Z" % [current_date.year, current_date.month, current_date.day]
text = "%04d-%02d-%02d" % [current_date.year, current_date.month, current_date.day]
emit_signal("date_selected", date_str)
popup.hide()
func get_date_iso() -> String:
if current_date.is_empty():
return ""
return "%04d-%02d-%02dT00:00:00Z" % [current_date.year, current_date.month, current_date.day]
func _get_days_in_month(month: int, year: int) -> int:
if month in [1, 3, 5, 7, 8, 10, 12]: return 31
if month in [4, 6, 9, 11]: return 30
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0): return 29
return 28
+1
View File
@@ -0,0 +1 @@
uid://0rwrfgvuqgrb
+42 -10
View File
@@ -1,12 +1,12 @@
extends HBoxContainer
extends PanelContainer
## FriendRow — Single row in the friends list inside SocialPanel.
@onready var _name_label: Label = %NameLabel
@onready var _state_label: Label = %StateLabel
@onready var _dm_btn: Button = %DMBtn
@onready var _accept_btn: Button = %AcceptBtn
@onready var _decline_btn: Button = %DeclineBtn
@onready var _remove_btn: Button = %RemoveBtn
@onready var _dm_btn: TextureButton = %DMBtn
@onready var _accept_btn: TextureButton = %AcceptBtn
@onready var _decline_btn: TextureButton = %DeclineBtn
@onready var _remove_btn: TextureButton = %RemoveBtn
var _user_id: String
var _username: String
@@ -19,17 +19,49 @@ func setup(uid: String, uname: String, state: int, panel: Control) -> void:
_name_label.text = uname
_dm_btn.visible = false
_accept_btn.visible = false
_decline_btn.visible = false
_remove_btn.visible = false
_state_label.visible = false
match state:
-1: # Search result (Not friend)
_accept_btn.visible = true
if not _accept_btn.pressed.is_connected(_on_accept):
_accept_btn.pressed.connect(_on_accept)
FriendManager.STATE_FRIEND:
_dm_btn.visible = true
_state_label.visible = true
_dm_btn.visible = true
_remove_btn.visible = true
_dm_btn.pressed.connect(func(): panel.call("open_dm", uid, uname))
_remove_btn.pressed.connect(func(): FriendManager.remove_friend(uid))
if not _dm_btn.pressed.is_connected(_on_dm):
_dm_btn.pressed.connect(_on_dm)
if not _remove_btn.pressed.is_connected(_on_remove):
_remove_btn.pressed.connect(_on_remove)
FriendManager.STATE_INVITE_OUT:
_state_label.text = "(invite sent)"
_state_label.visible = true
FriendManager.STATE_INVITE_IN:
_state_label.text = "Incoming Request"
_state_label.visible = true
_accept_btn.visible = true
_decline_btn.visible = true
_accept_btn.pressed.connect(func(): FriendManager.add_friend_by_id(uid))
_decline_btn.pressed.connect(func(): FriendManager.remove_friend(uid))
if not _accept_btn.pressed.is_connected(_on_accept):
_accept_btn.pressed.connect(_on_accept)
if not _decline_btn.pressed.is_connected(_on_remove):
_decline_btn.pressed.connect(_on_remove)
func _on_dm():
_panel.call("open_dm", _user_id, _username)
func _on_remove():
FriendManager.remove_friend(_user_id)
queue_free()
func _on_accept():
FriendManager.add_friend_by_id(_user_id)
_accept_btn.visible = false
_decline_btn.visible = false
_dm_btn.visible = true
_remove_btn.visible = true
_state_label.text = "LAST ONLINE: Just now"
+18 -1
View File
@@ -27,6 +27,10 @@ extends Control
@onready var register_button := %RegisterButton as Button
@onready var reg_status_label := %RegStatusLabel as Label
# Tab navigation buttons (left column)
@onready var sign_in_tab_btn := %SignInTabBtn as TextureButton
@onready var sign_up_tab_btn := %SignUpTabBtn as TextureButton
var current_captcha_answer: int = 0
var is_loading: bool = false
@@ -87,6 +91,10 @@ func _connect_signals() -> void:
lan_host_btn.pressed.connect(_on_lan_host_pressed)
lan_join_btn.pressed.connect(func(): _on_lan_join_pressed(lan_ip.text))
# Tab navigation buttons
sign_in_tab_btn.pressed.connect(func(): _switch_tab(0))
sign_up_tab_btn.pressed.connect(func(): _switch_tab(1))
# Enter key to submit
password_input.text_submitted.connect(func(_t): _on_login_pressed())
reg_confirm_password_input.text_submitted.connect(func(_t): _on_register_pressed())
@@ -95,7 +103,7 @@ func _setup_ui() -> void:
status_label.text = ""
reg_status_label.text = ""
loading_spinner.visible = false
tab_container.current_tab = 0
_switch_tab(0)
# Hide social buttons on platforms where they're not supported
_configure_social_buttons()
@@ -118,6 +126,15 @@ func _configure_social_buttons() -> void:
# Panel Switching
# =============================================================================
func _switch_tab(tab: int) -> void:
tab_container.current_tab = tab
_update_tab_visuals(tab)
_on_tab_changed(tab)
func _update_tab_visuals(tab: int) -> void:
sign_in_tab_btn.button_pressed = (tab == 0)
sign_up_tab_btn.button_pressed = (tab == 1)
func _on_tab_changed(tab: int) -> void:
if tab == 0:
status_label.text = ""
+180
View File
@@ -0,0 +1,180 @@
extends Panel
signal closed
@onready var close_btn := %CloseBtn as Button
@onready var mail_list_vbox := %MailListVBox as VBoxContainer
@onready var mail_title_lbl := %MailTitleLbl as Label
@onready var mail_content_text := %MailContentText as RichTextLabel
@onready var sender_lbl := %SenderLbl as Label
@onready var dynamic_rewards_container := %DynamicRewardsContainer as HBoxContainer
@onready var reward_hbox_template := %RewardHBoxTemplate as HBoxContainer
@onready var action_btn := %ActionBtn as Button
@onready var empty_state_lbl := %EmptyStateLbl as Label
var _current_mail: Dictionary = {}
func _ready() -> void:
visible = false
close_btn.pressed.connect(hide_panel)
action_btn.pressed.connect(_on_action_pressed)
if MailManager:
MailManager.mail_updated.connect(_refresh_ui)
func show_panel() -> void:
visible = true
_clear_details()
if MailManager:
MailManager.fetch_mails()
_refresh_ui()
func hide_panel() -> void:
visible = false
emit_signal("closed")
func _refresh_ui() -> void:
if not visible or not MailManager: return
for child in mail_list_vbox.get_children():
child.queue_free()
var mails = MailManager.mails
if mails.is_empty():
empty_state_lbl.visible = true
else:
empty_state_lbl.visible = false
for i in range(mails.size()):
var mail = mails[i]
var btn = _create_mail_button(mail)
mail_list_vbox.add_child(btn)
btn.pressed.connect(_on_mail_selected.bind(mail))
if not _current_mail.is_empty() and _current_mail.get("id") == mail.get("id"):
_current_mail = mail
_update_details(mail)
btn.button_pressed = true
if _current_mail.is_empty() and mails.size() > 0:
_on_mail_selected(mails[0])
@onready var mail_btn_template := %MailBtnTemplate as Button
func _create_mail_button(mail: Dictionary) -> Button:
var btn = mail_btn_template.duplicate()
btn.visible = true
var title = btn.get_node("VBox/Title") as Label
title.text = mail.get("title", "No Title")
var date_lbl = btn.get_node("VBox/HBox/DateLbl") as Label
var date_str = mail.get("date", "")
var expiry_str = mail.get("expiry_date", "")
var label_text = date_str.substr(0, 10) if date_str.length() >= 10 else date_str
if not expiry_str.is_empty():
var now = Time.get_unix_time_from_system()
var expiry_unix = Time.get_unix_time_from_datetime_string(expiry_str)
var diff = expiry_unix - now
if diff > 0:
var days = int(diff / 86400)
if days > 0:
label_text += " (Exp: %dd)" % days
else:
label_text += " (Exp: <1d)"
else:
label_text += " (Expired)"
date_lbl.text = label_text
var status_lbl = btn.get_node("VBox/HBox/StatusLbl") as Label
var mail_id = mail.get("id", "")
if mail_id in MailManager.claimed_ids:
status_lbl.text = "CLAIMED"
status_lbl.add_theme_color_override("font_color", Color.GREEN)
elif mail_id in MailManager.read_ids:
status_lbl.text = "READ"
status_lbl.add_theme_color_override("font_color", Color.GRAY)
else:
status_lbl.text = "NEW"
status_lbl.add_theme_color_override("font_color", Color.YELLOW)
return btn
func _on_mail_selected(mail: Dictionary) -> void:
for child in mail_list_vbox.get_children():
if child is Button:
child.button_pressed = false
_current_mail = mail
_update_details(mail)
MailManager.mark_as_read(mail.get("id", ""))
func _clear_details() -> void:
mail_title_lbl.text = ""
mail_content_text.text = ""
sender_lbl.text = ""
for child in dynamic_rewards_container.get_children():
if child.visible: child.queue_free()
action_btn.hide()
func _update_details(mail: Dictionary) -> void:
mail_title_lbl.text = mail.get("title", "No Title")
mail_content_text.text = mail.get("content", "")
sender_lbl.text = "SENDER:\n" + mail.get("sender", "SYSTEM")
for child in dynamic_rewards_container.get_children():
if child.visible: child.queue_free()
var rewards = mail.get("rewards", [])
var has_rewards = false
# Legacy dictionary support
if typeof(rewards) == TYPE_DICTIONARY:
var arr = []
if rewards.get("star", 0) > 0: arr.append({"type": "star", "amount": rewards.star})
if rewards.get("gold", 0) > 0: arr.append({"type": "gold", "amount": rewards.gold})
rewards = arr
for r in rewards:
has_rewards = true
var row = reward_hbox_template.duplicate()
row.visible = true
dynamic_rewards_container.add_child(row)
var amt_lbl = row.get_node("AmountLbl") as Label
var t = r.get("type", "star")
var amt = r.get("amount", 0)
var id = r.get("id", "")
if t == "star" or t == "gold":
amt_lbl.text = str(amt) + " " + t.to_upper()
else:
amt_lbl.text = str(amt) + " " + id
action_btn.show()
var mail_id = mail.get("id", "")
if has_rewards and mail_id not in MailManager.claimed_ids:
action_btn.text = "CLAIM"
action_btn.add_theme_color_override("font_color", Color.WHITE)
else:
action_btn.text = "DELETE"
action_btn.add_theme_color_override("font_color", Color.RED)
func _on_action_pressed() -> void:
if _current_mail.is_empty(): return
var mail_id = _current_mail.get("id", "")
if action_btn.text == "CLAIM":
action_btn.disabled = true
var ok = await MailManager.claim_reward(mail_id)
action_btn.disabled = false
if ok:
_update_details(_current_mail)
else:
action_btn.disabled = true
var ok = await MailManager.delete_mail(mail_id)
action_btn.disabled = false
if ok:
_current_mail = {}
_clear_details()
+1
View File
@@ -0,0 +1 @@
uid://b5fema68m6b2s
+32 -79
View File
@@ -9,31 +9,28 @@ signal closed
# Tab buttons
@onready var _search_tab_btn: Button = %SearchTabBtn
@onready var _requests_tab_btn: Button = %RequestsTabBtn
@onready var _friends_tab_btn: Button = %FriendsTabBtn
@onready var _dm_tab_btn: Button = %DMTabBtn
@onready var _dm_tab_btn: Button = get_node_or_null("%DMTabBtn")
# Views
@onready var _search_view: VBoxContainer = %SearchView
@onready var _requests_view: VBoxContainer = %RequestsView
@onready var _friends_view: VBoxContainer = %FriendsView
@onready var _dm_view: VBoxContainer = %DMView
@onready var _dm_view: PanelContainer = %DMView
# Search tab nodes
@onready var _search_input: LineEdit = %SearchInput
@onready var _search_btn: Button = %SearchBtn
@onready var _no_search_results: Label = %NoSearchResultsLabel
@onready var _search_results_list: VBoxContainer = %SearchResultsList
@onready var _search_result_tmpl: HBoxContainer = %SearchResultTemplate
@onready var _search_results_list: GridContainer = %SearchResultsList
# Requests tab nodes
@onready var _no_requests_label: Label = %NoRequestsLabel
@onready var _requests_list: VBoxContainer = %RequestsList
@onready var _request_row_tmpl: HBoxContainer = %RequestRowTemplate
# Friends tab nodes
@onready var _no_friends_label: Label = %NoFriendsLabel
@onready var _friend_list: VBoxContainer = %FriendList
@onready var _friend_list: GridContainer = %FriendList
# DM tab nodes
@onready var _dm_back_btn: Button = %DMBackBtn
@@ -53,10 +50,12 @@ func _ready() -> void:
# Tab buttons
_search_tab_btn.pressed.connect(func(): _show_tab("search"))
_requests_tab_btn.pressed.connect(func(): _show_tab("requests"))
_friends_tab_btn.pressed.connect(func(): _show_tab("friends"))
if _dm_tab_btn:
_dm_tab_btn.pressed.connect(func(): _show_tab("dm"))
_dm_back_btn.pressed.connect(func(): _show_tab("friends"))
# Search
_search_btn.pressed.connect(_on_search_pressed)
_search_input.text_submitted.connect(func(_t): _on_search_pressed())
@@ -81,7 +80,6 @@ func _ready() -> void:
func _show_tab(tab: String) -> void:
_current_tab = tab
_search_view.visible = tab == "search"
_requests_view.visible = tab == "requests"
_friends_view.visible = tab == "friends"
_dm_view.visible = tab == "dm"
@@ -121,60 +119,26 @@ func _populate_search_results(users: Array) -> void:
_no_search_results.visible = users.is_empty()
var my_id = NakamaManager.session.user_id if NakamaManager.session else ""
var friend_row_scene := preload("res://scenes/ui/friend_row.tscn")
for u in users:
if u.user_id == my_id:
continue # skip self
var row: HBoxContainer = _search_result_tmpl.duplicate()
row.show()
row.get_node("SRNameLabel").text = u.display_name + " (@" + u.username + ")"
var add_btn: Button = row.get_node("SRAddBtn")
add_btn.pressed.connect(func():
FriendManager.add_friend_by_id(u.user_id)
add_btn.text = "Sent ✓"
add_btn.disabled = true
)
var row: Control = friend_row_scene.instantiate()
_search_results_list.add_child(row)
row.setup(u.user_id, u.username, -1, self)
# ─── Requests Tab ────────────────────────────────────────────────────────────
func _populate_requests(incoming: Array) -> void:
for ch in _requests_list.get_children():
ch.queue_free()
_no_requests_label.visible = incoming.is_empty()
for f in incoming:
var row: HBoxContainer = _request_row_tmpl.duplicate()
row.show()
row.get_node("RRNameLabel").text = f.username
var accept_btn: Button = row.get_node("RRAcceptBtn")
var decline_btn: Button = row.get_node("RRDeclineBtn")
var uid: String = f.user_id
accept_btn.pressed.connect(func():
FriendManager.add_friend_by_id(uid)
row.queue_free()
)
decline_btn.pressed.connect(func():
FriendManager.remove_friend(uid)
row.queue_free()
)
_requests_list.add_child(row)
# Badge on tab button
if incoming.is_empty():
_requests_tab_btn.text = "Requests"
else:
_requests_tab_btn.text = "Requests (%d)" % incoming.size()
# ─── Friends Tab ─────────────────────────────────────────────────────────────
func _populate_friends(mutual: Array) -> void:
func _populate_friends(friends_array: Array) -> void:
for ch in _friend_list.get_children():
ch.queue_free()
_no_friends_label.visible = mutual.is_empty()
_no_friends_label.visible = friends_array.is_empty()
var friend_row_scene := preload("res://scenes/ui/friend_row.tscn")
for f in mutual:
for f in friends_array:
var row: Control = friend_row_scene.instantiate()
_friend_list.add_child(row)
row.setup(f.user_id, f.username, f.state, self)
@@ -182,35 +146,24 @@ func _populate_friends(mutual: Array) -> void:
# ─── FriendManager Callbacks ─────────────────────────────────────────────────
func _on_friends_updated(friends: Array) -> void:
print("[SocialPanel] _on_friends_updated: total=%d" % friends.size())
var incoming := friends.filter(func(f): return f.state == FriendManager.STATE_INVITE_IN)
var mutual := friends.filter(func(f): return f.state == FriendManager.STATE_FRIEND)
print("[SocialPanel] incoming=%d mutual=%d" % [incoming.size(), mutual.size()])
_populate_requests(incoming)
_populate_friends(mutual)
# Pass both incoming and mutual friends to the friends list
var display_list := friends.filter(func(f): return f.state == FriendManager.STATE_INVITE_IN or f.state == FriendManager.STATE_FRIEND)
# Also update the badge on the Friends tab if there are incoming requests
var incoming_count = friends.filter(func(f): return f.state == FriendManager.STATE_INVITE_IN).size()
if incoming_count > 0:
_friends_tab_btn.text = "FRIENDS (%d)" % incoming_count
else:
_friends_tab_btn.text = "FRIENDS"
_populate_friends(display_list)
signal dm_requested(user_id: String, username: String)
# ─── DM ──────────────────────────────────────────────────────────────────────
func open_dm(user_id: String, username: String) -> void:
_active_dm_user_id = user_id
_dm_username_label.text = "DM: %s" % username
_dm_tab_btn.visible = true
_dm_log.clear()
_show_tab("dm")
_dm_log.append_text("[i]Loading history...[/i]\n")
var history = await FriendManager.get_dm_history(user_id)
_dm_log.clear()
_dm_history[user_id] = []
var my_id = NakamaManager.session.user_id if NakamaManager.session else ""
for entry in history:
var is_self = entry.get("from") == my_id
var sender_name = "You" if is_self else username
_dm_history[user_id].append({"from": "me" if is_self else entry.get("from"), "msg": entry.get("msg")})
_dm_log.append_text("[b]%s:[/b] %s\n" % [sender_name, entry.get("msg", "")])
FriendManager.open_dm(user_id)
emit_signal("dm_requested", user_id, username)
func _send_dm() -> void:
var text = _dm_input.text.strip_edges()