feat: expand multiplayer, chat, and release support

- add network NPC synchronization and dialogue interactions
- add world bulletin publishing and display
- improve authentication, session refresh, and appearance sync
- synchronize player direction and movement animations
- improve input focus and progressive Web loading
- add macOS/Windows builds and deployment configuration
- include required fonts, shaders, and runtime assets
This commit is contained in:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

View File

@@ -56,6 +56,9 @@ extends Control
@onready var friends_list_surface: Control = %FriendsListSurface
@onready var add_friend_row: HBoxContainer = %AddFriendRow
@onready var add_friend_button: Button = %AddFriendButton
@onready var tabs: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/Tabs
@onready var panel_margin: MarginContainer = $ChatPanel/PanelMargin
@onready var input_row: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/InputRow
# ============================================================================
# 预加载资源
@@ -80,6 +83,9 @@ const TAB_WHISPER: String = "whisper"
const TAB_FRIENDS: String = "friends"
const MAX_DISPLAYED_MESSAGES: int = 100
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
const SEND_ICON := preload("res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png")
const STATIC_NPC_DIALOGUE_PREFIX: String = "static_npc_dialogue:"
const DEFAULT_WHISPER_TAB_TEXT: String = "悄悄话"
# ============================================================================
# 成员变量
@@ -106,6 +112,9 @@ var _current_tab: String = TAB_WORLD
# 悄悄话目标(靠近玩家按 E 后设置)
var _whisper_target_user_id: String = ""
var _whisper_target_username: String = ""
var _npc_target_id: String = ""
var _npc_target_name: String = ""
var _npc_session_id: String = ""
# 好友私聊目标(好友列表接入后复用同一私聊协议)
var _friend_target_user_id: String = ""
@@ -118,6 +127,12 @@ var _friends_status_message: String = ""
var _messages: Array[Dictionary] = []
var _send_failure_handled_by_ui: bool = false
var _npc_thinking_row: Control
var _npc_thinking_timer: Timer
var _npc_dialogue_mode: bool = false
var _npc_dialogue_read_only: bool = false
var _default_chat_panel_style: StyleBox
var _default_input_shell_style: StyleBox
# ============================================================================
# 生命周期方法
@@ -126,12 +141,17 @@ var _send_failure_handled_by_ui: bool = false
# 准备就绪
func _ready() -> void:
_configure_mouse_focus()
_configure_send_icon()
_capture_default_dialogue_styles()
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
get_viewport().size_changed.connect(_on_viewport_size_changed)
# 初始隐藏聊天框
hide_chat(true)
# 创建隐藏计时器
_create_hide_timer()
_create_npc_thinking_timer()
# 订阅事件Call Down via EventSystem
_subscribe_to_events()
@@ -144,6 +164,134 @@ func _ready() -> void:
_update_tab_visuals()
_update_bubble_send_button_visibility()
func _configure_send_icon() -> void:
if not is_instance_valid(send_button):
return
send_button.text = ""
send_button.icon = SEND_ICON
send_button.expand_icon = true
send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
func _capture_default_dialogue_styles() -> void:
if is_instance_valid(chat_panel):
_default_chat_panel_style = chat_panel.get_theme_stylebox("panel").duplicate() as StyleBox
if is_instance_valid(input_shell):
_default_input_shell_style = input_shell.get_theme_stylebox("panel").duplicate() as StyleBox
func _set_npc_dialogue_mode(enabled: bool, readOnly: bool = false) -> void:
_npc_dialogue_mode = enabled
_npc_dialogue_read_only = enabled and readOnly
var worldTab := popular_tab_button.get_parent() as Control if is_instance_valid(popular_tab_button) else null
var whisperTab := recent_tab_button.get_parent() as Control if is_instance_valid(recent_tab_button) else null
var friendsTab := friends_tab_button.get_parent() as Control if is_instance_valid(friends_tab_button) else null
if is_instance_valid(worldTab):
worldTab.visible = not enabled
if is_instance_valid(friendsTab):
friendsTab.visible = not enabled
if is_instance_valid(whisperTab):
whisperTab.visible = true
if is_instance_valid(recent_tab_button):
recent_tab_button.text = _npc_target_name if enabled else DEFAULT_WHISPER_TAB_TEXT
recent_tab_button.alignment = HORIZONTAL_ALIGNMENT_LEFT if enabled else HORIZONTAL_ALIGNMENT_CENTER
recent_tab_button.mouse_filter = Control.MOUSE_FILTER_IGNORE if enabled else Control.MOUSE_FILTER_STOP
if is_instance_valid(input_row):
input_row.visible = not _npc_dialogue_read_only
if is_instance_valid(chat_panel):
var panelStyle := _create_npc_dialogue_style() if enabled else _default_chat_panel_style
if panelStyle != null:
chat_panel.add_theme_stylebox_override("panel", panelStyle)
if is_instance_valid(input_shell):
var inputStyle := _create_npc_dialogue_input_style() if enabled else _default_input_shell_style
if inputStyle != null:
input_shell.add_theme_stylebox_override("panel", inputStyle)
if is_instance_valid(panel_margin):
var horizontalMargin := 24 if enabled else 20
var verticalMargin := 18
panel_margin.add_theme_constant_override("margin_left", horizontalMargin)
panel_margin.add_theme_constant_override("margin_top", verticalMargin)
panel_margin.add_theme_constant_override("margin_right", horizontalMargin)
panel_margin.add_theme_constant_override("margin_bottom", verticalMargin)
_apply_chat_panel_layout()
_update_tab_visuals()
if is_instance_valid(message_list):
_rerender_messages()
func _clear_npc_dialogue_target() -> void:
_npc_target_id = ""
_npc_target_name = ""
_npc_session_id = ""
_set_npc_dialogue_mode(false, false)
func _apply_chat_panel_layout() -> void:
if not is_instance_valid(chat_panel):
return
if not _npc_dialogue_mode:
chat_panel.anchor_left = 0.024
chat_panel.anchor_top = 1.0
chat_panel.anchor_right = 0.024
chat_panel.anchor_bottom = 1.0
chat_panel.offset_left = 0.0
chat_panel.offset_top = -448.0
chat_panel.offset_right = 548.0
chat_panel.offset_bottom = -32.0
return
var viewportSize := get_viewport_rect().size
var dialogWidth := clampf(viewportSize.x * 0.90, 320.0, 760.0)
var minimumHeight := 200.0 if _npc_dialogue_read_only else 250.0
var maximumHeight := 250.0 if _npc_dialogue_read_only else 340.0
var dialogHeight := clampf(viewportSize.y * 0.42, minimumHeight, maximumHeight)
var bottomMargin := clampf(viewportSize.y * 0.04, 18.0, 34.0)
chat_panel.anchor_left = 0.5
chat_panel.anchor_top = 1.0
chat_panel.anchor_right = 0.5
chat_panel.anchor_bottom = 1.0
chat_panel.offset_left = -dialogWidth * 0.5
chat_panel.offset_top = -bottomMargin - dialogHeight
chat_panel.offset_right = dialogWidth * 0.5
chat_panel.offset_bottom = -bottomMargin
func _on_viewport_size_changed() -> void:
_apply_chat_panel_layout()
func _create_npc_dialogue_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.985, 0.91, 0.985)
style.border_color = Color(0.055, 0.22, 0.235, 1.0)
style.border_width_left = 4
style.border_width_top = 4
style.border_width_right = 4
style.border_width_bottom = 4
style.corner_radius_top_left = 5
style.corner_radius_top_right = 5
style.corner_radius_bottom_left = 5
style.corner_radius_bottom_right = 5
style.shadow_color = Color(0.02, 0.06, 0.07, 0.3)
style.shadow_size = 8
style.shadow_offset = Vector2(0, 5)
return style
func _create_npc_dialogue_input_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 1.0, 0.98, 1.0)
style.border_color = Color(0.22, 0.42, 0.42, 0.82)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_left = 4
style.corner_radius_bottom_right = 4
style.content_margin_left = 14
style.content_margin_top = 6
style.content_margin_right = 14
style.content_margin_bottom = 6
return style
# 清理
func _exit_tree() -> void:
# 取消事件订阅
@@ -155,6 +303,8 @@ func _exit_tree() -> void:
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, self)
eventSystem.call("disconnect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
eventSystem.call("disconnect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
eventSystem.call("disconnect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
@@ -162,6 +312,11 @@ func _exit_tree() -> void:
# 清理计时器
if _hide_timer:
_hide_timer.queue_free()
if _npc_thinking_timer:
_npc_thinking_timer.queue_free()
_hide_npc_thinking()
if get_viewport() != null and get_viewport().size_changed.is_connected(_on_viewport_size_changed):
get_viewport().size_changed.disconnect(_on_viewport_size_changed)
if is_instance_valid(_transition_tween):
_transition_tween.kill()
@@ -192,6 +347,10 @@ func _input(event: InputEvent) -> void:
var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo:
return
if key_event.keycode == KEY_ESCAPE and _is_chat_visible:
hide_chat()
get_viewport().set_input_as_handled()
return
# T 键用于唤起聊天(输入框聚焦时不拦截)
if key_event.keycode == KEY_T and not chat_input.has_focus():
@@ -264,6 +423,9 @@ func _update_input_placeholder() -> void:
if _current_tab == TAB_WHISPER and not _whisper_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _whisper_target_username
return
if _current_tab == TAB_WHISPER and not _npc_target_name.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _npc_target_name
return
if _current_tab == TAB_FRIENDS and not _friend_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _friend_target_username
@@ -338,6 +500,8 @@ func show_chat(immediate: bool = false) -> void:
# 隐藏聊天框
func hide_chat(immediate: bool = false) -> void:
_end_npc_session()
_clear_npc_dialogue_target()
_is_chat_visible = false
_is_typing = false
@@ -374,6 +538,13 @@ func _create_hide_timer() -> void:
_hide_timer.timeout.connect(_on_hide_timeout)
add_child(_hide_timer)
func _create_npc_thinking_timer() -> void:
_npc_thinking_timer = Timer.new()
_npc_thinking_timer.wait_time = 60.0
_npc_thinking_timer.one_shot = true
_npc_thinking_timer.timeout.connect(_on_npc_thinking_timeout)
add_child(_npc_thinking_timer)
# 开始隐藏倒计时
func _start_hide_timer() -> void:
if _is_typing:
@@ -508,6 +679,17 @@ func _send_input_message(show_bubble: bool) -> void:
# 清空输入框
chat_input.clear()
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
_add_message_data({
"from_user": _current_username,
"content": content,
"timestamp": Time.get_unix_time_from_system(),
"is_self": true,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": _npc_target_id,
})
_show_npc_thinking()
# 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
call_deferred("_focus_input_after_send")
@@ -565,6 +747,8 @@ func _subscribe_to_events() -> void:
# 订阅近身悄悄话目标选择事件
eventSystem.call("connect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
eventSystem.call("connect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
eventSystem.call("connect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
# 订阅右下角好友列表的好友选择事件
eventSystem.call("connect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
@@ -611,9 +795,83 @@ func _on_chat_error(data: Dictionary) -> void:
if _current_tab == TAB_FRIENDS:
_render_friend_conversation_header()
return
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
_hide_npc_thinking()
if not message.strip_edges().is_empty():
_add_system_message(message)
func _on_npc_spoke(data: Dictionary) -> void:
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
if _npc_target_id.is_empty() or npc_id != _npc_target_id:
return
var response := str(data.get("response", "")).strip_edges()
if response.is_empty():
return
_hide_npc_thinking()
var session_id := str(data.get("session_id", data.get("sessionId", ""))).strip_edges()
if not session_id.is_empty():
_npc_session_id = session_id
_add_message_data({
"from_user": str(data.get("npc_name", data.get("npcName", _npc_target_name))),
"content": response,
"timestamp": Time.get_unix_time_from_system(),
"is_self": false,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": npc_id,
})
func _on_npc_interaction_error(data: Dictionary) -> void:
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
if not _npc_target_id.is_empty() and not npc_id.is_empty() and npc_id != _npc_target_id:
return
_hide_npc_thinking()
var message := str(data.get("message", "NPC暂时无法回应")).strip_edges()
if not message.is_empty():
_add_system_message(message)
func _show_npc_thinking() -> void:
_hide_npc_thinking()
if _npc_target_id.is_empty() or _current_tab != TAB_WHISPER:
return
if not is_instance_valid(message_list):
return
var row := HBoxContainer.new()
row.name = "NpcThinkingRow"
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
message_list.add_child(row)
var message_node := chat_message_scene.instantiate() as Control
if message_node == null:
row.queue_free()
return
row.add_child(message_node)
if message_node.has_method("set_message"):
message_node.call("set_message", _npc_target_name, "正在思考...", Time.get_unix_time_from_system(), false)
if message_node.has_method("set_dialogue_mode"):
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
message_node.modulate = Color(1.0, 1.0, 1.0, 0.78)
_npc_thinking_row = row
if is_instance_valid(_npc_thinking_timer):
_npc_thinking_timer.start()
call_deferred("_scroll_to_bottom")
func _hide_npc_thinking() -> void:
if is_instance_valid(_npc_thinking_timer):
_npc_thinking_timer.stop()
if is_instance_valid(_npc_thinking_row):
_npc_thinking_row.queue_free()
_npc_thinking_row = null
func _on_npc_thinking_timeout() -> void:
if not is_instance_valid(_npc_thinking_row):
return
_hide_npc_thinking()
_add_system_message("NPC暂时没有回应请稍后再试")
# 处理连接状态变化
func _on_connection_state_changed(_data: Dictionary) -> void:
# 连接状态变化处理当前不更新UI
@@ -699,6 +957,8 @@ func _send_current_tab_message(chatManager: Node, content: String, show_bubble:
TAB_WORLD:
return bool(chatManager.call("send_chat_message", content, "global", show_bubble))
TAB_WHISPER:
if not _npc_target_id.is_empty():
return bool(chatManager.call("interact_with_world_npc", _npc_target_id, content, _npc_session_id))
if _whisper_target_user_id.is_empty():
_add_system_message("请靠近玩家按 E 发起悄悄话")
_send_failure_handled_by_ui = true
@@ -734,6 +994,8 @@ func start_whisper(user_id: String, username: String = "") -> void:
if normalized_user_id.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_whisper_target_user_id = normalized_user_id
_whisper_target_username = username.strip_edges()
if _whisper_target_username.is_empty():
@@ -744,6 +1006,66 @@ func start_whisper(user_id: String, username: String = "") -> void:
show_chat(true)
call_deferred("_focus_input_after_send")
func start_npc_whisper(npc_id: String, npc_name: String = "NPC", greeting: String = "") -> void:
var normalized_id := npc_id.strip_edges()
if normalized_id.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_hide_npc_thinking()
_npc_target_id = normalized_id
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
_npc_session_id = ""
_whisper_target_user_id = ""
_whisper_target_username = ""
select_tab(TAB_WHISPER)
_set_npc_dialogue_mode(true, false)
show_chat(true)
if not greeting.strip_edges().is_empty():
_add_message_data({
"from_user": _npc_target_name,
"content": greeting.strip_edges(),
"timestamp": Time.get_unix_time_from_system(),
"is_self": false,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": _npc_target_id,
})
call_deferred("_focus_input_after_send")
func show_npc_dialogue(npc_name: String, text: String) -> void:
var normalizedText := text.strip_edges()
if normalizedText.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_hide_npc_thinking()
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
_npc_target_id = "%s%s:%d" % [STATIC_NPC_DIALOGUE_PREFIX, _npc_target_name, Time.get_ticks_msec()]
_npc_session_id = ""
_whisper_target_user_id = ""
_whisper_target_username = ""
select_tab(TAB_WHISPER)
_set_npc_dialogue_mode(true, true)
show_chat(true)
_add_message_data({
"from_user": _npc_target_name,
"content": normalizedText,
"timestamp": Time.get_unix_time_from_system(),
"is_self": false,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": _npc_target_id,
})
func _end_npc_session() -> void:
if _npc_target_id.is_empty() or _npc_session_id.is_empty() or _npc_target_id.begins_with(STATIC_NPC_DIALOGUE_PREFIX):
return
var chat_manager := _get_chat_manager()
if chat_manager != null and chat_manager.has_method("end_world_npc_session"):
chat_manager.call("end_world_npc_session", _npc_target_id, _npc_session_id)
_npc_session_id = ""
func add_whisper_target_as_friend() -> bool:
if _whisper_target_user_id.is_empty():
_add_system_message("请先靠近玩家按 F")
@@ -780,6 +1102,8 @@ func select_friend_private_target(user_id: String, username: String = "") -> voi
if normalized_user_id.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_friend_target_user_id = normalized_user_id
_friend_target_username = username.strip_edges()
if _friend_target_username.is_empty():
@@ -934,15 +1258,23 @@ func _add_message_data(message: Dictionary) -> void:
_render_message(message)
func _render_message(message: Dictionary) -> void:
# 如果聊天框隐藏,自动显示
# 世界频道消息进入公告 HUD不自动抢占地图视野
# 私聊、NPC 回复和用户主动发送的消息仍可唤起聊天面板。
if not _is_chat_visible:
var message_scope := str(message.get("scope", "global")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or message_scope == "private"
var is_npc := not str(message.get("npc_id", "")).strip_edges().is_empty()
# 系统存在/欢迎消息也属于世界公告,不应自动打开旧聊天窗口。
# 只有私聊和 NPC 会话需要在收到回复时主动唤起聊天 UI。
if not is_private and not is_npc:
return
show_chat()
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.alignment = BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN
row.alignment = BoxContainer.ALIGNMENT_BEGIN if _npc_dialogue_mode else (BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN)
# 创建消息节点
var message_node: Control = chat_message_scene.instantiate() as Control
@@ -962,6 +1294,8 @@ func _render_message(message: Dictionary) -> void:
float(message.get("timestamp", 0.0)),
bool(message.get("is_self", false))
)
if message_node.has_method("set_dialogue_mode"):
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
# 自动滚动到底部
call_deferred("_scroll_to_bottom")
@@ -993,6 +1327,9 @@ func _message_matches_current_tab(message: Dictionary) -> bool:
return true
func _private_message_matches_current_tab(message: Dictionary) -> bool:
var npc_id := str(message.get("npc_id", message.get("npcId", ""))).strip_edges()
if _current_tab == TAB_WHISPER and not _npc_target_id.is_empty():
return npc_id == _npc_target_id
var scope := str(message.get("scope", "local")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or scope == "private"
if not is_private: