1626 lines
55 KiB
GDScript
1626 lines
55 KiB
GDScript
extends Node
|
||
|
||
# ============================================================================
|
||
# ChatManager.gd - 聊天系统业务逻辑核心
|
||
# ============================================================================
|
||
# 管理聊天功能的核心业务逻辑
|
||
#
|
||
# 核心职责:
|
||
# - 聊天消息发送/接收协调
|
||
# - 客户端频率限制(10条/分钟)
|
||
# - 消息历史管理(最多100条)
|
||
# - Signal Up: 通过信号和 EventSystem 向上通知
|
||
# - 整合 AuthManager 获取 token
|
||
#
|
||
# 使用方式:
|
||
# ChatManager.connect_to_chat_server()
|
||
# ChatManager.send_chat_message("Hello", "global")
|
||
# ChatManager.chat_message_received.connect(_on_message_received)
|
||
#
|
||
# 注意事项:
|
||
# - 作为自动加载单例,全局可访问
|
||
# - 遵循 "Signal Up, Call Down" 架构
|
||
# - 所有聊天事件通过 EventSystem 广播
|
||
# ============================================================================
|
||
|
||
# ============================================================================
|
||
# 信号定义 (Signal Up)
|
||
# ============================================================================
|
||
|
||
# 聊天消息已发送信号
|
||
# 参数:
|
||
# message_id: String - 消息 ID
|
||
# timestamp: float - 时间戳
|
||
signal chat_message_sent(message_id: String, timestamp: float)
|
||
|
||
# 聊天消息已接收信号
|
||
# 参数:
|
||
# from_user: String - 发送者用户名
|
||
# content: String - 消息内容
|
||
# show_bubble: bool - 是否显示气泡
|
||
# timestamp: float - 时间戳
|
||
signal chat_message_received(from_user: String, content: String, show_bubble: bool, timestamp: float)
|
||
|
||
# 聊天错误发生信号
|
||
# 参数:
|
||
# error_code: String - 错误代码
|
||
# message: String - 错误消息
|
||
signal chat_error_occurred(error_code: String, message: String)
|
||
|
||
# 聊天连接状态变化信号
|
||
# 参数:
|
||
# state: int - 连接状态(0=DISCONNECTED, 1=CONNECTING, 2=CONNECTED, 3=RECONNECTING, 4=ERROR)
|
||
signal chat_connection_state_changed(state: int)
|
||
|
||
# 位置更新成功信号
|
||
# 参数:
|
||
# stream: String - Stream 名称
|
||
# topic: String - Topic 名称
|
||
signal chat_position_updated(stream: String, topic: String)
|
||
|
||
# ============================================================================
|
||
# 常量定义
|
||
# ============================================================================
|
||
|
||
const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebSocketManager.gd")
|
||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||
|
||
# WebSocket 服务器 URL(原生 WebSocket)
|
||
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||
|
||
# 重连配置
|
||
const RECONNECT_MAX_ATTEMPTS: int = 5
|
||
const RECONNECT_BASE_DELAY: float = 3.0
|
||
|
||
# 频率限制配置
|
||
const RATE_LIMIT_MESSAGES: int = 10
|
||
const RATE_LIMIT_WINDOW: float = 60.0 # 秒
|
||
|
||
# 消息限制
|
||
const MAX_MESSAGE_LENGTH: int = 1000
|
||
|
||
# 当前会话消息限制(当前游戏会话,超过后删除最旧的)
|
||
const MAX_SESSION_MESSAGES: int = 100
|
||
|
||
# 历史消息分页大小(从 Zulip 后端每次加载的数量)
|
||
const HISTORY_PAGE_SIZE: int = 100
|
||
|
||
const GAME_TOKEN_ENV_KEY: String = "WHALETOWN_GAME_TOKEN"
|
||
const MAX_QUEUED_MESSAGES: int = 10
|
||
const MAX_RECOVERABLE_MESSAGES: int = 5
|
||
|
||
# 错误消息映射
|
||
const CHAT_ERROR_MESSAGES: Dictionary = {
|
||
"AUTH_FAILED": "请先登录后再使用聊天",
|
||
"RATE_LIMIT": "消息发送过于频繁,请稍后再试",
|
||
"CONTENT_FILTERED": "消息内容包含违规内容",
|
||
"CONTENT_TOO_LONG": "消息内容过长(最大1000字符)",
|
||
"PERMISSION_DENIED": "您没有权限发送消息",
|
||
"SESSION_EXPIRED": "会话已过期,请重新连接",
|
||
"ZULIP_ERROR": "消息服务暂时不可用",
|
||
"INTERNAL_ERROR": "服务器内部错误"
|
||
}
|
||
|
||
# ============================================================================
|
||
# 成员变量
|
||
# ============================================================================
|
||
|
||
# WebSocket 管理器
|
||
var _websocket_manager: Node
|
||
var _history_request: HTTPRequest
|
||
|
||
# 是否已登录
|
||
var _is_logged_in: bool = false
|
||
|
||
# 消息历史记录(当前会话,最多100条,超过后删除最旧的)
|
||
var _message_history: Array[Dictionary] = []
|
||
|
||
# 历史消息加载状态
|
||
var _history_loading: bool = false
|
||
var _has_more_history: bool = true
|
||
var _oldest_message_timestamp: float = 0.0
|
||
|
||
# 消息发送时间戳(用于频率限制)
|
||
var _message_timestamps: Array[float] = []
|
||
|
||
# 当前用户信息
|
||
var _current_username: String = ""
|
||
var _current_map: String = ""
|
||
var _world_ready_map: String = ""
|
||
|
||
# 游戏 token
|
||
var _game_token: String = ""
|
||
|
||
# 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。
|
||
var _queued_messages: Array[Dictionary] = []
|
||
var _recoverable_messages: Array[Dictionary] = []
|
||
|
||
# 发送后本地回显去重(避免服务端也回发导致重复显示)
|
||
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
|
||
var _pending_self_messages: Array[Dictionary] = []
|
||
|
||
# 好友列表缓存
|
||
var _friends: Array[Dictionary] = []
|
||
var _friend_protocol_supported: bool = true
|
||
|
||
# 空消息类型告警限频(避免日志刷屏)
|
||
const EMPTY_MESSAGE_TYPE_WARNING_INTERVAL: float = 10.0
|
||
var _last_empty_message_type_warning_at: float = -1000.0
|
||
var _refreshing_auth_session: bool = false
|
||
var _chat_login_refresh_attempted: bool = false
|
||
|
||
# ============================================================================
|
||
# 生命周期方法
|
||
# ============================================================================
|
||
|
||
# 初始化
|
||
func _ready() -> void:
|
||
_load_token_from_environment()
|
||
|
||
# 创建 WebSocket 管理器
|
||
_websocket_manager = CHAT_WEBSOCKET_MANAGER_SCRIPT.new()
|
||
add_child(_websocket_manager)
|
||
_history_request = HTTPRequest.new()
|
||
_history_request.timeout = 12.0
|
||
_history_request.request_completed.connect(_on_history_request_completed)
|
||
add_child(_history_request)
|
||
|
||
# 连接信号
|
||
_connect_signals()
|
||
var authManager := get_node_or_null("/root/AuthManager")
|
||
if authManager != null and authManager.has_signal("profile_update_succeeded"):
|
||
authManager.connect("profile_update_succeeded", _on_account_profile_updated)
|
||
if authManager != null and authManager.has_signal("auth_state_changed"):
|
||
authManager.connect("auth_state_changed", _on_auth_state_changed)
|
||
|
||
# 清理
|
||
func _exit_tree() -> void:
|
||
if is_instance_valid(_websocket_manager):
|
||
_websocket_manager.queue_free()
|
||
|
||
func _get_event_system() -> Node:
|
||
return get_node_or_null("/root/EventSystem")
|
||
|
||
func _emit_event(event_name: String, data: Variant = null) -> void:
|
||
var eventSystem: Node = _get_event_system()
|
||
if eventSystem == null:
|
||
return
|
||
|
||
eventSystem.call("emit_event", event_name, data)
|
||
|
||
# ============================================================================
|
||
# 公共 API - Token 管理
|
||
# ============================================================================
|
||
|
||
# 设置游戏 token
|
||
#
|
||
# 参数:
|
||
# token: String - 游戏认证 token
|
||
#
|
||
# 使用示例:
|
||
# ChatManager.set_game_token("your_game_token")
|
||
func set_game_token(token: String) -> void:
|
||
var normalizedToken := token.strip_edges()
|
||
if normalizedToken != _game_token:
|
||
_chat_login_refresh_attempted = false
|
||
_game_token = normalizedToken
|
||
if _game_token.strip_edges().is_empty():
|
||
_is_logged_in = false
|
||
_queued_messages.clear()
|
||
_recoverable_messages.clear()
|
||
_world_ready_map = ""
|
||
|
||
func is_logged_in() -> bool:
|
||
return _is_logged_in
|
||
|
||
func mark_world_ready(mapId: String, position: Vector2) -> void:
|
||
var normalizedMapId := mapId.strip_edges()
|
||
if not _is_logged_in or normalizedMapId.is_empty() or _world_ready_map == normalizedMapId:
|
||
return
|
||
|
||
var payload := {
|
||
"type": "world_ready",
|
||
"mapId": normalizedMapId,
|
||
"x": position.x,
|
||
"y": position.y,
|
||
}
|
||
if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
|
||
_world_ready_map = normalizedMapId
|
||
|
||
func leave_world(sceneId: String = "private_space") -> void:
|
||
if not _is_logged_in or _world_ready_map.is_empty():
|
||
return
|
||
var normalizedSceneId := sceneId.strip_edges()
|
||
if normalizedSceneId.is_empty():
|
||
normalizedSceneId = "private_space"
|
||
var payload := {
|
||
"type": "leave_world",
|
||
"sceneId": normalizedSceneId,
|
||
}
|
||
if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
|
||
_world_ready_map = ""
|
||
|
||
func notify_appearance_changed() -> void:
|
||
if not _is_logged_in or _world_ready_map.is_empty():
|
||
return
|
||
_websocket_manager.send_message(JSON.stringify({"type": "appearance_changed"}))
|
||
|
||
# 获取游戏 token
|
||
#
|
||
# 返回值:
|
||
# String - 当前游戏 token
|
||
func get_game_token() -> String:
|
||
return _game_token
|
||
|
||
# ============================================================================
|
||
# 公共 API - 连接管理
|
||
# ============================================================================
|
||
|
||
# 连接到聊天服务器
|
||
func connect_to_chat_server() -> void:
|
||
if _websocket_manager.is_websocket_connected():
|
||
push_warning("聊天服务器已连接")
|
||
return
|
||
|
||
var connection_state: int = _websocket_manager.call("get_connection_state")
|
||
if connection_state == 1 or connection_state == 3:
|
||
push_warning("聊天服务器正在连接")
|
||
return
|
||
|
||
if _game_token.strip_edges().is_empty():
|
||
var authToken := _get_auth_manager_token()
|
||
if not authToken.is_empty():
|
||
_game_token = authToken
|
||
|
||
if _game_token.strip_edges().is_empty():
|
||
_handle_error("AUTH_FAILED", "请先登录后再使用聊天")
|
||
return
|
||
|
||
_websocket_manager.connect_to_game_server()
|
||
|
||
# 断开聊天服务器
|
||
func disconnect_from_chat_server() -> void:
|
||
_queued_messages.clear()
|
||
_recoverable_messages.clear()
|
||
|
||
# 发送登出消息
|
||
if _is_logged_in:
|
||
var logout_data := {"type": "logout"}
|
||
_websocket_manager.send_message(JSON.stringify(logout_data))
|
||
_is_logged_in = false
|
||
|
||
# 断开连接
|
||
_websocket_manager.disconnect_websocket()
|
||
|
||
# 检查是否已连接
|
||
#
|
||
# 返回值:
|
||
# bool - 是否已连接
|
||
func is_chat_connected() -> bool:
|
||
return _websocket_manager.is_websocket_connected()
|
||
|
||
func is_chat_logged_in() -> bool:
|
||
return _is_logged_in
|
||
|
||
func can_attempt_chat_connection() -> bool:
|
||
if not _game_token.strip_edges().is_empty():
|
||
return true
|
||
if not _get_auth_manager_token().is_empty():
|
||
return true
|
||
return false
|
||
|
||
# ============================================================================
|
||
# 公共 API - 聊天操作
|
||
# ============================================================================
|
||
|
||
# 发送聊天消息
|
||
#
|
||
# 参数:
|
||
# content: String - 消息内容
|
||
# scope: String - 消息范围("local" / "global")
|
||
# show_bubble: bool - 是否同步显示角色气泡
|
||
#
|
||
# 使用示例:
|
||
# ChatManager.send_chat_message("Hello, world!", "global")
|
||
func send_chat_message(content: String, scope: String = "local", show_bubble: bool = false) -> bool:
|
||
return _send_chat_payload(content, scope, {"bubble": show_bubble and _settings_bool("show_chat_bubbles", true)})
|
||
|
||
func send_private_message(content: String, target_user_id: String, target_username: String = "", private_context: String = "") -> bool:
|
||
if not _settings_bool("allow_nearby_private", true) and private_context.strip_edges() == "whisper":
|
||
_handle_error("PRIVATE_DISABLED", "附近私聊已在设置中关闭")
|
||
return false
|
||
|
||
var normalized_target_user_id := target_user_id.strip_edges()
|
||
if normalized_target_user_id.is_empty():
|
||
_handle_error("PRIVATE_TARGET_REQUIRED", "请选择悄悄话对象")
|
||
return false
|
||
|
||
return _send_chat_payload(content, "private", {
|
||
"targetUserId": normalized_target_user_id,
|
||
"targetUsername": target_username.strip_edges(),
|
||
"privateContext": private_context.strip_edges()
|
||
})
|
||
|
||
func request_friend_list() -> bool:
|
||
if not _friend_protocol_supported:
|
||
_emit_friend_protocol_status("当前聊天后端暂不支持好友功能")
|
||
return false
|
||
return _send_friend_command({"type": "friend_list"})
|
||
|
||
func add_friend(friend_user_id: String, friend_username: String = "") -> bool:
|
||
if not _friend_protocol_supported:
|
||
_emit_friend_protocol_status("当前聊天后端暂不支持好友功能")
|
||
return false
|
||
|
||
var normalized_friend_user_id := friend_user_id.strip_edges()
|
||
if normalized_friend_user_id.is_empty():
|
||
_handle_error("FRIEND_TARGET_REQUIRED", "请选择好友")
|
||
return false
|
||
|
||
return _send_friend_command({
|
||
"type": "friend_add",
|
||
"friendUserId": normalized_friend_user_id,
|
||
"friendUsername": friend_username.strip_edges()
|
||
})
|
||
|
||
func request_friend(friend_user_id: String, friend_username: String = "") -> bool:
|
||
if not _settings_bool("allow_nearby_friend_requests", true):
|
||
_handle_error("FRIEND_REQUEST_DISABLED", "好友申请已在设置中关闭")
|
||
return false
|
||
|
||
if not _friend_protocol_supported:
|
||
_emit_friend_protocol_status("当前聊天后端暂不支持好友功能")
|
||
return false
|
||
|
||
var normalized_friend_user_id := friend_user_id.strip_edges()
|
||
if normalized_friend_user_id.is_empty():
|
||
_handle_error("FRIEND_TARGET_REQUIRED", "请选择好友")
|
||
return false
|
||
|
||
return _send_friend_command({
|
||
"type": "friend_request",
|
||
"friendUserId": normalized_friend_user_id,
|
||
"friendUsername": friend_username.strip_edges()
|
||
})
|
||
|
||
func accept_friend_request(friend_user_id: String, friend_username: String = "") -> bool:
|
||
if not _friend_protocol_supported:
|
||
_emit_friend_protocol_status("当前聊天后端暂不支持好友功能")
|
||
return false
|
||
|
||
var normalized_friend_user_id := friend_user_id.strip_edges()
|
||
if normalized_friend_user_id.is_empty():
|
||
_handle_error("FRIEND_TARGET_REQUIRED", "请选择好友请求")
|
||
return false
|
||
|
||
return _send_friend_command({
|
||
"type": "friend_accept",
|
||
"friendUserId": normalized_friend_user_id,
|
||
"friendUsername": friend_username.strip_edges()
|
||
})
|
||
|
||
func reject_friend_request(friend_user_id: String, friend_username: String = "") -> bool:
|
||
if not _friend_protocol_supported:
|
||
_emit_friend_protocol_status("当前聊天后端暂不支持好友功能")
|
||
return false
|
||
|
||
var normalized_friend_user_id := friend_user_id.strip_edges()
|
||
if normalized_friend_user_id.is_empty():
|
||
_handle_error("FRIEND_TARGET_REQUIRED", "请选择好友请求")
|
||
return false
|
||
|
||
return _send_friend_command({
|
||
"type": "friend_reject",
|
||
"friendUserId": normalized_friend_user_id,
|
||
"friendUsername": friend_username.strip_edges()
|
||
})
|
||
|
||
func remove_friend(friend_user_id: String) -> bool:
|
||
var normalized_friend_user_id := friend_user_id.strip_edges()
|
||
if normalized_friend_user_id.is_empty():
|
||
return false
|
||
|
||
return _send_friend_command({
|
||
"type": "friend_remove",
|
||
"friendUserId": normalized_friend_user_id
|
||
})
|
||
|
||
func get_friends() -> Array[Dictionary]:
|
||
return _friends.duplicate(true)
|
||
|
||
func _send_friend_command(message_data: Dictionary) -> bool:
|
||
if not _websocket_manager.is_websocket_connected():
|
||
connect_to_chat_server()
|
||
return false
|
||
if not _is_logged_in:
|
||
_handle_error("NOT_LOGGED_IN", "聊天尚未登录")
|
||
return false
|
||
|
||
var send_err: Error = _websocket_manager.send_message(JSON.stringify(message_data))
|
||
if send_err != OK:
|
||
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
|
||
return false
|
||
return true
|
||
|
||
func _send_chat_payload(content: String, scope: String = "local", extra_data: Dictionary = {}) -> bool:
|
||
var normalized_content := content.strip_edges()
|
||
if normalized_content.is_empty():
|
||
return false
|
||
var show_bubble: bool = bool(extra_data.get("bubble", extra_data.get("showBubble", extra_data.get("show_bubble", false)))) and _settings_bool("show_chat_bubbles", true)
|
||
var suppress_local_echo := bool(extra_data.get("_suppressLocalEcho", false))
|
||
var is_recovery_retry := bool(extra_data.get("_isRecoveryRetry", false))
|
||
var recoverable_extra_data := extra_data.duplicate(true)
|
||
recoverable_extra_data.erase("_suppressLocalEcho")
|
||
|
||
# 检查消息长度
|
||
if normalized_content.length() > MAX_MESSAGE_LENGTH:
|
||
_handle_error("CONTENT_TOO_LONG", "消息内容过长")
|
||
return false
|
||
|
||
# 检查频率限制
|
||
if not is_recovery_retry and not can_send_message():
|
||
var wait_time := get_time_until_next_message()
|
||
_handle_error("RATE_LIMIT", "请等待 %.1f 秒后再试" % wait_time)
|
||
return false
|
||
|
||
# 检查连接状态
|
||
if not _websocket_manager.is_websocket_connected():
|
||
if _game_token.strip_edges().is_empty():
|
||
var authToken := _get_auth_manager_token()
|
||
if not authToken.is_empty():
|
||
_game_token = authToken
|
||
|
||
if _game_token.strip_edges().is_empty():
|
||
_handle_error("AUTH_FAILED", "请先登录后再使用聊天")
|
||
return false
|
||
|
||
if not _queue_message_until_ready(normalized_content, scope, extra_data):
|
||
return false
|
||
connect_to_chat_server()
|
||
return true
|
||
|
||
# 检查登录状态
|
||
if not _is_logged_in:
|
||
return _queue_message_until_ready(normalized_content, scope, extra_data)
|
||
|
||
# 构建消息数据
|
||
var message_data := {
|
||
"type": "chat",
|
||
"content": normalized_content,
|
||
"scope": scope,
|
||
"bubble": show_bubble
|
||
}
|
||
for key in extra_data.keys():
|
||
if str(key).begins_with("_"):
|
||
continue
|
||
message_data[key] = extra_data[key]
|
||
|
||
# 发送消息(JSON 字符串)
|
||
var json_string := JSON.stringify(message_data)
|
||
var send_err: Error = _websocket_manager.send_message(json_string)
|
||
if send_err != OK:
|
||
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
|
||
return false
|
||
_track_recoverable_message({
|
||
"content": normalized_content,
|
||
"scope": scope,
|
||
"extra_data": recoverable_extra_data
|
||
})
|
||
|
||
# 记录发送时间
|
||
if not is_recovery_retry:
|
||
_record_message_timestamp()
|
||
|
||
var now_timestamp: float = Time.get_unix_time_from_system()
|
||
|
||
if not suppress_local_echo:
|
||
# 添加到历史
|
||
_add_message_to_history({
|
||
"from_user": _current_username,
|
||
"content": normalized_content,
|
||
"timestamp": now_timestamp,
|
||
"is_self": true,
|
||
"show_bubble": show_bubble,
|
||
"scope": scope,
|
||
"to_user_id": str(extra_data.get("targetUserId", "")),
|
||
"to_username": str(extra_data.get("targetUsername", "")),
|
||
"private_context": str(extra_data.get("privateContext", "")),
|
||
"is_private": scope == "private"
|
||
})
|
||
|
||
# 记录待去重的“自己消息”(如果服务端也回发 chat_render,则避免重复显示)
|
||
_pending_self_messages.append({
|
||
"content": normalized_content,
|
||
"scope": scope,
|
||
"targetUserId": str(extra_data.get("targetUserId", "")),
|
||
"privateContext": str(extra_data.get("privateContext", "")),
|
||
"expires_at": now_timestamp + SELF_ECHO_DEDUPE_WINDOW
|
||
})
|
||
|
||
# 本地回显:UI 目前只订阅 CHAT_MESSAGE_RECEIVED,所以这里也发一次 received
|
||
chat_message_received.emit(_current_username, normalized_content, show_bubble, now_timestamp)
|
||
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||
"from_user": _current_username,
|
||
"content": normalized_content,
|
||
"show_bubble": show_bubble,
|
||
"timestamp": now_timestamp,
|
||
"is_self": true,
|
||
"scope": scope,
|
||
"to_user_id": str(extra_data.get("targetUserId", "")),
|
||
"to_username": str(extra_data.get("targetUsername", "")),
|
||
"private_context": str(extra_data.get("privateContext", "")),
|
||
"is_private": scope == "private"
|
||
})
|
||
return true
|
||
|
||
# 消息发送完成回调
|
||
func _on_chat_message_sent(_request_id: String, success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||
if success:
|
||
var message_id: String = str(data.get("data", {}).get("id", ""))
|
||
var timestamp: float = Time.get_unix_time_from_system()
|
||
chat_message_sent.emit(message_id, timestamp)
|
||
_emit_event(EventNames.CHAT_MESSAGE_SENT, {
|
||
"message_id": message_id,
|
||
"timestamp": timestamp
|
||
})
|
||
else:
|
||
_handle_error("SEND_FAILED", error_info.get("message", "发送失败"))
|
||
|
||
# 更新玩家位置
|
||
#
|
||
# 参数:
|
||
# x: float - X 坐标
|
||
# y: float - Y 坐标
|
||
# map_id: String - 地图 ID
|
||
#
|
||
# 使用示例:
|
||
# ChatManager.update_player_position(150.0, 200.0, "novice_village")
|
||
func update_player_position(x: float, y: float, map_id: String) -> void:
|
||
update_player_position_with_appearance(x, y, map_id, {})
|
||
|
||
func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}) -> void:
|
||
if not _websocket_manager.is_websocket_connected():
|
||
connect_to_chat_server()
|
||
return
|
||
if not _is_logged_in:
|
||
return
|
||
|
||
var position_data := {
|
||
"type": "position",
|
||
"x": x,
|
||
"y": y,
|
||
"mapId": map_id
|
||
}
|
||
# 发送消息(JSON 字符串)
|
||
var json_string := JSON.stringify(position_data)
|
||
_websocket_manager.send_message(json_string)
|
||
|
||
# ============================================================================
|
||
# 公共 API - 频率限制
|
||
# ============================================================================
|
||
|
||
# 检查是否可以发送消息
|
||
#
|
||
# 返回值:
|
||
# bool - 是否可以发送
|
||
func can_send_message() -> bool:
|
||
var current_time := Time.get_unix_time_from_system()
|
||
|
||
# 清理过期的时间戳
|
||
var filter_func := func(timestamp: float) -> bool:
|
||
return current_time - timestamp < RATE_LIMIT_WINDOW
|
||
_message_timestamps = _message_timestamps.filter(filter_func)
|
||
|
||
# 检查数量
|
||
return _message_timestamps.size() < RATE_LIMIT_MESSAGES
|
||
|
||
# 获取距离下次可发送消息的时间
|
||
#
|
||
# 返回值:
|
||
# float - 等待时间(秒)
|
||
func get_time_until_next_message() -> float:
|
||
if _message_timestamps.is_empty():
|
||
return 0.0
|
||
|
||
if _message_timestamps.size() < RATE_LIMIT_MESSAGES:
|
||
return 0.0
|
||
|
||
# 找到最早的时间戳
|
||
var earliest_timestamp: float = _message_timestamps[0]
|
||
var current_time := Time.get_unix_time_from_system()
|
||
var elapsed := current_time - earliest_timestamp
|
||
|
||
if elapsed >= RATE_LIMIT_WINDOW:
|
||
return 0.0
|
||
|
||
return RATE_LIMIT_WINDOW - elapsed
|
||
|
||
# ============================================================================
|
||
# 公共 API - 消息历史
|
||
# ============================================================================
|
||
|
||
# 获取消息历史
|
||
#
|
||
# 返回值:
|
||
# Array[Dictionary] - 消息历史数组
|
||
func get_message_history() -> Array[Dictionary]:
|
||
return _message_history.duplicate()
|
||
|
||
# 清空消息历史
|
||
func clear_message_history() -> void:
|
||
_message_history.clear()
|
||
|
||
# 重置当前会话(每次登录/重连时调用)
|
||
#
|
||
# 功能:
|
||
# - 清空当前会话消息缓存
|
||
# - 重置历史消息加载状态
|
||
# - 不影响 Zulip 后端的历史消息
|
||
#
|
||
# 使用场景:
|
||
# - 用户登录成功后
|
||
# - 重新连接到聊天服务器后
|
||
func reset_session() -> void:
|
||
_message_history.clear()
|
||
_history_loading = false
|
||
_has_more_history = true
|
||
_oldest_message_timestamp = 0.0
|
||
|
||
# 加载历史消息(按需从 Zulip 后端获取)
|
||
#
|
||
# 参数:
|
||
# count: int - 要加载的消息数量(默认 HISTORY_PAGE_SIZE)
|
||
#
|
||
# 功能:
|
||
# - 从 Zulip 后端获取历史消息
|
||
# - 添加到当前会话历史开头
|
||
# - 触发 CHAT_MESSAGE_RECEIVED 事件显示消息
|
||
#
|
||
# 使用场景:
|
||
# - 用户滚动到聊天窗口顶部
|
||
# - 用户主动点击"加载历史"按钮
|
||
#
|
||
func load_history(count: int = HISTORY_PAGE_SIZE) -> void:
|
||
if _history_loading:
|
||
return
|
||
|
||
if not _has_more_history:
|
||
return
|
||
|
||
_history_loading = true
|
||
var token := _get_auth_manager_token()
|
||
if token.is_empty():
|
||
_history_loading = false
|
||
return
|
||
var mapId := _current_map.strip_edges()
|
||
if mapId.is_empty():
|
||
mapId = "whale_port"
|
||
var url := "%s/chat/history?mapId=%s&limit=%d&offset=%d" % [
|
||
NetworkConfig.get_api_base_url(),
|
||
mapId.uri_encode(),
|
||
max(1, count),
|
||
_message_history.size(),
|
||
]
|
||
var err := _history_request.request(url, PackedStringArray([
|
||
"Accept: application/json",
|
||
"Authorization: Bearer %s" % token,
|
||
]), HTTPClient.METHOD_GET)
|
||
if err != OK:
|
||
_history_loading = false
|
||
_handle_error("INTERNAL_ERROR", "聊天历史请求发送失败")
|
||
|
||
func _on_history_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
|
||
_history_loading = false
|
||
_handle_error("INTERNAL_ERROR", "聊天历史读取失败")
|
||
return
|
||
var json := JSON.new()
|
||
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
|
||
_history_loading = false
|
||
_handle_error("INTERNAL_ERROR", "聊天历史响应解析失败")
|
||
return
|
||
var response: Dictionary = json.data
|
||
var messagesVariant: Variant = response.get("messages", [])
|
||
var dataVariant: Variant = response.get("data", {})
|
||
if messagesVariant is Array and (messagesVariant as Array).is_empty() and dataVariant is Dictionary:
|
||
messagesVariant = (dataVariant as Dictionary).get("messages", [])
|
||
if messagesVariant is Array:
|
||
_on_history_loaded(_normalize_history_messages(messagesVariant as Array))
|
||
return
|
||
_history_loading = false
|
||
_has_more_history = false
|
||
|
||
func _normalize_history_messages(messages: Array) -> Array:
|
||
var normalized: Array = []
|
||
for messageVariant in messages:
|
||
if not (messageVariant is Dictionary):
|
||
continue
|
||
var message: Dictionary = messageVariant
|
||
normalized.append({
|
||
"from_user": str(message.get("sender", message.get("from_user", ""))),
|
||
"from_user_id": str(message.get("fromUserId", message.get("from_user_id", ""))),
|
||
"content": str(message.get("content", "")),
|
||
"timestamp": _parse_chat_timestamp_to_unix(message.get("timestamp", 0.0)),
|
||
"is_self": str(message.get("sender", "")) == _current_username,
|
||
"scope": str(message.get("scope", "local")),
|
||
"show_bubble": bool(message.get("bubble", false)),
|
||
"is_history": true,
|
||
})
|
||
return normalized
|
||
|
||
# 历史消息加载完成回调
|
||
func _on_history_loaded(messages: Array) -> void:
|
||
_history_loading = false
|
||
|
||
if messages.is_empty():
|
||
_has_more_history = false
|
||
return
|
||
|
||
# 将历史消息插入到当前会话历史开头
|
||
for i in range(messages.size() - 1, -1, -1):
|
||
var message: Dictionary = messages[i]
|
||
_message_history.push_front(message)
|
||
|
||
# 触发事件显示消息(Signal Up)
|
||
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||
"from_user": message.get("from_user", ""),
|
||
"content": message.get("content", ""),
|
||
"show_bubble": false,
|
||
"timestamp": message.get("timestamp", 0.0),
|
||
"is_self": (not _current_username.is_empty() and message.get("from_user", "") == _current_username),
|
||
"is_history": true # 标记为历史消息
|
||
})
|
||
|
||
# 更新最旧消息时间戳
|
||
var oldest: Dictionary = messages.back()
|
||
if oldest.has("timestamp"):
|
||
_oldest_message_timestamp = oldest.timestamp
|
||
|
||
# 检查是否还有更多历史
|
||
if messages.size() < HISTORY_PAGE_SIZE:
|
||
_has_more_history = false
|
||
|
||
# ============================================================================
|
||
# 内部方法 - 信号连接
|
||
# ============================================================================
|
||
|
||
# 连接信号
|
||
func _connect_signals() -> void:
|
||
# WebSocket 管理器信号
|
||
_websocket_manager.connection_state_changed.connect(_on_connection_state_changed)
|
||
_websocket_manager.data_received.connect(_on_data_received)
|
||
_websocket_manager.connection_lost.connect(_on_connection_lost)
|
||
|
||
func _on_connection_lost() -> void:
|
||
_is_logged_in = false
|
||
_world_ready_map = ""
|
||
_restore_recoverable_messages_to_queue()
|
||
|
||
func _load_token_from_environment() -> void:
|
||
var env_token: String = OS.get_environment(GAME_TOKEN_ENV_KEY).strip_edges()
|
||
if not env_token.is_empty():
|
||
_game_token = env_token
|
||
|
||
func _is_headless_run() -> bool:
|
||
if OS.has_feature("headless"):
|
||
return true
|
||
return DisplayServer.get_name() == "headless"
|
||
|
||
func _get_auth_manager_token() -> String:
|
||
var authManager := get_node_or_null("/root/AuthManager")
|
||
if authManager == null or not authManager.has_method("get_access_token"):
|
||
return ""
|
||
|
||
return str(authManager.call("get_access_token")).strip_edges()
|
||
|
||
func _queue_message_until_ready(content: String, scope: String, extra_data: Dictionary = {}) -> bool:
|
||
if _queued_messages.size() >= MAX_QUEUED_MESSAGES:
|
||
_handle_error("RATE_LIMIT", "聊天正在连接,请稍后再试")
|
||
return false
|
||
|
||
_queued_messages.append({
|
||
"content": content,
|
||
"scope": scope,
|
||
"extra_data": extra_data.duplicate(true)
|
||
})
|
||
return true
|
||
|
||
func _track_recoverable_message(message: Dictionary) -> void:
|
||
_recoverable_messages.append(message.duplicate(true))
|
||
if _recoverable_messages.size() > MAX_RECOVERABLE_MESSAGES:
|
||
_recoverable_messages.pop_front()
|
||
|
||
func _restore_recoverable_messages_to_queue() -> void:
|
||
if _recoverable_messages.is_empty():
|
||
return
|
||
|
||
var messages := _recoverable_messages.duplicate(true)
|
||
_recoverable_messages.clear()
|
||
for message_variant in messages:
|
||
if not (message_variant is Dictionary):
|
||
continue
|
||
if _queued_messages.size() >= MAX_QUEUED_MESSAGES:
|
||
break
|
||
var message: Dictionary = message_variant
|
||
var extra_data: Dictionary = {}
|
||
var extra_variant: Variant = message.get("extra_data", {})
|
||
if extra_variant is Dictionary:
|
||
extra_data = (extra_variant as Dictionary).duplicate(true)
|
||
extra_data["_suppressLocalEcho"] = true
|
||
extra_data["_isRecoveryRetry"] = true
|
||
_queued_messages.append({
|
||
"content": str(message.get("content", "")),
|
||
"scope": str(message.get("scope", "local")),
|
||
"extra_data": extra_data
|
||
})
|
||
|
||
func _flush_queued_messages() -> void:
|
||
if _queued_messages.is_empty():
|
||
return
|
||
|
||
var queued_messages := _queued_messages.duplicate()
|
||
_queued_messages.clear()
|
||
|
||
for queued_message in queued_messages:
|
||
var queued_content: String = str(queued_message.get("content", ""))
|
||
var queued_scope: String = str(queued_message.get("scope", "local"))
|
||
var queued_extra_data: Dictionary = {}
|
||
var extra_variant: Variant = queued_message.get("extra_data", {})
|
||
if extra_variant is Dictionary:
|
||
queued_extra_data = extra_variant
|
||
_send_chat_payload(queued_content, queued_scope, queued_extra_data)
|
||
|
||
func _http_request_result_to_string(result: int) -> String:
|
||
match result:
|
||
HTTPRequest.RESULT_SUCCESS:
|
||
return "SUCCESS"
|
||
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:
|
||
return "CHUNKED_BODY_SIZE_MISMATCH"
|
||
HTTPRequest.RESULT_CANT_CONNECT:
|
||
return "CANT_CONNECT"
|
||
HTTPRequest.RESULT_CANT_RESOLVE:
|
||
return "CANT_RESOLVE"
|
||
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||
return "CONNECTION_ERROR"
|
||
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||
return "TLS_HANDSHAKE_ERROR"
|
||
HTTPRequest.RESULT_NO_RESPONSE:
|
||
return "NO_RESPONSE"
|
||
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
||
return "BODY_SIZE_LIMIT_EXCEEDED"
|
||
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
||
return "BODY_DECOMPRESS_FAILED"
|
||
HTTPRequest.RESULT_REQUEST_FAILED:
|
||
return "REQUEST_FAILED"
|
||
HTTPRequest.RESULT_DOWNLOAD_FILE_CANT_OPEN:
|
||
return "DOWNLOAD_FILE_CANT_OPEN"
|
||
HTTPRequest.RESULT_DOWNLOAD_FILE_WRITE_ERROR:
|
||
return "DOWNLOAD_FILE_WRITE_ERROR"
|
||
HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED:
|
||
return "REDIRECT_LIMIT_REACHED"
|
||
HTTPRequest.RESULT_TIMEOUT:
|
||
return "TIMEOUT"
|
||
_:
|
||
return "UNKNOWN_%d" % result
|
||
|
||
func _preview_text(text: String) -> String:
|
||
var preview := text.strip_edges()
|
||
if preview.length() > 180:
|
||
return preview.substr(0, 180) + "..."
|
||
return preview
|
||
|
||
# 发送登录消息
|
||
func _send_login_message() -> void:
|
||
if _game_token.strip_edges().is_empty():
|
||
_handle_error("AUTH_FAILED", "缺少聊天认证 token")
|
||
return
|
||
|
||
var login_data := {
|
||
"type": "login",
|
||
"token": _game_token
|
||
}
|
||
|
||
var json_string := JSON.stringify(login_data)
|
||
_websocket_manager.send_message(json_string)
|
||
|
||
# 连接状态变化
|
||
func _on_connection_state_changed(state: int) -> void:
|
||
# 发射信号
|
||
chat_connection_state_changed.emit(state)
|
||
|
||
# 通过 EventSystem 广播(Signal Up)
|
||
_emit_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, {
|
||
"state": state
|
||
})
|
||
|
||
# 如果连接成功,发送登录消息
|
||
if state == 2: # CONNECTED
|
||
_send_login_message()
|
||
|
||
# ============================================================================
|
||
# 内部方法 - 消息处理
|
||
# ============================================================================
|
||
|
||
# WebSocket 数据接收
|
||
func _on_data_received(message: String) -> void:
|
||
# 解析 JSON 消息
|
||
var json := JSON.new()
|
||
var parse_result := json.parse(message)
|
||
|
||
if parse_result != OK:
|
||
push_error("ChatManager: JSON 解析失败")
|
||
return
|
||
|
||
var data_variant: Variant = json.data
|
||
if not (data_variant is Dictionary):
|
||
push_warning("ChatManager: 收到非对象消息,已忽略")
|
||
return
|
||
|
||
var data: Dictionary = data_variant
|
||
|
||
# 兼容不同后端字段命名:t / type
|
||
var message_type: String = str(data.get("t", data.get("type", ""))).strip_edges()
|
||
if message_type.is_empty():
|
||
_warn_empty_message_type_limited(data)
|
||
return
|
||
|
||
match message_type:
|
||
"connected":
|
||
pass
|
||
"login_success":
|
||
_handle_login_success(data)
|
||
"login_error":
|
||
_handle_login_error(data)
|
||
"chat":
|
||
_handle_chat_render(data)
|
||
"chat_sent":
|
||
_handle_chat_sent(data)
|
||
"chat_error":
|
||
_handle_chat_error(data)
|
||
"chat_render":
|
||
_handle_chat_render(data)
|
||
"friend_list":
|
||
_handle_friend_list(data)
|
||
"friend_added":
|
||
_handle_friend_added(data)
|
||
"friend_request_received":
|
||
_handle_friend_request_received(data)
|
||
"friend_request_sent":
|
||
_handle_friend_request_sent(data)
|
||
"friend_request_accepted":
|
||
_handle_friend_request_accepted(data)
|
||
"friend_request_rejected":
|
||
_handle_friend_request_rejected(data)
|
||
"friend_removed":
|
||
_handle_friend_removed(data)
|
||
"friend_error":
|
||
_handle_friend_error(data)
|
||
"map_players_snapshot":
|
||
_handle_map_players_snapshot(data)
|
||
"system_presence":
|
||
_handle_system_presence(data)
|
||
"position_update":
|
||
_handle_position_update(data)
|
||
"player_joined":
|
||
_handle_player_joined(data)
|
||
"appearance_changed":
|
||
_handle_player_joined(data)
|
||
"world_ready_success":
|
||
pass
|
||
"world_left":
|
||
_world_ready_map = ""
|
||
"player_left":
|
||
_handle_player_left(data)
|
||
"position_updated":
|
||
_handle_position_updated(data)
|
||
"error":
|
||
_handle_error_response(data)
|
||
_:
|
||
push_warning("ChatManager: 未处理的消息类型 %s" % message_type)
|
||
|
||
func _warn_empty_message_type_limited(data: Dictionary) -> void:
|
||
var now: float = Time.get_unix_time_from_system()
|
||
if now - _last_empty_message_type_warning_at < EMPTY_MESSAGE_TYPE_WARNING_INTERVAL:
|
||
return
|
||
|
||
_last_empty_message_type_warning_at = now
|
||
var payload_preview: String = JSON.stringify(data)
|
||
if payload_preview.length() > 180:
|
||
payload_preview = payload_preview.substr(0, 180) + "..."
|
||
push_warning("ChatManager: 收到未带消息类型的消息,已忽略 payload=%s" % payload_preview)
|
||
|
||
# 处理登录成功
|
||
func _handle_login_success(data: Dictionary) -> void:
|
||
_is_logged_in = true
|
||
_refreshing_auth_session = false
|
||
_chat_login_refresh_attempted = false
|
||
_friend_protocol_supported = true
|
||
_current_username = data.get("username", "")
|
||
_current_map = data.get("currentMap", "")
|
||
_world_ready_map = ""
|
||
|
||
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
|
||
reset_session()
|
||
|
||
# 通过 EventSystem 广播(Signal Up)
|
||
_emit_event(EventNames.CHAT_LOGIN_SUCCESS, {
|
||
"username": _current_username,
|
||
"current_map": _current_map
|
||
})
|
||
|
||
_flush_queued_messages()
|
||
request_friend_list()
|
||
|
||
func _handle_system_presence(data: Dictionary) -> void:
|
||
var content := str(data.get("content", "")).strip_edges()
|
||
if content.is_empty():
|
||
return
|
||
var timestamp := _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
|
||
var message := {
|
||
"from_user": "系统",
|
||
"from_user_id": "",
|
||
"content": content,
|
||
"timestamp": timestamp,
|
||
"is_self": false,
|
||
"scope": "global",
|
||
"show_bubble": false,
|
||
"system_presence": true,
|
||
}
|
||
_add_message_to_history(message)
|
||
chat_message_received.emit("系统", content, false, timestamp)
|
||
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message)
|
||
|
||
func _on_account_profile_updated(_profile: Dictionary) -> void:
|
||
notify_appearance_changed()
|
||
|
||
func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void:
|
||
if not isAuthenticated:
|
||
_refreshing_auth_session = false
|
||
_game_token = ""
|
||
disconnect_from_chat_server()
|
||
return
|
||
|
||
var refreshedToken := _get_auth_manager_token()
|
||
if refreshedToken.is_empty():
|
||
return
|
||
_game_token = refreshedToken
|
||
if not _refreshing_auth_session:
|
||
return
|
||
_refreshing_auth_session = false
|
||
if _websocket_manager.is_websocket_connected():
|
||
_send_login_message()
|
||
else:
|
||
connect_to_chat_server()
|
||
|
||
# 处理登录失败
|
||
func _handle_login_error(data: Dictionary) -> void:
|
||
var error_message: String = data.get("message", "登录失败")
|
||
|
||
_is_logged_in = false
|
||
if _request_auth_session_refresh():
|
||
return
|
||
_queued_messages.clear()
|
||
|
||
# 通过 EventSystem 广播错误(Signal Up)
|
||
_emit_event(EventNames.CHAT_LOGIN_FAILED, {
|
||
"error_code": "LOGIN_FAILED",
|
||
"message": error_message
|
||
})
|
||
|
||
# 处理聊天消息发送成功
|
||
func _handle_chat_sent(data: Dictionary) -> void:
|
||
var message_id: String = str(data.get("messageId", ""))
|
||
var timestamp: float = data.get("timestamp", 0.0)
|
||
|
||
# 发射信号
|
||
chat_message_sent.emit(message_id, timestamp)
|
||
|
||
# 通过 EventSystem 广播(Signal Up)
|
||
_emit_event(EventNames.CHAT_MESSAGE_SENT, {
|
||
"message_id": message_id,
|
||
"timestamp": timestamp
|
||
})
|
||
if not _recoverable_messages.is_empty():
|
||
_recoverable_messages.pop_front()
|
||
|
||
# 处理聊天消息发送失败
|
||
func _handle_chat_error(data: Dictionary) -> void:
|
||
var error_message: String = data.get("message", "消息发送失败")
|
||
var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges()
|
||
if error_code == "SESSION_EXPIRED":
|
||
_handle_session_expired(error_message)
|
||
return
|
||
|
||
# 通过 EventSystem 广播错误(Signal Up)
|
||
_emit_event(EventNames.CHAT_ERROR_OCCURRED, {
|
||
"error_code": error_code if not error_code.is_empty() else "CHAT_SEND_FAILED",
|
||
"message": error_message
|
||
})
|
||
|
||
func _handle_friend_list(data: Dictionary) -> void:
|
||
_friends.clear()
|
||
var friends_variant: Variant = data.get("friends", [])
|
||
if friends_variant is Array:
|
||
for friend_variant in friends_variant:
|
||
if friend_variant is Dictionary:
|
||
var friend: Dictionary = friend_variant
|
||
_friends.append({
|
||
"user_id": str(friend.get("userId", friend.get("user_id", ""))).strip_edges(),
|
||
"username": str(friend.get("username", "")),
|
||
"online": bool(friend.get("online", false))
|
||
})
|
||
|
||
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
|
||
"friends": _friends.duplicate(true),
|
||
"requests": _normalize_friend_requests(data.get("requests", [])),
|
||
"supported": true,
|
||
"status": ""
|
||
})
|
||
|
||
func _handle_friend_added(data: Dictionary) -> void:
|
||
var friend_variant: Variant = data.get("friend", {})
|
||
if friend_variant is Dictionary:
|
||
var friend: Dictionary = friend_variant
|
||
var friend_user_id := str(friend.get("userId", friend.get("user_id", ""))).strip_edges()
|
||
if not friend_user_id.is_empty():
|
||
_upsert_friend({
|
||
"user_id": friend_user_id,
|
||
"username": str(friend.get("username", "")),
|
||
"online": bool(friend.get("online", false))
|
||
})
|
||
request_friend_list()
|
||
|
||
func _handle_friend_request_received(data: Dictionary) -> void:
|
||
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
|
||
"friends": _friends.duplicate(true),
|
||
"requests": _normalize_friend_requests([data.get("request", {})])
|
||
})
|
||
request_friend_list()
|
||
|
||
func _handle_friend_request_sent(_data: Dictionary) -> void:
|
||
_emit_event(EventNames.CHAT_ERROR_OCCURRED, {
|
||
"error_code": "FRIEND_REQUEST_SENT",
|
||
"message": "好友请求已发送"
|
||
})
|
||
|
||
func _handle_friend_request_accepted(_data: Dictionary) -> void:
|
||
request_friend_list()
|
||
|
||
func _handle_friend_request_rejected(data: Dictionary) -> void:
|
||
var username := str(data.get("username", "")).strip_edges()
|
||
var message := "好友请求已被拒绝"
|
||
if not username.is_empty():
|
||
message = "%s 拒绝了好友请求" % username
|
||
_emit_event(EventNames.CHAT_ERROR_OCCURRED, {
|
||
"error_code": "FRIEND_REQUEST_REJECTED",
|
||
"message": message
|
||
})
|
||
|
||
func _handle_friend_removed(data: Dictionary) -> void:
|
||
var friend_user_id := str(data.get("friendUserId", data.get("friend_user_id", ""))).strip_edges()
|
||
if friend_user_id.is_empty():
|
||
return
|
||
|
||
for i in range(_friends.size() - 1, -1, -1):
|
||
if str(_friends[i].get("user_id", "")) == friend_user_id:
|
||
_friends.remove_at(i)
|
||
|
||
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
|
||
"friends": _friends.duplicate(true),
|
||
"requests": _normalize_friend_requests(data.get("requests", [])),
|
||
"supported": true,
|
||
"status": ""
|
||
})
|
||
|
||
func _handle_friend_error(data: Dictionary) -> void:
|
||
var error_code := str(data.get("code", data.get("error_code", "FRIEND_ERROR"))).strip_edges()
|
||
var error_message := str(data.get("message", "好友操作失败"))
|
||
if error_code == "SESSION_EXPIRED":
|
||
_handle_session_expired(error_message)
|
||
return
|
||
_handle_error(error_code if not error_code.is_empty() else "FRIEND_ERROR", error_message)
|
||
|
||
func _upsert_friend(friend: Dictionary) -> void:
|
||
var friend_user_id := str(friend.get("user_id", "")).strip_edges()
|
||
if friend_user_id.is_empty():
|
||
return
|
||
|
||
for i in range(_friends.size()):
|
||
if str(_friends[i].get("user_id", "")) == friend_user_id:
|
||
_friends[i] = friend
|
||
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
|
||
"friends": _friends.duplicate(true),
|
||
"requests": []
|
||
})
|
||
return
|
||
|
||
_friends.append(friend)
|
||
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
|
||
"friends": _friends.duplicate(true),
|
||
"requests": []
|
||
})
|
||
|
||
# 处理接收到的聊天消息
|
||
func _handle_chat_render(data: Dictionary) -> void:
|
||
# 兼容不同后端字段命名:
|
||
# - chat_render: {from, txt, bubble, timestamp}
|
||
# - chat: {content, scope, (可选 from/username/timestamp)}
|
||
var from_user: String = data.get("from", data.get("from_user", data.get("username", "")))
|
||
var content: String = data.get("txt", data.get("content", ""))
|
||
var show_bubble: bool = bool(data.get("bubble", data.get("show_bubble", false)))
|
||
var scope: String = str(data.get("scope", "local")).strip_edges().to_lower()
|
||
if scope.is_empty():
|
||
scope = "local"
|
||
var from_user_id: String = str(data.get("fromUserId", data.get("from_user_id", data.get("userId", data.get("user_id", ""))))).strip_edges()
|
||
var to_user_id: String = str(data.get("toUserId", data.get("to_user_id", data.get("targetUserId", data.get("target_user_id", ""))))).strip_edges()
|
||
var to_username: String = str(data.get("toUsername", data.get("to_username", data.get("targetUsername", data.get("target_username", ""))))).strip_edges()
|
||
var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges()
|
||
var is_private: bool = scope == "private"
|
||
|
||
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
|
||
|
||
var is_self: bool = (not _current_username.is_empty() and from_user == _current_username)
|
||
if is_self and _consume_pending_self_message(content, scope, to_user_id):
|
||
# 已经本地回显过,避免重复显示
|
||
return
|
||
|
||
# 如果服务端没带发送者信息,但内容匹配最近自己发送的消息,则认为是自己消息
|
||
if from_user.is_empty() and _consume_pending_self_message(content, scope, to_user_id):
|
||
from_user = _current_username
|
||
is_self = true
|
||
|
||
# 添加到历史
|
||
_add_message_to_history({
|
||
"from_user": from_user,
|
||
"from_user_id": from_user_id,
|
||
"content": content,
|
||
"timestamp": timestamp,
|
||
"is_self": is_self,
|
||
"scope": scope,
|
||
"to_user_id": to_user_id,
|
||
"to_username": to_username,
|
||
"private_context": private_context,
|
||
"is_private": is_private
|
||
})
|
||
|
||
# 发射信号
|
||
chat_message_received.emit(from_user, content, show_bubble, timestamp)
|
||
|
||
# 通过 EventSystem 广播(Signal Up)
|
||
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||
"from_user": from_user,
|
||
"from_user_id": from_user_id,
|
||
"content": content,
|
||
"show_bubble": show_bubble,
|
||
"timestamp": timestamp,
|
||
"is_self": is_self,
|
||
"scope": scope,
|
||
"to_user_id": to_user_id,
|
||
"to_username": to_username,
|
||
"private_context": private_context,
|
||
"is_private": is_private
|
||
})
|
||
|
||
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
|
||
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
|
||
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
|
||
var ts := float(timestamp_raw)
|
||
return ts if ts > 0.0 else Time.get_unix_time_from_system()
|
||
|
||
var ts_str := str(timestamp_raw)
|
||
if ts_str.strip_edges().is_empty():
|
||
return Time.get_unix_time_from_system()
|
||
|
||
# 纯数字字符串(必须整串都是数字/小数点,避免把 ISO 字符串前缀 "2026" 误判成时间戳)
|
||
var numeric_regex := RegEx.new()
|
||
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
|
||
if numeric_regex.search(ts_str) != null:
|
||
var ts_num := float(ts_str)
|
||
return ts_num if ts_num > 0.0 else Time.get_unix_time_from_system()
|
||
|
||
# ISO 8601: 2026-01-19T15:15:43.930Z
|
||
var regex := RegEx.new()
|
||
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
|
||
var result := regex.search(ts_str)
|
||
if result == null:
|
||
return Time.get_unix_time_from_system()
|
||
|
||
var utc_dict := {
|
||
"year": int(result.get_string(1)),
|
||
"month": int(result.get_string(2)),
|
||
"day": int(result.get_string(3)),
|
||
"hour": int(result.get_string(4)),
|
||
"minute": int(result.get_string(5)),
|
||
"second": int(result.get_string(6))
|
||
}
|
||
return Time.get_unix_time_from_datetime_dict(utc_dict)
|
||
|
||
# 处理位置更新成功
|
||
func _handle_position_updated(data: Dictionary) -> void:
|
||
var stream: String = data.get("stream", "")
|
||
var topic: String = data.get("topic", "")
|
||
|
||
# 发射信号
|
||
chat_position_updated.emit(stream, topic)
|
||
|
||
# 通过 EventSystem 广播(Signal Up)
|
||
_emit_event(EventNames.CHAT_POSITION_UPDATED, {
|
||
"stream": stream,
|
||
"topic": topic
|
||
})
|
||
|
||
func _handle_position_update(data: Dictionary) -> void:
|
||
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
|
||
if user_id.is_empty():
|
||
return
|
||
var appearance := _normalize_player_appearance(data)
|
||
|
||
_emit_event(EventNames.REMOTE_PLAYER_POSITION_UPDATED, {
|
||
"userId": user_id,
|
||
"username": str(data.get("username", "")),
|
||
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
|
||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||
"skin_id": str(appearance.get("skin_id", "")),
|
||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||
"skin_asset": appearance.get("skin_asset", {}),
|
||
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
|
||
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
|
||
})
|
||
|
||
func _handle_map_players_snapshot(data: Dictionary) -> void:
|
||
var players: Array[Dictionary] = []
|
||
var players_variant: Variant = data.get("players", [])
|
||
if players_variant is Array:
|
||
for player_variant in players_variant:
|
||
if not (player_variant is Dictionary):
|
||
continue
|
||
var player: Dictionary = player_variant
|
||
var user_id := str(player.get("userId", player.get("user_id", ""))).strip_edges()
|
||
if user_id.is_empty():
|
||
continue
|
||
var appearance := _normalize_player_appearance(player)
|
||
players.append({
|
||
"userId": user_id,
|
||
"username": str(player.get("username", "")),
|
||
"position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))),
|
||
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
|
||
"skin_id": str(appearance.get("skin_id", "")),
|
||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||
"skin_asset": appearance.get("skin_asset", {}),
|
||
"cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))),
|
||
"movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false)))
|
||
})
|
||
|
||
_emit_event(EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, {
|
||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||
"players": players
|
||
})
|
||
|
||
func _handle_player_joined(data: Dictionary) -> void:
|
||
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
|
||
if user_id.is_empty():
|
||
return
|
||
var appearance := _normalize_player_appearance(data)
|
||
|
||
_emit_event(EventNames.REMOTE_PLAYER_JOINED, {
|
||
"userId": user_id,
|
||
"username": str(data.get("username", "")),
|
||
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
|
||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||
"skin_id": str(appearance.get("skin_id", "")),
|
||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||
"skin_asset": appearance.get("skin_asset", {}),
|
||
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
|
||
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
|
||
})
|
||
|
||
func _normalize_player_appearance(data: Dictionary) -> Dictionary:
|
||
var appearanceVariant: Variant = data.get("appearance", {})
|
||
var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {}
|
||
return {
|
||
"skin_id": str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))),
|
||
"avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))),
|
||
"skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))),
|
||
}
|
||
|
||
func _normalize_cafe_companion(value: Variant) -> Dictionary:
|
||
if not (value is Dictionary):
|
||
return {}
|
||
|
||
var raw: Dictionary = value
|
||
var servicePointId := str(raw.get("servicePointId", raw.get("service_point_id", ""))).strip_edges()
|
||
var companionId := str(raw.get("companionId", raw.get("companion_id", ""))).strip_edges()
|
||
var personaName := str(raw.get("personaName", raw.get("persona_name", ""))).strip_edges()
|
||
if servicePointId.is_empty() or companionId.is_empty() or personaName.is_empty():
|
||
return {}
|
||
|
||
return {
|
||
"cafe_id": str(raw.get("cafeId", raw.get("cafe_id", "whale_cafe"))).strip_edges(),
|
||
"service_point_id": servicePointId,
|
||
"companion_id": companionId,
|
||
"companion_type": str(raw.get("companionType", raw.get("companion_type", "hired_player"))).strip_edges(),
|
||
"persona_name": personaName,
|
||
"employment_ends_at": str(raw.get("employmentEndsAt", raw.get("employment_ends_at", ""))).strip_edges(),
|
||
"owner_user_id": str(raw.get("ownerUserId", raw.get("owner_user_id", ""))).strip_edges(),
|
||
}
|
||
|
||
func _handle_player_left(data: Dictionary) -> void:
|
||
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
|
||
if user_id.is_empty():
|
||
return
|
||
|
||
_emit_event(EventNames.REMOTE_PLAYER_LEFT, {
|
||
"userId": user_id,
|
||
"username": str(data.get("username", "")),
|
||
"mapId": str(data.get("mapId", data.get("map_id", "")))
|
||
})
|
||
|
||
# 处理错误响应(如果需要)
|
||
func _handle_error_response(data: Dictionary) -> void:
|
||
var error_code: String = str(data.get("code", ""))
|
||
var error_message: String = str(data.get("message", ""))
|
||
var unknown_message_type := _extract_unknown_message_type(error_message)
|
||
if _is_friend_protocol_message_type(unknown_message_type):
|
||
_handle_friend_protocol_unsupported(unknown_message_type)
|
||
return
|
||
if error_code == "SESSION_EXPIRED":
|
||
_handle_session_expired(error_message)
|
||
return
|
||
|
||
_handle_error(error_code, error_message)
|
||
|
||
func _handle_session_expired(error_message: String = "") -> void:
|
||
push_warning("ChatManager: [SESSION_EXPIRED] %s" % error_message)
|
||
_is_logged_in = false
|
||
_restore_recoverable_messages_to_queue()
|
||
_chat_login_refresh_attempted = false
|
||
if _request_auth_session_refresh():
|
||
return
|
||
_game_token = _get_auth_manager_token()
|
||
if _game_token.is_empty():
|
||
_handle_error("AUTH_FAILED", "请先登录后再使用聊天")
|
||
return
|
||
if _websocket_manager.is_websocket_connected():
|
||
_send_login_message()
|
||
else:
|
||
connect_to_chat_server()
|
||
|
||
func _request_auth_session_refresh() -> bool:
|
||
if _refreshing_auth_session or _chat_login_refresh_attempted:
|
||
return false
|
||
var authManager := get_node_or_null("/root/AuthManager")
|
||
if authManager == null or not authManager.has_method("refresh_session") or not authManager.has_method("get_refresh_token"):
|
||
return false
|
||
if str(authManager.call("get_refresh_token")).strip_edges().is_empty():
|
||
return false
|
||
_chat_login_refresh_attempted = true
|
||
_refreshing_auth_session = true
|
||
authManager.call("refresh_session")
|
||
return true
|
||
|
||
# 处理 Socket 错误(如果需要)
|
||
func _on_socket_error(error: String) -> void:
|
||
_handle_error("SOCKET_ERROR", error)
|
||
|
||
func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]:
|
||
var requests: Array[Dictionary] = []
|
||
if not (requests_variant is Array):
|
||
return requests
|
||
|
||
for request_variant in requests_variant:
|
||
if not (request_variant is Dictionary):
|
||
continue
|
||
var request: Dictionary = request_variant
|
||
var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges()
|
||
if user_id.is_empty():
|
||
continue
|
||
requests.append({
|
||
"user_id": user_id,
|
||
"username": str(request.get("username", "玩家")).strip_edges(),
|
||
"created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges()
|
||
})
|
||
return requests
|
||
|
||
func _extract_unknown_message_type(error_message: String) -> String:
|
||
var normalized_message := error_message.strip_edges()
|
||
for marker in ["未知消息类型:", "未知消息类型:", "Unknown message type:", "Unknown message type:"]:
|
||
var index := normalized_message.find(marker)
|
||
if index >= 0:
|
||
return normalized_message.substr(index + marker.length()).strip_edges()
|
||
return ""
|
||
|
||
func _is_friend_protocol_message_type(message_type: String) -> bool:
|
||
match message_type.strip_edges():
|
||
"friend_list", "friend_add", "friend_remove", "friend_request", "friend_accept", "friend_reject":
|
||
return true
|
||
_:
|
||
return false
|
||
|
||
func _handle_friend_protocol_unsupported(message_type: String) -> void:
|
||
_friend_protocol_supported = false
|
||
push_warning("ChatManager: 当前聊天后端暂不支持好友协议: %s" % message_type)
|
||
_emit_friend_protocol_status("当前聊天后端暂不支持好友功能")
|
||
|
||
func _emit_friend_protocol_status(status_message: String) -> void:
|
||
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
|
||
"friends": _friends.duplicate(true),
|
||
"requests": [],
|
||
"supported": false,
|
||
"status": status_message
|
||
})
|
||
|
||
# ============================================================================
|
||
# 内部方法 - 工具函数
|
||
# ============================================================================
|
||
|
||
# 处理错误
|
||
func _handle_error(error_code: String, error_message: String) -> void:
|
||
if error_code == "NOT_CONNECTED" or error_code == "NOT_LOGGED_IN":
|
||
push_warning("ChatManager: [%s] %s" % [error_code, error_message])
|
||
else:
|
||
push_error("ChatManager: [%s] %s" % [error_code, error_message])
|
||
|
||
# 获取用户友好的错误消息
|
||
var user_message: String = CHAT_ERROR_MESSAGES.get(error_code, error_message) as String
|
||
|
||
# 发射信号
|
||
chat_error_occurred.emit(error_code, user_message)
|
||
|
||
# 通过 EventSystem 广播(Signal Up)
|
||
_emit_event(EventNames.CHAT_ERROR_OCCURRED, {
|
||
"error_code": error_code,
|
||
"message": user_message
|
||
})
|
||
|
||
# 特殊处理认证失败
|
||
if error_code == "AUTH_FAILED" or error_code == "SESSION_EXPIRED":
|
||
_is_logged_in = false
|
||
_emit_event(EventNames.CHAT_LOGIN_FAILED, {
|
||
"error_code": error_code
|
||
})
|
||
|
||
# 记录消息发送时间戳
|
||
func _record_message_timestamp() -> void:
|
||
var current_time := Time.get_unix_time_from_system()
|
||
_message_timestamps.append(current_time)
|
||
|
||
# 消费一个待去重的“自己消息”(允许相同内容多次发送:每次消费一个)
|
||
func _consume_pending_self_message(content: String, scope: String = "", target_user_id: String = "") -> bool:
|
||
var now := Time.get_unix_time_from_system()
|
||
|
||
# 先清理过期项
|
||
for i in range(_pending_self_messages.size() - 1, -1, -1):
|
||
var item: Dictionary = _pending_self_messages[i]
|
||
if float(item.get("expires_at", 0.0)) < now:
|
||
_pending_self_messages.remove_at(i)
|
||
|
||
# 再匹配内容
|
||
for i in range(_pending_self_messages.size() - 1, -1, -1):
|
||
var item: Dictionary = _pending_self_messages[i]
|
||
var same_content := str(item.get("content", "")) == content
|
||
var same_scope := scope.is_empty() or str(item.get("scope", "")).is_empty() or str(item.get("scope", "")) == scope
|
||
var same_target := target_user_id.is_empty() or str(item.get("targetUserId", "")) == target_user_id
|
||
if same_content and same_scope and same_target:
|
||
_pending_self_messages.remove_at(i)
|
||
return true
|
||
|
||
return false
|
||
|
||
# 添加消息到当前会话历史
|
||
func _add_message_to_history(message: Dictionary) -> void:
|
||
_message_history.append(message)
|
||
|
||
# 更新最旧消息时间戳(用于历史消息加载)
|
||
if _oldest_message_timestamp == 0.0 or message.timestamp < _oldest_message_timestamp:
|
||
_oldest_message_timestamp = message.timestamp
|
||
|
||
# 限制当前会话消息数量(超过后删除最旧的)
|
||
if _message_history.size() > MAX_SESSION_MESSAGES:
|
||
_message_history.pop_front()
|
||
|
||
func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||
var settingsManager := get_node_or_null("/root/SettingsManager")
|
||
if settingsManager != null and settingsManager.has_method("get_bool"):
|
||
return bool(settingsManager.call("get_bool", key))
|
||
return defaultValue
|