feat: update chat

This commit is contained in:
2026-04-15 07:31:49 +08:00
parent a592eb1de0
commit 01661a56ba
9 changed files with 430 additions and 115 deletions
+200 -22
View File
@@ -2,20 +2,22 @@ extends Control
# UI References - Main Menu
@onready var main_menu_panel = $MainMenuPanel
@onready var main_title = $MainMenuPanel/VBoxContainer/TitleContainer/Title
@onready var main_subtitle = $MainMenuPanel/VBoxContainer/TitleContainer/Subtitle
@onready var create_room_btn = $MainMenuPanel/VBoxContainer/ButtonSection/CreateRoomBtn
@onready var browse_rooms_btn = $MainMenuPanel/VBoxContainer/ButtonSection/BrowseRoomsBtn
@onready var main_menu_profile_btn = $MainMenuPanel/VBoxContainer/ButtonSection/ProfileBtn
@onready var lobby_settings_btn = $MainMenuPanel/VBoxContainer/ButtonSection/SettingsBtn
@onready var quit_btn = $MainMenuPanel/VBoxContainer/ButtonSection/QuitBtn
@onready var main_title = $MainMenuPanel/HiddenLogic/Title
@onready var username_label = $MainMenuPanel/MainContainer/MarginContainer/LeftPanel/ProfileBox/VBoxContainer/Username
@onready var main_subtitle = $MainMenuPanel/MainContainer/MarginContainer/LeftPanel/ProfileBox/VBoxContainer/Subtitle
@onready var create_room_btn = $MainMenuPanel/MainContainer/MarginContainer/BottomRightPanel/CreateRoomBtn
@onready var browse_rooms_btn = $MainMenuPanel/MainContainer/MarginContainer/BottomRightPanel/BrowseRoomsBtn
@onready var tutorial_btn = $MainMenuPanel/MainContainer/MarginContainer/BottomRightPanel/TutorialBtn
@onready var main_menu_profile_btn = $MainMenuPanel/MainContainer/MarginContainer/LeftPanel/ProfileBox/ProfileBtn
@onready var lobby_settings_btn = $MainMenuPanel/MainContainer/MarginContainer/TopRightPanel/SettingsBtn
@onready var quit_btn = $MainMenuPanel/MainContainer/MarginContainer/LeftPanel/HBoxContainer/QuitBtn
# UI References - Server Selection
@onready var server_option = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerOption
@onready var server_ip_input = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerIPInput
@onready var server_option = $MainMenuPanel/HiddenLogic/ServerOption
@onready var server_ip_input = $MainMenuPanel/HiddenLogic/ServerIPInput
# Leaderboard Reference
@onready var leaderboard_btn = $MainMenuPanel/VBoxContainer/ButtonSection/LeaderboardBtn
@onready var leaderboard_btn = $MainMenuPanel/MainContainer/MarginContainer/TopRightPanel/LeaderboardBtn
# UI References - Room List
@onready var room_list_panel = $RoomListPanel
@@ -99,10 +101,22 @@ var leaderboard_panel_instance: Control
# Bot name tracking keyed by slot index to avoid re-generating on each update
var _bot_names: Dictionary = {}
# =============================================================================
# Chat System
# =============================================================================
const GLOBAL_CHAT_ROOM := "global_lobby"
var _chat_channel = null
var _chat_messages: Array = []
@onready var chat_display: RichTextLabel = $MainMenuPanel/MainContainer/MarginContainer/LeftPanel/ChatPanel/MarginContainer/RichTextLabel
@onready var chat_input: LineEdit = $MainMenuPanel/MainContainer/MarginContainer/LeftPanel/HBoxContainer/ChatInput
@onready var chat_send_btn: Button = $MainMenuPanel/MainContainer/MarginContainer/LeftPanel/HBoxContainer/SendBtn
# Server Selection Controls (Now in tscn)
# var server_option: OptionButton
# var server_ip_input: LineEdit
func _ready():
# Start background music for Lobby
MusicManager.start_music()
@@ -121,21 +135,11 @@ func _ready():
# Initial UI update
_on_profile_updated()
# Inject Tutorial button
var tutorial_btn = Button.new()
tutorial_btn.name = "TutorialBtn"
tutorial_btn.text = "PLAY TUTORIAL"
tutorial_btn.theme_type_variation = "FlatButton"
tutorial_btn.add_theme_font_size_override("font_size", 24)
tutorial_btn.pressed.connect(_on_tutorial_pressed)
var btn_section = main_menu_panel.get_node_or_null("VBoxContainer/ButtonSection")
if btn_section:
btn_section.add_child(tutorial_btn)
btn_section.move_child(tutorial_btn, 0)
# Connect button signals - Main Menu
create_room_btn.pressed.connect(_on_create_room_pressed)
browse_rooms_btn.pressed.connect(_on_browse_rooms_pressed)
if tutorial_btn:
tutorial_btn.pressed.connect(_on_tutorial_pressed)
if main_menu_profile_btn:
main_menu_profile_btn.pressed.connect(_on_profile_btn_pressed)
if lobby_settings_btn:
@@ -222,6 +226,12 @@ func _ready():
UserProfileManager.profile_loaded.connect(func(_p): _on_profile_updated())
UserProfileManager.profile_updated.connect(_on_profile_updated)
# Connect Chat UI
if chat_send_btn:
chat_send_btn.pressed.connect(_on_chat_send_pressed)
if chat_input:
chat_input.text_submitted.connect(func(_t): _on_chat_send_pressed())
# Set initial title if already loaded
_on_profile_updated()
@@ -232,6 +242,11 @@ func _ready():
if not LobbyManager.disconnect_reason.is_empty():
connection_status.text = LobbyManager.disconnect_reason
LobbyManager.disconnect_reason = ""
# Try to join global chat if already connected
if NakamaManager.is_connected_to_nakama():
_join_global_chat()
# =============================================================================
# Setup
@@ -831,6 +846,7 @@ func _on_area_changed(area_name: String) -> void:
func _on_connected_to_nakama() -> void:
connection_status.text = "Connected to server"
_join_global_chat()
func _on_connection_failed(error_message: String) -> void:
connection_status.text = "Connection failed: %s" % error_message
@@ -854,6 +870,16 @@ func _on_profile_updated() -> void:
if main_title:
main_title.text = display_name
if username_label:
username_label.text = display_name
if main_menu_profile_btn:
var avatar_url = UserProfileManager.get_avatar_url()
if ResourceLoader.exists(avatar_url):
main_menu_profile_btn.icon = load(avatar_url)
main_menu_profile_btn.expand_icon = true
main_menu_profile_btn.text = ""
# Sync to LobbyManager
LobbyManager.set_player_name(display_name)
@@ -998,3 +1024,155 @@ func _apply_loadout_character() -> void:
if idx != -1:
LobbyManager.local_character_index = idx
print("[Lobby] Loadout character applied: ", saved_char)
# =============================================================================
# Global Chat System
# =============================================================================
func _join_global_chat() -> void:
"""Join the persistent global lobby chat channel via Nakama socket."""
# Already in the channel — don't rejoin
if _chat_channel != null:
return
var socket = NakamaManager.socket
if not socket or not socket.is_connected_to_host():
push_warning("[Chat] Socket not connected, skipping chat join.")
return
# Join a persistent room channel named by our constant
var result = await socket.join_chat_async(GLOBAL_CHAT_ROOM,
NakamaSocket.ChannelType.Room,
true, # persistence = true (server stores history)
false) # hidden = false
if result.is_exception():
push_warning("[Chat] Failed to join global chat: " + result.get_exception().message)
return
_chat_channel = result
print("[Chat] Joined global channel: ", _chat_channel.id)
# Connect incoming message signal
if not socket.received_channel_message.is_connected(_on_chat_message_received):
socket.received_channel_message.connect(_on_chat_message_received)
# Load history and render (Nakama sends recent messages on join via received_channel_message)
_refresh_chat_display()
func _on_chat_message_received(message) -> void:
"""Nakama socket signal: a message arrived on any channel."""
# message is ApiChannelMessage — use direct property access, NOT .get()
if _chat_channel == null or message.channel_id != _chat_channel.id:
return
# content is a String (JSON) — parse to extract our "msg" field
var text: String = ""
var parsed = JSON.parse_string(message.content)
if typeof(parsed) == TYPE_DICTIONARY:
text = parsed.get("msg", message.content)
else:
text = message.content
# Sender: use username property directly (falls back to first 8 chars of sender_id)
var sender: String = message.username
if sender.is_empty():
sender = message.sender_id.substr(0, 8)
# Timestamp → HH:MM
var ts_str: String = _format_nakama_time(message.create_time)
_chat_messages.append({
"sender": sender,
"content": text,
"ts": ts_str,
"date": message.create_time.substr(0, 10)
})
_trim_old_messages()
_refresh_chat_display()
func _on_chat_send_pressed() -> void:
"""Send a message to the global chat channel."""
if chat_input == null or chat_input.text.strip_edges().is_empty():
return
var text = chat_input.text.strip_edges()
chat_input.text = ""
chat_input.grab_focus()
var socket = NakamaManager.socket
if not socket or _chat_channel == null:
# Offline fallback: show locally only
_inject_local_message(text)
return
# Nakama GDScript SDK: write_chat_message_async takes a Dictionary, not a JSON string
var content := {"msg": text}
var result = await socket.write_chat_message_async(_chat_channel.id, content)
if result.is_exception():
push_warning("[Chat] Failed to send message: " + result.get_exception().message)
# Still show it locally
_inject_local_message(text)
func _inject_local_message(text: String) -> void:
"""Display a message as the local player when offline/fallback."""
var display_name = UserProfileManager.get_display_name("You")
var ts_str = _get_local_time_hhmm()
_chat_messages.append({
"sender": display_name,
"content": text,
"ts": ts_str,
"date": Time.get_date_string_from_system()
})
_refresh_chat_display()
func _trim_old_messages() -> void:
"""Remove messages from previous calendar days (daily clear)."""
var today: String = Time.get_date_string_from_system()
_chat_messages = _chat_messages.filter(func(m): return m.get("date", today) == today)
func _refresh_chat_display() -> void:
"""Re-render the RichTextLabel with all buffered messages."""
if not chat_display:
return
chat_display.clear()
for msg in _chat_messages:
var ts: String = msg.get("ts", "")
var sender: String = msg.get("sender", "?")
var text: String = msg.get("content", "")
# Format: [dim][HH:MM] [/dim][b]Name:[/b] message
chat_display.append_text("[color=#888888][%s][/color] [b]%s:[/b] %s\n" % [ts, sender, text])
# Scroll to bottom
await get_tree().process_frame
chat_display.scroll_to_line(chat_display.get_line_count())
func _format_nakama_time(iso_str: String) -> String:
"""Convert Nakama ISO timestamp '2026-04-14T10:30:00Z' → local 'HH:MM'."""
# Parse the UTC time components
if iso_str.length() < 19:
return _get_local_time_hhmm()
var t_parts = iso_str.split("T")
if t_parts.size() < 2:
return _get_local_time_hhmm()
var time_part = t_parts[1].replace("Z", "").split(":")
if time_part.size() < 2:
return _get_local_time_hhmm()
# Use UTC hours/minutes directly (simple, avoids TZ complexity in Godot)
return "%s:%s" % [time_part[0], time_part[1]]
func _get_local_time_hhmm() -> String:
var t = Time.get_time_dict_from_system()
return "%02d:%02d" % [t.hour, t.minute]
func _leave_global_chat() -> void:
"""Leave the chat channel cleanly."""
var socket = NakamaManager.socket
if socket and _chat_channel:
socket.received_channel_message.disconnect(_on_chat_message_received)
await socket.leave_chat_async(_chat_channel.id)
_chat_channel = null