12 Commits

Author SHA1 Message Date
14bd5e1b29 fix: 调整square上的物品与场景的yz轴排序 2026-01-11 12:02:12 +08:00
fecebbc4c1 fix: 调整npc与通知板、欢迎板的排序,修复气泡被板挡住的问题 2026-01-11 11:05:16 +08:00
75eb227b18 feat: 增加通知板场景
- 增加通知板与用户交互,点击E,弹出通知消息
- 预留前端调用后端获取通知的接口,当不可用时,使用mock data
2026-01-11 01:55:19 +08:00
449cd1e8f3 feat: 增加欢迎板
- 增加欢迎板场景
- 增加与玩家的交互,点击E弹出弹出框
2026-01-11 00:52:43 +08:00
8a5a4a0005 feat: 增加NPC范鲸晶
- player场景增加RayCast2D
- 增加npc场景
- 增加NPC对话气泡
2026-01-11 00:10:45 +08:00
ed7d89e39d feat:增加多角色在线功能
- 增加远程登录角色精灵
- 基于后端接口完成位置同步
- 实现多人在线以及跳转
- 增加个人房间功能
2026-01-10 21:26:15 +08:00
ce47bd6eeb Merge branch 'main' of https://gitea.xinghangee.icu/datawhale/whale-town-front into feature/whaletown-developer-ground 2026-01-10 21:22:31 +08:00
13e6553748 feat: 增加碰撞和空气墙 2026-01-10 02:30:45 +08:00
6a3823ad16 feat: 增加player从广场到房间互相跳转的动作逻辑 2026-01-10 00:44:01 +08:00
4bfe49d5b1 feat:增加room场景 2026-01-09 23:49:58 +08:00
1d3f580560 feat:增加Datawhale和喷泉 2026-01-09 23:29:23 +08:00
9259865e72 feat:替换角色资源并增加基础tilesetlayer
- 增加底纹、草坪、河堤、河、小码头以及公会的tilesetlayer
- 替换角色精灵图为 4 行 4 列格式
- 更新 player.tscn:配置上下左右的 idle 和 walk 动画
- 更新 player.gd:重构动画逻辑,支持四方向判断与播放
2026-01-07 23:07:54 +08:00
76 changed files with 728 additions and 3985 deletions

View File

@@ -58,14 +58,3 @@ const GRID_SNAP_REQUESTED = "grid_snap_requested"
# 测试事件 # 测试事件
# ============================================================================ # ============================================================================
const TEST_EVENT = "test_event" const TEST_EVENT = "test_event"
# ============================================================================
# 聊天事件
# ============================================================================
const CHAT_MESSAGE_SENT = "chat_message_sent"
const CHAT_MESSAGE_RECEIVED = "chat_message_received"
const CHAT_ERROR_OCCURRED = "chat_error_occurred"
const CHAT_CONNECTION_STATE_CHANGED = "chat_connection_state_changed"
const CHAT_POSITION_UPDATED = "chat_position_updated"
const CHAT_LOGIN_SUCCESS = "chat_login_success"
const CHAT_LOGIN_FAILED = "chat_login_failed"

View File

@@ -29,7 +29,7 @@ extends RefCounted
# ============ 信号定义 ============ # ============ 信号定义 ============
# 登录成功信号 # 登录成功信号
signal login_success(username: String) signal login_success(username: String, token: String)
# 登录失败信号 # 登录失败信号
signal login_failed(message: String) signal login_failed(message: String)
@@ -68,9 +68,6 @@ enum LoginMode {
# ============ 成员变量 ============ # ============ 成员变量 ============
# 当前用户ID静态变量用于BaseLevel等场景访问
static var current_user_id: String = ""
# 登录状态 # 登录状态
var current_login_mode: LoginMode = LoginMode.PASSWORD var current_login_mode: LoginMode = LoginMode.PASSWORD
var is_processing: bool = false var is_processing: bool = false
@@ -83,188 +80,22 @@ var current_email: String = ""
# 网络请求管理 # 网络请求管理
var active_request_ids: Array = [] var active_request_ids: Array = []
# ============ Token 管理 ============ # 当前登录用户ID (静态变量,全局访问)
static var current_user_id: String = ""
# 本地存储路径常量
const AUTH_CONFIG_PATH: String = "user://auth.cfg"
# Token 存储(内存中,用于快速访问)
var _access_token: String = "" # JWT访问令牌短期用于API和WebSocket
var _refresh_token: String = "" # JWT刷新令牌长期用于获取新access_token
var _user_info: Dictionary = {} # 用户信息
var _token_expiry: float = 0.0 # access_token过期时间Unix时间戳
# 游戏 token废弃保留供旧接口
var _game_token: String = "" # @deprecated 使用 _access_token 替代
# ============ 生命周期方法 ============ # ============ 生命周期方法 ============
# 初始化管理器 # 初始化管理器
func _init() -> void: func _init():
_load_auth_data() print("AuthManager 初始化完成")
# 清理资源 # 清理资源
func cleanup() -> void: func cleanup():
# 取消所有活动的网络请求 # 取消所有活动的网络请求
for request_id in active_request_ids: for request_id in active_request_ids:
NetworkManager.cancel_request(request_id) NetworkManager.cancel_request(request_id)
active_request_ids.clear() active_request_ids.clear()
# ============ Token 管理 ============
# 保存 Token 到内存
#
# 参数:
# data: Dictionary - 登录响应数据
#
# 功能:
# - 从登录响应中提取 access_token 和 refresh_token
# - 保存到内存变量中
# - 保存用户信息
func _save_tokens_to_memory(data: Dictionary) -> void:
if not data.has("data"):
push_warning("AuthManager: 登录响应中没有 data 字段")
return
var token_data: Dictionary = data.data
_access_token = token_data.get("access_token", "")
_refresh_token = token_data.get("refresh_token", "")
_user_info = token_data.get("user", {})
_token_expiry = Time.get_unix_time_from_system() + float(token_data.get("expires_in", 0))
# 设置当前用户ID用于BaseLevel等场景
if _user_info.has("id"):
AuthManager.current_user_id = str(_user_info.id)
_game_token = _access_token
# 保存 Token 到本地ConfigFile
#
# 参数:
# data: Dictionary - 登录响应数据
#
# 功能:
# - 将 refresh_token 和用户信息保存到 ConfigFile
# - access_token 不保存到本地,仅保存在内存中
func _save_tokens_to_local(data: Dictionary) -> void:
if not data.has("data"):
return
var token_data: Dictionary = data.data
var auth_data: Dictionary = {
"refresh_token": token_data.get("refresh_token", ""),
"user_id": token_data.get("user", {}).get("id", ""),
"username": token_data.get("user", {}).get("username", ""),
"saved_at": Time.get_unix_time_from_system()
}
var config: ConfigFile = ConfigFile.new()
config.load(AUTH_CONFIG_PATH)
config.set_value("auth", "refresh_token", auth_data["refresh_token"])
config.set_value("auth", "user_id", auth_data["user_id"])
config.set_value("auth", "username", auth_data["username"])
config.set_value("auth", "saved_at", auth_data["saved_at"])
var error: Error = config.save(AUTH_CONFIG_PATH)
if error != OK:
push_error("AuthManager: 保存Token到本地失败错误码: %d" % error)
# 从本地加载 Token游戏启动时调用
#
# 功能:
# - 从 ConfigFile 加载 refresh_token 和用户信息
# - access_token 需要通过 refresh_token 刷新获取
func _load_auth_data() -> void:
if not FileAccess.file_exists(AUTH_CONFIG_PATH):
return
var config: ConfigFile = ConfigFile.new()
var error: Error = config.load(AUTH_CONFIG_PATH)
if error != OK:
push_error("AuthManager: 加载本地认证数据失败,错误码: %d" % error)
return
_refresh_token = config.get_value("auth", "refresh_token", "")
var user_id: String = config.get_value("auth", "user_id", "")
var username: String = config.get_value("auth", "username", "")
if not _refresh_token.is_empty():
_user_info = {
"id": user_id,
"username": username
}
else:
push_warning("AuthManager: 本地认证数据无效(没有 refresh_token")
# 清除本地认证数据(登出时调用)
#
# 功能:
# - 清除内存中的 Token
# - 删除本地 ConfigFile
func _clear_auth_data() -> void:
_access_token = ""
_refresh_token = ""
_user_info = {}
_token_expiry = 0.0
_game_token = ""
if FileAccess.file_exists(AUTH_CONFIG_PATH):
DirAccess.remove_absolute(AUTH_CONFIG_PATH)
# ============ Token 访问方法 ============
# 设置游戏 token建议优先使用 _save_tokens_to_memory
#
# 参数:
# token: String - 游戏认证 token
#
# 使用场景:
# - 登录成功后设置 token
# - 从服务器响应中获取 token
func set_game_token(token: String) -> void:
_game_token = token
_access_token = token # 同步更新 access_token
# 获取游戏 token
#
# 返回值:
# String - access_token如果未设置则返回空字符串
#
# 使用场景:
# - ChatManager 连接 WebSocket 时需要 token
# - 其他需要游戏认证的场景
func get_game_token() -> String:
return _access_token
# 获取 access token
#
# 返回值:
# String - JWT访问令牌
#
# 使用场景:
# - API请求认证
# - WebSocket聊天认证
func get_access_token() -> String:
return _access_token
# 获取 refresh token
#
# 返回值:
# String - JWT刷新令牌
#
# 使用场景:
# - 刷新过期的 access token
func get_refresh_token() -> String:
return _refresh_token
# 获取用户信息
#
# 返回值:
# Dictionary - 用户信息字典
func get_user_info() -> Dictionary:
return _user_info
# ============ 登录相关方法 ============ # ============ 登录相关方法 ============
# 执行密码登录 # 执行密码登录
@@ -593,7 +424,7 @@ func validate_verification_code(code: String) -> Dictionary:
# ============ 网络响应处理 ============ # ============ 网络响应处理 ============
# 处理登录响应 # 处理登录响应
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary):
_reset_login_state() _reset_login_state()
var result = ResponseHandler.handle_login_response(success, data, error_info) var result = ResponseHandler.handle_login_response(success, data, error_info)
@@ -602,20 +433,26 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
show_toast_message.emit(result.message, result.success) show_toast_message.emit(result.message, result.success)
if result.success: if result.success:
# 保存 Token 到内存和本地 var username = ""
_save_tokens_to_memory(data) if data.has("data") and data.data.has("user"):
_save_tokens_to_local(data) var user_data = data.data.user
if user_data.has("username"):
var username: String = _user_info.get("username", "") username = user_data.username
if user_data.has("id"):
current_user_id = user_data.id
print("AuthManager: Current User ID set to ", current_user_id)
# 延迟发送登录成功信号 # 延迟发送登录成功信号
await Engine.get_main_loop().create_timer(1.0).timeout await Engine.get_main_loop().create_timer(1.0).timeout
login_success.emit(username) var token = ""
if data.has("data") and data.data.has("access_token"):
token = data.data.access_token
login_success.emit(username, token)
else: else:
login_failed.emit(result.message) login_failed.emit(result.message)
# 处理验证码登录响应 # 处理验证码登录响应
func _on_verification_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: func _on_verification_login_response(success: bool, data: Dictionary, error_info: Dictionary):
_reset_login_state() _reset_login_state()
var result = ResponseHandler.handle_verification_code_login_response(success, data, error_info) var result = ResponseHandler.handle_verification_code_login_response(success, data, error_info)
@@ -624,14 +461,17 @@ func _on_verification_login_response(success: bool, data: Dictionary, error_info
show_toast_message.emit(result.message, result.success) show_toast_message.emit(result.message, result.success)
if result.success: if result.success:
# 保存 Token 到内存和本地 var username = ""
_save_tokens_to_memory(data) if data.has("data") and data.data.has("user"):
_save_tokens_to_local(data) var user_data = data.data.user
if user_data.has("username"):
var username: String = _user_info.get("username", "") username = user_data.username
if user_data.has("id"):
current_user_id = user_data.id
print("AuthManager: Current User ID set to ", current_user_id)
await Engine.get_main_loop().create_timer(1.0).timeout await Engine.get_main_loop().create_timer(1.0).timeout
login_success.emit(username) login_success.emit(username, data.get("access_token", ""))
else: else:
login_failed.emit(result.message) login_failed.emit(result.message)

View File

@@ -1,726 +0,0 @@
extends Node
# ============================================================================
# ChatManager.gd - 聊天系统业务逻辑核心
# ============================================================================
# 管理聊天功能的核心业务逻辑
#
# 核心职责:
# - 聊天消息发送/接收协调
# - 客户端频率限制10条/分钟)
# - 消息历史管理最多100条
# - Signal Up: 通过信号和 EventSystem 向上通知
# - 整合 AuthManager 获取 token
#
# 使用方式:
# ChatManager.connect_to_chat_server()
# ChatManager.send_chat_message("Hello", "local")
# 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)
# ============================================================================
# 常量定义
# ============================================================================
# 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 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: ChatWebSocketManager
# 是否已登录
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 = ""
# 游戏 token
var _game_token: String = ""
# 发送后本地回显去重(避免服务端也回发导致重复显示)
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
var _pending_self_messages: Array[Dictionary] = []
# ============================================================================
# 生命周期方法
# ============================================================================
# 初始化
func _ready() -> void:
# 创建 WebSocket 管理器
_websocket_manager = ChatWebSocketManager.new()
add_child(_websocket_manager)
# 连接信号
_connect_signals()
# 清理
func _exit_tree() -> void:
if is_instance_valid(_websocket_manager):
_websocket_manager.queue_free()
# ============================================================================
# 公共 API - Token 管理
# ============================================================================
# 设置游戏 token
#
# 参数:
# token: String - 游戏认证 token
#
# 使用示例:
# ChatManager.set_game_token("your_game_token")
func set_game_token(token: String) -> void:
_game_token = token
# 获取游戏 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
_websocket_manager.connect_to_game_server()
# 断开聊天服务器
func disconnect_from_chat_server() -> void:
# 发送登出消息
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()
# ============================================================================
# 公共 API - 聊天操作
# ============================================================================
# 发送聊天消息
#
# 参数:
# content: String - 消息内容
# scope: String - 消息范围("local" 或具体 topic 名称)
#
# 使用示例:
# ChatManager.send_chat_message("Hello, world!", "local")
func send_chat_message(content: String, scope: String = "local") -> void:
# 检查连接状态
if not _websocket_manager.is_websocket_connected():
_handle_error("NOT_CONNECTED", "未连接到聊天服务器")
return
# 检查登录状态
if not _is_logged_in:
_handle_error("NOT_LOGGED_IN", "尚未登录聊天服务器")
return
# 检查消息长度
if content.length() > MAX_MESSAGE_LENGTH:
_handle_error("CONTENT_TOO_LONG", "消息内容过长")
return
# 检查频率限制
if not can_send_message():
var wait_time := get_time_until_next_message()
_handle_error("RATE_LIMIT", "请等待 %.1f 秒后再试" % wait_time)
return
# 构建消息数据
var message_data := {
"type": "chat",
"content": content,
"scope": scope
}
# 发送消息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
# 记录发送时间
_record_message_timestamp()
# 添加到历史
_add_message_to_history({
"from_user": _current_username,
"content": content,
"timestamp": Time.get_unix_time_from_system(),
"is_self": true
})
var now_timestamp: float = Time.get_unix_time_from_system()
# 记录待去重的“自己消息”(如果服务端也回发 chat_render则避免重复显示
_pending_self_messages.append({
"content": content,
"expires_at": now_timestamp + SELF_ECHO_DEDUPE_WINDOW
})
# 本地回显UI 目前只订阅 CHAT_MESSAGE_RECEIVED所以这里也发一次 received
chat_message_received.emit(_current_username, content, true, now_timestamp)
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
"from_user": _current_username,
"content": content,
"show_bubble": true,
"timestamp": now_timestamp,
"is_self": 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)
EventSystem.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:
if not _websocket_manager.is_websocket_connected():
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 事件显示消息
#
# 使用场景:
# - 用户滚动到聊天窗口顶部
# - 用户主动点击"加载历史"按钮
#
# 注意:
# - 这是异步操作,需要通过 Zulip API 实现
# - 当前实现为占位符,需要后端 API 支持
func load_history(_count: int = HISTORY_PAGE_SIZE) -> void:
if _history_loading:
return
if not _has_more_history:
return
_history_loading = true
# TODO: 实现从 Zulip 后端获取历史消息
# NetworkManager.get_chat_history(_oldest_message_timestamp, count, _on_history_loaded)
# 历史消息加载完成回调
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
EventSystem.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)
# 发送登录消息
func _send_login_message() -> void:
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
EventSystem.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: Dictionary = json.data
# 检查消息类型字段
var message_type: String = data.get("t", "")
match message_type:
"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)
"position_updated":
_handle_position_updated(data)
_:
push_warning("ChatManager: 未处理的消息类型 %s" % message_type)
# 处理登录成功
func _handle_login_success(data: Dictionary) -> void:
_is_logged_in = true
_current_username = data.get("username", "")
_current_map = data.get("currentMap", "")
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
reset_session()
# 通过 EventSystem 广播Signal Up
EventSystem.emit_event(EventNames.CHAT_LOGIN_SUCCESS, {
"username": _current_username,
"current_map": _current_map
})
# 处理登录失败
func _handle_login_error(data: Dictionary) -> void:
var error_message: String = data.get("message", "登录失败")
_is_logged_in = false
# 通过 EventSystem 广播错误Signal Up
EventSystem.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
EventSystem.emit_event(EventNames.CHAT_MESSAGE_SENT, {
"message_id": message_id,
"timestamp": timestamp
})
# 处理聊天消息发送失败
func _handle_chat_error(data: Dictionary) -> void:
var error_message: String = data.get("message", "消息发送失败")
# 通过 EventSystem 广播错误Signal Up
EventSystem.emit_event(EventNames.CHAT_ERROR_OCCURRED, {
"error_code": "CHAT_SEND_FAILED",
"message": error_message
})
# 处理接收到的聊天消息
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 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):
# 已经本地回显过,避免重复显示
return
# 如果服务端没带发送者信息,但内容匹配最近自己发送的消息,则认为是自己消息
if from_user.is_empty() and _consume_pending_self_message(content):
from_user = _current_username
is_self = true
# 添加到历史
_add_message_to_history({
"from_user": from_user,
"content": content,
"timestamp": timestamp,
"is_self": is_self
})
# 发射信号
chat_message_received.emit(from_user, content, show_bubble, timestamp)
# 通过 EventSystem 广播Signal Up
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
"from_user": from_user,
"content": content,
"show_bubble": show_bubble,
"timestamp": timestamp,
"is_self": is_self
})
# 解析聊天消息时间戳(兼容 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
EventSystem.emit_event(EventNames.CHAT_POSITION_UPDATED, {
"stream": stream,
"topic": topic
})
# 处理错误响应(如果需要)
func _handle_error_response(data: Dictionary) -> void:
var error_code: String = data.get("code", "")
var error_message: String = data.get("message", "")
_handle_error(error_code, error_message)
# 处理 Socket 错误(如果需要)
func _on_socket_error(error: String) -> void:
_handle_error("SOCKET_ERROR", error)
# ============================================================================
# 内部方法 - 工具函数
# ============================================================================
# 处理错误
func _handle_error(error_code: String, error_message: String) -> void:
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
EventSystem.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
EventSystem.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) -> 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):
if str(_pending_self_messages[i].get("content", "")) == content:
_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()

View File

@@ -1 +0,0 @@
uid://b6lnbss2i3pss

View File

@@ -57,6 +57,7 @@ var game_version: String = "1.0.0" # 游戏版本号
# 初始化游戏管理器 # 初始化游戏管理器
# 在节点准备就绪时调用,设置初始状态 # 在节点准备就绪时调用,设置初始状态
func _ready(): func _ready():
print("GameManager 初始化完成")
change_state(GameState.AUTH) # 启动时进入认证状态 change_state(GameState.AUTH) # 启动时进入认证状态
# ============ 状态管理方法 ============ # ============ 状态管理方法 ============
@@ -80,6 +81,9 @@ func change_state(new_state: GameState):
previous_state = current_state previous_state = current_state
current_state = new_state current_state = new_state
# 输出状态变更日志
print("游戏状态变更: ", GameState.keys()[previous_state], " -> ", GameState.keys()[current_state])
# 发送状态变更信号 # 发送状态变更信号
game_state_changed.emit(new_state) game_state_changed.emit(new_state)
@@ -96,7 +100,7 @@ func get_current_state() -> GameState:
# GameState - 上一个游戏状态 # GameState - 上一个游戏状态
# #
# 使用场景: # 使用场景:
# - 从暂停状态恢复时,返回到暂停前状态 # - 从暂停状态恢复时,返回到之前的状态
# - 错误处理时回退到安全状态 # - 错误处理时回退到安全状态
func get_previous_state() -> GameState: func get_previous_state() -> GameState:
return previous_state return previous_state
@@ -110,12 +114,14 @@ func get_previous_state() -> GameState:
# #
# 功能: # 功能:
# - 存储当前登录用户信息 # - 存储当前登录用户信息
# - 输出用户设置日志
# #
# 注意事项: # 注意事项:
# - 用户登录成功后调用此方法 # - 用户登录成功后调用此方法
# - 用户登出时应传入空字符串 # - 用户登出时应传入空字符串
func set_current_user(username: String): func set_current_user(username: String):
current_user = username current_user = username
print("当前用户设置为: ", username)
# 获取当前登录用户 # 获取当前登录用户
# #

View File

@@ -1,155 +0,0 @@
extends Node
# ============================================================================
# LocationManager.gd - 位置同步管理器
# ============================================================================
# 负责与后端 WebSocket 服务进行位置同步和多人会话管理
#
# 协议文档: new_docs/game_architecture_design.md
# 后端地址: wss://whaletownend.xinghangee.icu/location-broadcast
# ============================================================================
signal connected_to_server()
signal connection_closed()
signal connection_error()
signal session_joined(data: Dictionary)
signal user_joined(data: Dictionary)
signal user_left(data: Dictionary)
signal position_updated(data: Dictionary)
const WS_URL = "wss://whaletownend.xinghangee.icu/location-broadcast"
const PING_INTERVAL = 25.0 # 秒
var _socket: WebSocketPeer
var _connected: bool = false
var _ping_timer: float = 0.0
var _auth_token: String = ""
func _ready():
_socket = WebSocketPeer.new()
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
func _process(delta):
_socket.poll()
var state = _socket.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
if not _connected:
_on_connected()
# 处理接收到的数据包
while _socket.get_available_packet_count() > 0:
var packet = _socket.get_packet()
_handle_packet(packet)
# 心跳处理
_ping_timer += delta
if _ping_timer >= PING_INTERVAL:
_send_heartbeat()
_ping_timer = 0.0
elif state == WebSocketPeer.STATE_CLOSED:
if _connected:
_on_disconnected()
func connect_to_server():
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
return
var err = _socket.connect_to_url(WS_URL)
if err != OK:
push_error("LocationManager: WebSocket 连接请求失败,错误码: %d" % err)
connection_error.emit()
else:
# Godot WebSocket connect is non-blocking, wait for state change in _process
pass
func close_connection():
_socket.close()
func set_auth_token(token: String):
_auth_token = token
# ============ 协议发送 ============
func send_packet(event: String, data: Dictionary):
if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
return
var message = {
"event": event,
"data": data
}
var json_str = JSON.stringify(message)
_socket.put_packet(json_str.to_utf8_buffer())
func join_session(map_id: String, initial_pos: Vector2):
var data = {
"sessionId": map_id,
"initialPosition": {
"x": initial_pos.x,
"y": initial_pos.y,
"mapId": map_id
},
"token": _auth_token
}
send_packet("join_session", data)
func leave_session(map_id: String):
send_packet("leave_session", {"sessionId": map_id})
func send_position_update(map_id: String, pos: Vector2, anim_data: Dictionary = {}):
var data = {
"x": pos.x,
"y": pos.y,
"mapId": map_id,
"metadata": anim_data
}
if map_id == "":
push_warning("LocationManager: position_update 的 map_id 为空")
send_packet("position_update", data)
func _send_heartbeat():
send_packet("heartbeat", {"timestamp": Time.get_unix_time_from_system()})
# ============ 事件处理 ============
func _on_connected():
_connected = true
connected_to_server.emit()
func _on_disconnected():
_connected = false
connection_closed.emit()
func _handle_packet(packet: PackedByteArray):
var json_str = packet.get_string_from_utf8()
var json = JSON.new()
var err = json.parse(json_str)
if err != OK:
push_error("LocationManager: JSON 解析失败 - %s" % json.get_error_message())
return
var message = json.data
if not message is Dictionary or not message.has("event"):
return
var event = message["event"]
var data = message.get("data", {})
match event:
"session_joined":
session_joined.emit(data)
"user_joined":
user_joined.emit(data)
"user_left":
user_left.emit(data)
"position_update":
position_updated.emit(data)
"heartbeat_response":
pass # 静默处理
"error":
push_error("LocationManager: WebSocket 错误事件 - %s" % JSON.stringify(data))
_:
push_warning("LocationManager: 未处理的 WebSocket 事件 %s" % event)

View File

@@ -1 +0,0 @@
uid://biyq426f66nkb

View File

@@ -38,10 +38,17 @@ signal request_completed(request_id: String, success: bool, data: Dictionary)
# message: String - 错误消息 # message: String - 错误消息
signal request_failed(request_id: String, error_type: String, message: String) signal request_failed(request_id: String, error_type: String, message: String)
# 公告列表接收信号
signal notices_received(data: Array)
# ============ 常量定义 ============ # ============ 常量定义 ============
# API基础URL - 所有请求的根地址 # API基础URL - 所有请求的根地址
const API_BASE_URL = "https://whaletownend.xinghangee.icu" # [Remote] 正式环境地址 (实际正式项目用此地址)
# const API_BASE_URL = "https://whaletownend.xinghangee.icu"
# [Local] 本地调试地址 (本地调试用此地址)
const API_BASE_URL = "http://localhost:3000"
# 默认请求超时时间(秒) # 默认请求超时时间(秒)
const DEFAULT_TIMEOUT = 30.0 const DEFAULT_TIMEOUT = 30.0
@@ -109,6 +116,14 @@ class RequestInfo:
var active_requests: Dictionary = {} # 存储所有活动请求 {request_id: RequestInfo} var active_requests: Dictionary = {} # 存储所有活动请求 {request_id: RequestInfo}
var request_counter: int = 0 # 请求计数器用于生成唯一ID var request_counter: int = 0 # 请求计数器用于生成唯一ID
# ============ 生命周期方法 ============
# 初始化网络管理器
# 在节点准备就绪时调用
func _ready():
process_mode = Node.PROCESS_MODE_ALWAYS
print("NetworkManager 已初始化")
# ============ 公共API接口 ============ # ============ 公共API接口 ============
# 发送GET请求 # 发送GET请求
@@ -186,6 +201,14 @@ func delete_request(endpoint: String, callback: Callable = Callable(), timeout:
# #
# 回调函数签名: # 回调函数签名:
# func callback(success: bool, data: Dictionary, error_info: Dictionary) # func callback(success: bool, data: Dictionary, error_info: Dictionary)
#
# 使用示例:
# NetworkManager.login("user@example.com", "password123", func(success, data, error):
# if success:
# print("登录成功: ", data)
# else:
# print("登录失败: ", error.message)
# )
func login(identifier: String, password: String, callback: Callable = Callable()) -> String: func login(identifier: String, password: String, callback: Callable = Callable()) -> String:
var data = { var data = {
"identifier": identifier, "identifier": identifier,
@@ -421,6 +444,18 @@ func github_login(github_id: String, username: String, nickname: String, email:
return post_request("/auth/github", data, callback) return post_request("/auth/github", data, callback)
# TODO: 获取公告列表
func request_notices():
# 发送 GET 请求到 /notices 接口
get_request("/notices", _on_notices_response)
func _on_notices_response(success: bool, data: Dictionary, error_info: Dictionary):
if success and data.has("data"):
notices_received.emit(data["data"])
else:
# 失败或无数据时发送空数组
notices_received.emit([])
# ============ 核心请求处理 ============ # ============ 核心请求处理 ============
# 发送请求的核心方法 # 发送请求的核心方法
@@ -457,24 +492,41 @@ func send_request(endpoint: String, method: RequestType, headers: PackedStringAr
var godot_method = _convert_to_godot_method(method) var godot_method = _convert_to_godot_method(method)
var error = http_request.request(full_url, headers, godot_method, body) var error = http_request.request(full_url, headers, godot_method, body)
print("=== 发送网络请求 ===")
print("请求ID: ", request_id)
print("URL: ", full_url)
print("方法: ", RequestType.keys()[method])
print("Headers: ", headers)
print("Body: ", body if body.length() < 200 else body.substr(0, 200) + "...")
print("发送结果: ", error)
if error != OK: if error != OK:
push_error("NetworkManager: 请求发送失败,错误码: %d" % error) print("请求发送失败,错误码: ", error)
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络请求发送失败: " + str(error)) _handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络请求发送失败: " + str(error))
return "" return ""
return request_id return request_id
# 请求完成回调 # 请求完成回调
func _on_request_completed(request_id: String, _result: int, response_code: int, func _on_request_completed(request_id: String, result: int, response_code: int,
_headers: PackedStringArray, body: PackedByteArray): headers: PackedStringArray, body: PackedByteArray):
print("=== 网络请求完成 ===")
print("请求ID: ", request_id)
print("结果: ", result)
print("状态码: ", response_code)
print("响应头: ", headers)
# 获取请求信息 # 获取请求信息
if not active_requests.has(request_id): if not active_requests.has(request_id):
push_warning("NetworkManager: 未找到请求ID %s" % request_id) print("警告: 未找到请求ID ", request_id)
return return
var _request_info = active_requests[request_id] var _request_info = active_requests[request_id]
var response_text = body.get_string_from_utf8() var response_text = body.get_string_from_utf8()
print("响应体长度: ", body.size(), " 字节")
print("响应内容: ", response_text if response_text.length() < 500 else response_text.substr(0, 500) + "...")
# 处理网络连接失败 # 处理网络连接失败
if response_code == 0: if response_code == 0:
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络连接失败,请检查网络连接") _handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络连接失败,请检查网络连接")
@@ -510,11 +562,13 @@ func _handle_response(request_id: String, response_code: int, data: Dictionary):
# 特殊情况206测试模式 - 根据API文档这是成功的测试模式响应 # 特殊情况206测试模式 - 根据API文档这是成功的测试模式响应
elif response_code == 206 and error_code == "TEST_MODE_ONLY": elif response_code == 206 and error_code == "TEST_MODE_ONLY":
is_success = true is_success = true
print("🧪 测试模式响应: ", message)
# 201创建成功 # 201创建成功
elif response_code == 201: elif response_code == 201:
is_success = true is_success = true
if is_success: if is_success:
print("✅ 请求成功: ", request_id)
# 发送成功信号 # 发送成功信号
request_completed.emit(request_id, true, data) request_completed.emit(request_id, true, data)
@@ -522,6 +576,8 @@ func _handle_response(request_id: String, response_code: int, data: Dictionary):
if request_info.callback.is_valid(): if request_info.callback.is_valid():
request_info.callback.call(true, data, {}) request_info.callback.call(true, data, {})
else: else:
print("❌ 请求失败: ", request_id, " - HTTP:", response_code, " 错误码:", error_code, " 消息:", message)
# 确定错误类型 # 确定错误类型
var error_type = _determine_error_type(response_code, error_code) var error_type = _determine_error_type(response_code, error_code)
@@ -543,7 +599,7 @@ func _handle_response(request_id: String, response_code: int, data: Dictionary):
# 处理请求错误 # 处理请求错误
func _handle_request_error(request_id: String, error_type: ErrorType, message: String): func _handle_request_error(request_id: String, error_type: ErrorType, message: String):
push_error("NetworkManager: 请求错误 %s - %s" % [request_id, message]) print(" 请求错误: ", request_id, " - ", message)
# 发送错误信号 # 发送错误信号
request_failed.emit(request_id, ErrorType.keys()[error_type], message) request_failed.emit(request_id, ErrorType.keys()[error_type], message)
@@ -603,6 +659,8 @@ func _cleanup_request(request_id: String):
# 从活动请求中移除 # 从活动请求中移除
active_requests.erase(request_id) active_requests.erase(request_id)
print("🧹 清理请求: ", request_id)
# 转换请求方法 # 转换请求方法
func _convert_to_godot_method(method: RequestType) -> HTTPClient.Method: func _convert_to_godot_method(method: RequestType) -> HTTPClient.Method:
match method: match method:
@@ -624,12 +682,14 @@ func _convert_to_godot_method(method: RequestType) -> HTTPClient.Method:
# 取消请求 # 取消请求
func cancel_request(request_id: String) -> bool: func cancel_request(request_id: String) -> bool:
if active_requests.has(request_id): if active_requests.has(request_id):
print("🚫 取消请求: ", request_id)
_cleanup_request(request_id) _cleanup_request(request_id)
return true return true
return false return false
# 取消所有请求 # 取消所有请求
func cancel_all_requests(): func cancel_all_requests():
print("🚫 取消所有请求")
var request_ids = active_requests.keys() var request_ids = active_requests.keys()
for request_id in request_ids: for request_id in request_ids:
cancel_request(request_id) cancel_request(request_id)

View File

@@ -128,15 +128,21 @@ func handle_send_verification_code_response(success: bool, data: Dictionary, err
var error_code = data.get("error_code", "") var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY": if error_code == "TEST_MODE_ONLY":
result.success = true result.success = true
result.message = "🧪 测试模式:验证码已生成" result.message = "🧪 测试模式:验证码已生成,请查看控制台"
result.toast_type = "success" result.toast_type = "success"
# 在控制台显示验证码
if data.has("data") and data.data.has("verification_code"): if data.has("data") and data.data.has("verification_code"):
print("🔑 测试模式验证码: ", data.data.verification_code)
result.message += "\n验证码: " + str(data.data.verification_code) result.message += "\n验证码: " + str(data.data.verification_code)
else: else:
result.success = true result.success = true
result.message = "📧 验证码已发送到您的邮箱,请查收" result.message = "📧 验证码已发送到您的邮箱,请查收"
result.toast_type = "success" result.toast_type = "success"
# 开发环境下显示验证码
if data.has("data") and data.data.has("verification_code"):
print("🔑 开发环境验证码: ", data.data.verification_code)
else: else:
result = _handle_send_code_error(data, error_info) result = _handle_send_code_error(data, error_info)
@@ -150,15 +156,18 @@ func handle_send_login_code_response(success: bool, data: Dictionary, error_info
var error_code = data.get("error_code", "") var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY": if error_code == "TEST_MODE_ONLY":
result.success = true result.success = true
result.message = "测试模式:登录验证码已生成" result.message = "测试模式:登录验证码已生成,请查看控制台"
result.toast_type = "success" result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"): if data.has("data") and data.data.has("verification_code"):
result.message += "\n验证码: " + str(data.data.verification_code) print("测试模式登录验证码: ", data.data.verification_code)
else: else:
result.success = true result.success = true
result.message = "登录验证码已发送,请查收" result.message = "登录验证码已发送,请查收"
result.toast_type = "success" result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("开发环境登录验证码: ", data.data.verification_code)
else: else:
result = _handle_send_login_code_error(data, error_info) result = _handle_send_login_code_error(data, error_info)
@@ -205,15 +214,18 @@ func handle_resend_email_verification_response(success: bool, data: Dictionary,
var error_code = data.get("error_code", "") var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY": if error_code == "TEST_MODE_ONLY":
result.success = true result.success = true
result.message = "🧪 测试模式:验证码已重新生成" result.message = "🧪 测试模式:验证码已重新生成,请查看控制台"
result.toast_type = "success" result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"): if data.has("data") and data.data.has("verification_code"):
result.message += "\n验证码: " + str(data.data.verification_code) print("🔑 测试模式重新发送验证码: ", data.data.verification_code)
else: else:
result.success = true result.success = true
result.message = "📧 验证码已重新发送到您的邮箱,请查收" result.message = "📧 验证码已重新发送到您的邮箱,请查收"
result.toast_type = "success" result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("🔑 开发环境重新发送验证码: ", data.data.verification_code)
else: else:
result = _handle_resend_email_verification_error(data, error_info) result = _handle_resend_email_verification_error(data, error_info)
@@ -227,15 +239,18 @@ func handle_forgot_password_response(success: bool, data: Dictionary, error_info
var error_code = data.get("error_code", "") var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY": if error_code == "TEST_MODE_ONLY":
result.success = true result.success = true
result.message = "🧪 测试模式:重置验证码已生成" result.message = "🧪 测试模式:重置验证码已生成,请查看控制台"
result.toast_type = "success" result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"): if data.has("data") and data.data.has("verification_code"):
result.message += "\n验证码: " + str(data.data.verification_code) print("🔑 测试模式重置验证码: ", data.data.verification_code)
else: else:
result.success = true result.success = true
result.message = "📧 重置验证码已发送,请查收" result.message = "📧 重置验证码已发送,请查收"
result.toast_type = "success" result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("🔑 开发环境重置验证码: ", data.data.verification_code)
else: else:
result = _handle_forgot_password_error(data, error_info) result = _handle_forgot_password_error(data, error_info)

View File

@@ -50,13 +50,17 @@ var scene_paths: Dictionary = {
"inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面 "inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面
"shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面 "shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面
"settings": "res://scenes/ui/SettingsWindow.tscn", # 设置界面 "settings": "res://scenes/ui/SettingsWindow.tscn", # 设置界面
"square": "res://scenes/Maps/square.tscn", # 广场地图 "room": "res://scenes/Maps/room.tscn", # 房间场景
"room": "res://scenes/Maps/room.tscn", # 房间地图 "square": "res://scenes/Maps/square.tscn" # 广场场景
"fountain": "res://scenes/Maps/fountain.tscn", # 喷泉地图
"datawhale_home": "res://scenes/Maps/datawhale_home.tscn", # 数据鲸鱼之家
"community": "res://scenes/Maps/community.tscn" # 社区地图
} }
# ============ 生命周期方法 ============
# 初始化场景管理器
# 在节点准备就绪时调用
func _ready():
print("SceneManager 初始化完成")
# ============ 场景切换方法 ============ # ============ 场景切换方法 ============
# 切换到指定场景 # 切换到指定场景
@@ -77,6 +81,8 @@ var scene_paths: Dictionary = {
# #
# 使用示例: # 使用示例:
# var success = SceneManager.change_scene("main", true) # var success = SceneManager.change_scene("main", true)
# if success:
# print("场景切换成功")
# #
# 注意事项: # 注意事项:
# - 场景切换是异步操作 # - 场景切换是异步操作
@@ -85,15 +91,16 @@ var scene_paths: Dictionary = {
func change_scene(scene_name: String, use_transition: bool = true): func change_scene(scene_name: String, use_transition: bool = true):
# 防止重复切换 # 防止重复切换
if is_changing_scene: if is_changing_scene:
push_warning("SceneManager: 场景切换中,忽略新的切换请求") print("场景切换中,忽略新的切换请求")
return false return false
# 检查场景是否存在 # 检查场景是否存在
if not scene_paths.has(scene_name): if not scene_paths.has(scene_name):
push_error("SceneManager: 未找到场景 %s" % scene_name) print("错误: 未找到场景 ", scene_name)
return false return false
var scene_path = scene_paths[scene_name] var scene_path = scene_paths[scene_name]
print("开始切换场景: ", current_scene_name, " -> ", scene_name)
# 设置切换状态 # 设置切换状态
is_changing_scene = true is_changing_scene = true
@@ -103,15 +110,18 @@ func change_scene(scene_name: String, use_transition: bool = true):
if use_transition: if use_transition:
await show_transition() await show_transition()
# 更新场景名称(在切换之前设置,确保新场景的 _ready 能获取正确的名称)
current_scene_name = scene_name
# 执行场景切换 # 执行场景切换
var error = get_tree().change_scene_to_file(scene_path) var error = get_tree().change_scene_to_file(scene_path)
if error != OK: if error != OK:
push_error("SceneManager: 场景切换失败 %s -> %s, 错误码: %d" % [current_scene_name, scene_name, error]) print("场景切换失败: ", error)
current_scene_name = "" # 恢复为空
is_changing_scene = false is_changing_scene = false
return false return false
# 更新状态 # 更新状态
current_scene_name = scene_name
is_changing_scene = false is_changing_scene = false
scene_changed.emit(scene_name) scene_changed.emit(scene_name)
@@ -119,6 +129,7 @@ func change_scene(scene_name: String, use_transition: bool = true):
if use_transition: if use_transition:
await hide_transition() await hide_transition()
print("场景切换完成: ", scene_name)
return true return true
# ============ 查询方法 ============ # ============ 查询方法 ============
@@ -130,72 +141,27 @@ func change_scene(scene_name: String, use_transition: bool = true):
func get_current_scene_name() -> String: func get_current_scene_name() -> String:
return current_scene_name return current_scene_name
# ============ 场景位置和出生点管理 ============
# 设置下一个场景的初始位置 # 设置下一个场景的初始位置
#
# 参数:
# pos: Vector2 - 玩家在下一个场景的初始位置
#
# 功能:
# - 用于场景切换时传递玩家位置信息
# - 配合 DoorTeleport 等传送机制使用
#
# 使用示例:
# SceneManager.set_next_scene_position(Vector2(100, 200))
# SceneManager.change_scene("room")
func set_next_scene_position(pos: Vector2) -> void: func set_next_scene_position(pos: Vector2) -> void:
_next_scene_position = pos _next_scene_position = pos
# 获取并清除下一个场景的初始位置 # 获取并清除下一个场景的初始位置
#
# 返回值:
# Variant - Vector2 位置或 null如果未设置
#
# 功能:
# - 获取预设的场景初始位置
# - 获取后自动清除,避免影响后续场景切换
#
# 注意事项:
# - 此方法会清除存储的位置,只能获取一次
# - 如果未设置位置,返回 null
func get_next_scene_position() -> Variant: func get_next_scene_position() -> Variant:
var pos = _next_scene_position var pos = _next_scene_position
_next_scene_position = null _next_scene_position = null
return pos return pos
# 设置下一个场景的出生点名称 # 设置下一个场景的出生点名称
#
# 参数:
# spawn_name: String - 出生点节点的名称
#
# 功能:
# - 指定玩家在下一个场景应该出现在哪个出生点
# - 配合场景中的 Marker2D 出生点使用
#
# 使用示例:
# SceneManager.set_next_spawn_name("DoorExit")
# SceneManager.change_scene("square")
func set_next_spawn_name(spawn_name: String) -> void: func set_next_spawn_name(spawn_name: String) -> void:
_next_spawn_name = spawn_name _next_spawn_name = spawn_name
# 获取并清除下一个场景的出生点名称 # 获取并清除下一个场景的出生点名称
#
# 返回值:
# String - 出生点名称(如果未设置则返回空字符串)
#
# 功能:
# - 获取预设的出生点名称
# - 获取后自动清除,避免影响后续场景切换
#
# 注意事项:
# - 此方法会清除存储的名称,只能获取一次
# - 如果未设置名称,返回空字符串
func get_next_spawn_name() -> String: func get_next_spawn_name() -> String:
var name = _next_spawn_name var name = _next_spawn_name
_next_spawn_name = "" _next_spawn_name = ""
return name return name
# ============ 场景注册方法 ============ # ============ 场景注册方法 ============
# 注册新场景 # 注册新场景
@@ -212,6 +178,7 @@ func get_next_spawn_name() -> String:
# SceneManager.register_scene("boss_battle", "res://scenes/boss/boss_battle.tscn") # SceneManager.register_scene("boss_battle", "res://scenes/boss/boss_battle.tscn")
func register_scene(scene_name: String, scene_path: String): func register_scene(scene_name: String, scene_path: String):
scene_paths[scene_name] = scene_path scene_paths[scene_name] = scene_path
print("注册场景: ", scene_name, " -> ", scene_path)
# ============ 过渡效果方法 ============ # ============ 过渡效果方法 ============
@@ -227,7 +194,8 @@ func register_scene(scene_name: String, scene_path: String):
# #
# TODO: 实现淡入淡出、滑动等过渡效果 # TODO: 实现淡入淡出、滑动等过渡效果
func show_transition(): func show_transition():
# TODO: 实现场景切换过渡效果(当前仅占位延时) # TODO: 实现场景切换过渡效果
print("显示场景切换过渡效果")
await get_tree().create_timer(0.2).timeout await get_tree().create_timer(0.2).timeout
# 隐藏场景切换过渡效果 # 隐藏场景切换过渡效果
@@ -242,5 +210,6 @@ func show_transition():
# #
# TODO: 实现与show_transition()对应的隐藏效果 # TODO: 实现与show_transition()对应的隐藏效果
func hide_transition(): func hide_transition():
# TODO: 隐藏场景切换过渡效果(当前仅占位延时) # TODO: 隐藏场景切换过渡效果
print("隐藏场景切换过渡效果")
await get_tree().create_timer(0.2).timeout await get_tree().create_timer(0.2).timeout

View File

@@ -40,6 +40,7 @@ var toast_counter: int = 0 # Toast计数器用于生成
# container: Control - Toast消息的容器节点 # container: Control - Toast消息的容器节点
func setup(container: Control): func setup(container: Control):
toast_container = container toast_container = container
print("ToastManager 初始化完成")
# ============ 公共方法 ============ # ============ 公共方法 ============
@@ -50,9 +51,10 @@ func setup(container: Control):
# is_success: bool - 是否为成功消息(影响颜色) # is_success: bool - 是否为成功消息(影响颜色)
func show_toast(message: String, is_success: bool = true): func show_toast(message: String, is_success: bool = true):
if toast_container == null: if toast_container == null:
push_error("ToastManager: toast_container 节点不存在") print("错误: toast_container 节点不存在")
return return
print("显示Toast消息: ", message, " 成功: ", is_success)
_create_toast_instance(message, is_success) _create_toast_instance(message, is_success)
# 清理所有Toast # 清理所有Toast
@@ -121,17 +123,21 @@ func _create_toast_instance(message: String, is_success: bool):
# 平台特定的字体处理 # 平台特定的字体处理
if is_web: if is_web:
print("Web平台Toast字体处理")
# Web平台使用主题文件 # Web平台使用主题文件
var chinese_theme = load("res://assets/ui/chinese_theme.tres") var chinese_theme = load("res://assets/ui/chinese_theme.tres")
if chinese_theme: if chinese_theme:
text_label.theme = chinese_theme text_label.theme = chinese_theme
print("Web平台应用中文主题")
else: else:
push_warning("ToastManager: Web平台中文主题加载失败,使用默认字体") print("Web平台中文主题加载失败")
else: else:
print("桌面平台Toast字体处理")
# 桌面平台直接加载中文字体 # 桌面平台直接加载中文字体
var desktop_chinese_font = load("res://assets/fonts/msyh.ttc") var desktop_chinese_font = load("res://assets/fonts/msyh.ttc")
if desktop_chinese_font: if desktop_chinese_font:
text_label.add_theme_font_override("font", desktop_chinese_font) text_label.add_theme_font_override("font", desktop_chinese_font)
print("桌面平台使用中文字体")
text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
text_label.custom_minimum_size = Vector2(280, 0) text_label.custom_minimum_size = Vector2(280, 0)

View File

@@ -1,429 +1,172 @@
extends Node extends Node
# ============================================================================ # ============================================================================
# WebSocketManager.gd - WebSocket 连接生命周期管理(原生 WebSocket 版本) # WebSocketManager.gd - WebSocket连接管理器
# ============================================================================ # ============================================================================
# 管理 WebSocket 连接状态、自动重连和错误恢复 # 负责与后端 Native WebSocket 服务进行实时通信
# #
# 核心职责: # 协议文档: new_docs/game_architecture_design.md
# - 连接状态管理(断开、连接中、已连接、重连中) # 后端地址: ws://localhost:3000/location-broadcast
# - 自动重连机制(指数退避)
# - 连接错误恢复
# - WebSocket 消息发送/接收
# ============================================================================
# 使用方式:
# WebSocketManager.connect_to_game_server()
# WebSocketManager.connection_state_changed.connect(_on_state_changed)
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 自动处理连接断开和重连
# - 通过信号通知连接状态变化
# ============================================================================ # ============================================================================
class_name ChatWebSocketManager signal connected_to_server()
signal connection_closed()
signal connection_error()
signal session_joined(data: Dictionary)
signal user_joined(data: Dictionary)
signal user_left(data: Dictionary)
signal position_updated(data: Dictionary)
# ============================================================================ const WS_URL = "wss://whaletownend.xinghangee.icu/location-broadcast"
# 信号定义 const PING_INTERVAL = 25.0 # 秒
# ============================================================================
# 连接状态变化信号 var _socket: WebSocketPeer
# 参数: var _connected: bool = false
# new_state: ConnectionState - 新的连接状态 var _ping_timer: float = 0.0
signal connection_state_changed(new_state: ConnectionState) var _auth_token: String = ""
# 连接丢失信号 func _ready():
signal connection_lost() _socket = WebSocketPeer.new()
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
print("WebSocketManager Initialized")
# 重连成功信号 func _process(delta):
signal reconnection_succeeded() _socket.poll()
var state = _socket.get_ready_state()
# 重连失败信号
# 参数:
# attempt: int - 当前重连尝试次数
# max_attempts: int - 最大重连次数
signal reconnection_failed(attempt: int, max_attempts: int)
# WebSocket 消息接收信号
# 参数:
# message: String - 接收到的消息内容JSON 字符串)
signal data_received(message: String)
# ============================================================================
# 枚举定义
# ============================================================================
# 连接状态枚举
enum ConnectionState {
DISCONNECTED, # 未连接
CONNECTING, # 连接中
CONNECTED, # 已连接
RECONNECTING, # 重连中
ERROR # 错误状态
}
# ============================================================================
# 常量定义
# ============================================================================
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 默认最大重连次数
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
# 默认重连基础延迟(秒)
const DEFAULT_RECONNECT_BASE_DELAY: float = 3.0
# 最大重连延迟(秒)
const MAX_RECONNECT_DELAY: float = 30.0
# ============================================================================
# 成员变量
# ============================================================================
# WebSocket peer
var _websocket_peer: WebSocketPeer = WebSocketPeer.new()
# 当前连接状态
var _connection_state: ConnectionState = ConnectionState.DISCONNECTED
# 自动重连启用标志
var _auto_reconnect_enabled: bool = true
# 最大重连次数
var _max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS
# 重连基础延迟
var _reconnect_base_delay: float = DEFAULT_RECONNECT_BASE_DELAY
# 当前重连尝试次数
var _reconnect_attempt: int = 0
# 重连定时器
var _reconnect_timer: Timer = Timer.new()
# 是否为正常关闭(非异常断开)
var _clean_close: bool = true
# 心跳定时器
var _heartbeat_timer: Timer = Timer.new()
# 心跳间隔(秒)
const HEARTBEAT_INTERVAL: float = 30.0
# ============================================================================
# 生命周期方法
# ============================================================================
# 初始化
func _ready() -> void:
# 设置重连定时器
_setup_reconnect_timer()
# 设置心跳定时器
_setup_heartbeat_timer()
# 启动处理循环
set_process(true)
# 处理每帧
func _process(_delta: float) -> void:
# 检查 WebSocket 状态变化
_check_websocket_state()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
if state == WebSocketPeer.STATE_OPEN: if state == WebSocketPeer.STATE_OPEN:
# 接收数据 if not _connected:
_websocket_peer.poll() _on_connected()
# 处理收到的数据 # 处理收到的数据
while _websocket_peer.get_available_packet_count() > 0: while _socket.get_available_packet_count() > 0:
var packet: PackedByteArray = _websocket_peer.get_packet() var packet = _socket.get_packet()
var message: String = packet.get_string_from_utf8() _handle_packet(packet)
# 发射消息接收信号 # 心跳处理
data_received.emit(message) _ping_timer += delta
if _ping_timer >= PING_INTERVAL:
_send_heartbeat()
_ping_timer = 0.0
# 清理 elif state == WebSocketPeer.STATE_CLOSED:
func _exit_tree() -> void: if _connected:
_disconnect() _on_disconnected()
if is_instance_valid(_reconnect_timer): func connect_to_server():
_reconnect_timer.stop() if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
_reconnect_timer.queue_free() print("WebSocket 已经是连接状态")
if is_instance_valid(_heartbeat_timer):
_heartbeat_timer.stop()
_heartbeat_timer.queue_free()
# ============================================================================
# 公共 API - 连接管理
# ============================================================================
# 连接到游戏服务器
func connect_to_game_server() -> void:
if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING:
push_warning("已经在连接或已连接状态")
return return
_set_connection_state(ConnectionState.CONNECTING) print("正在连接 WebSocket: ", WS_URL)
_clean_close = true var err = _socket.connect_to_url(WS_URL)
_reconnect_attempt = 0
var err: Error = _websocket_peer.connect_to_url(WEBSOCKET_URL)
if err != OK: if err != OK:
push_error("WebSocketManager: 连接失败 - %s" % error_string(err)) print("WebSocket 连接请求失败: ", err)
_set_connection_state(ConnectionState.ERROR) connection_error.emit()
return
# 启动心跳
_start_heartbeat()
# 断开 WebSocket 连接
func disconnect_websocket() -> void:
_disconnect()
# 断开连接(内部方法)
func _disconnect() -> void:
_clean_close = true
# 停止重连定时器
_reconnect_timer.stop()
# 停止心跳
_heartbeat_timer.stop()
# 关闭 WebSocket
if _websocket_peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
_websocket_peer.close()
_set_connection_state(ConnectionState.DISCONNECTED)
# 检查 WebSocket 是否已连接
#
# 返回值:
# bool - WebSocket 是否已连接
func is_websocket_connected() -> bool:
return _connection_state == ConnectionState.CONNECTED
# 获取当前连接状态
#
# 返回值:
# ConnectionState - 当前连接状态
func get_connection_state() -> ConnectionState:
return _connection_state
# ============================================================================
# 公共 API - 消息发送
# ============================================================================
# 发送 WebSocket 消息
#
# 参数:
# message: String - 要发送的消息内容JSON 字符串)
#
# 返回值:
# Error - 错误码OK 表示成功
func send_message(message: String) -> Error:
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
push_warning("WebSocketManager: 未连接,无法发送消息")
return ERR_UNCONFIGURED
var err: Error = _websocket_peer.send_text(message)
if err != OK:
push_error("WebSocketManager: 发送消息失败 - %s" % error_string(err))
return err
return OK
# ============================================================================
# 公共 API - 自动重连
# ============================================================================
# 启用/禁用自动重连
#
# 参数:
# enabled: bool - 是否启用自动重连
# max_attempts: int - 最大重连次数(默认 5
# base_delay: float - 基础重连延迟,秒(默认 3.0
#
# 使用示例:
# WebSocketManager.enable_auto_reconnect(true, 5, 3.0)
func enable_auto_reconnect(enabled: bool, max_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS, base_delay: float = DEFAULT_RECONNECT_BASE_DELAY) -> void:
_auto_reconnect_enabled = enabled
_max_reconnect_attempts = max_attempts
_reconnect_base_delay = base_delay
# 获取重连信息
#
# 返回值:
# Dictionary - 重连信息 {enabled, attempt, max_attempts, delay}
func get_reconnect_info() -> Dictionary:
return {
"enabled": _auto_reconnect_enabled,
"attempt": _reconnect_attempt,
"max_attempts": _max_reconnect_attempts,
"next_delay": _calculate_reconnect_delay() if _connection_state == ConnectionState.RECONNECTING else 0.0
}
# ============================================================================
# 内部方法 - 连接状态管理
# ============================================================================
# 设置连接状态
func _set_connection_state(new_state: ConnectionState) -> void:
if _connection_state == new_state:
return
_connection_state = new_state
# 发射信号
connection_state_changed.emit(new_state)
# ============================================================================
# 内部方法 - WebSocket 状态监控
# ============================================================================
# 检查 WebSocket 状态变化
func _check_websocket_state() -> void:
# 必须先 poll 才能获取最新状态
_websocket_peer.poll()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
match state:
WebSocketPeer.STATE_CONNECTING:
# 正在连接
if _connection_state != ConnectionState.CONNECTING and _connection_state != ConnectionState.RECONNECTING:
_set_connection_state(ConnectionState.CONNECTING)
WebSocketPeer.STATE_OPEN:
# 连接成功
if _connection_state != ConnectionState.CONNECTED:
_on_websocket_connected()
WebSocketPeer.STATE_CLOSING:
# 正在关闭
pass
WebSocketPeer.STATE_CLOSED:
# 连接关闭
var code: int = _websocket_peer.get_close_code()
_on_websocket_closed(code != 0) # code=0 表示正常关闭
# WebSocket 连接成功处理
func _on_websocket_connected() -> void:
# 如果是重连,发射重连成功信号
if _connection_state == ConnectionState.RECONNECTING:
_reconnect_attempt = 0
reconnection_succeeded.emit()
_set_connection_state(ConnectionState.CONNECTED)
# WebSocket 连接关闭处理
func _on_websocket_closed(clean_close: bool) -> void:
_clean_close = clean_close
# 如果是异常断开且启用了自动重连
if not clean_close and _auto_reconnect_enabled:
connection_lost.emit()
_attempt_reconnect()
else: else:
_set_connection_state(ConnectionState.DISCONNECTED) # Godot WebSocket connect is non-blocking, wait for state change in _process
pass
# ============================================================================ func close_connection():
# 内部方法 - 重连机制 _socket.close()
# ============================================================================
# 设置重连定时器 func set_auth_token(token: String):
func _setup_reconnect_timer() -> void: _auth_token = token
_reconnect_timer = Timer.new()
_reconnect_timer.one_shot = true
_reconnect_timer.autostart = false
add_child(_reconnect_timer)
_reconnect_timer.timeout.connect(_on_reconnect_timeout) # ============ 协议发送 ============
# 尝试重连 func send_packet(event: String, data: Dictionary):
func _attempt_reconnect() -> void: if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
# 检查是否超过最大重连次数
if _reconnect_attempt >= _max_reconnect_attempts:
push_error("WebSocketManager: 达到最大重连次数 (%d),停止重连" % _max_reconnect_attempts)
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
_set_connection_state(ConnectionState.ERROR)
return return
_reconnect_attempt += 1 var message = {
_set_connection_state(ConnectionState.RECONNECTING) "event": event,
"data": data
}
var json_str = JSON.stringify(message)
_socket.put_packet(json_str.to_utf8_buffer())
# 计算重连延迟(指数退避) func join_session(map_id: String, initial_pos: Vector2):
var delay: float = _calculate_reconnect_delay() var data = {
"sessionId": map_id,
"initialPosition": {
"x": initial_pos.x,
"y": initial_pos.y,
"mapId": map_id
},
"token": _auth_token
}
print("发送加入会话请求: ", map_id, " mapId Payload: ", data.initialPosition.mapId)
send_packet("join_session", data)
# 启动重连定时器 func leave_session(map_id: String):
_reconnect_timer.start(delay) print("发送离开会话请求: ", map_id)
send_packet("leave_session", {"sessionId": map_id})
# 计算重连延迟(指数退避) func send_position_update(map_id: String, pos: Vector2, anim_data: Dictionary = {}):
func _calculate_reconnect_delay() -> float: var data = {
# 指数退避: base_delay * 2^(attempt-1) "x": pos.x,
var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1) "y": pos.y,
"mapId": map_id,
"metadata": anim_data
}
# print("发送位置更新: ", map_id)
if map_id == "":
print("WARNING: Sending position update with EMPTY mapId! Pos: ", pos)
send_packet("position_update", data)
# 限制最大延迟 func _send_heartbeat():
return min(delay, MAX_RECONNECT_DELAY) send_packet("heartbeat", {"timestamp": Time.get_unix_time_from_system()})
# 重连定时器超时处理 # ============ 事件处理 ============
func _on_reconnect_timeout() -> void:
_clean_close = false
connect_to_game_server()
# ============================================================================ func _on_connected():
# 内部方法 - 心跳机制 _connected = true
# ============================================================================ print("WebSocket 连接成功!")
connected_to_server.emit()
# 设置心跳定时器 func _on_disconnected():
func _setup_heartbeat_timer() -> void: _connected = false
_heartbeat_timer = Timer.new() var code = _socket.get_close_code()
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL var reason = _socket.get_close_reason()
_heartbeat_timer.one_shot = false print("WebSocket 连接断开. Code: %d, Reason: %s" % [code, reason])
_heartbeat_timer.autostart = false connection_closed.emit()
add_child(_heartbeat_timer)
_heartbeat_timer.timeout.connect(_on_heartbeat) func _handle_packet(packet: PackedByteArray):
var json_str = packet.get_string_from_utf8()
var json = JSON.new()
var err = json.parse(json_str)
# 启动心跳 if err != OK:
func _start_heartbeat() -> void: print("JSON 解析失败: ", json.get_error_message())
_heartbeat_timer.start() return
# 停止心跳 var message = json.data
func _stop_heartbeat() -> void: if not message is Dictionary or not message.has("event"):
_heartbeat_timer.stop() return
# 心跳超时处理 var event = message["event"]
func _on_heartbeat() -> void: var data = message.get("data", {})
# 不发送心跳,避免服务器返回 "消息格式错误"
# 如果需要心跳,服务器应该支持特定格式
pass
# ============================================================================ if event != "heartbeat_response":
# 工具方法 print("WebSocket Rx: ", event) # Debug logs for all events
# ============================================================================
# 获取连接状态描述 match event:
# "session_joined":
# 返回值: session_joined.emit(data)
# String - 连接状态描述 print("加入会话成功,当前房间人数: ", data.get("users", []).size())
func get_state_description() -> String: "user_joined":
match _connection_state: user_joined.emit(data)
ConnectionState.DISCONNECTED: print("用户加入: ", data.get("userId"))
return "未连接" "user_left":
ConnectionState.CONNECTING: user_left.emit(data)
return "连接中" print("用户离开: ", data.get("userId"))
ConnectionState.CONNECTED: "position_update":
return "已连接" print("WebSocket Rx position_update: ", data.get("userId", "unknown"))
ConnectionState.RECONNECTING: position_updated.emit(data)
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts] "heartbeat_response":
ConnectionState.ERROR: pass # 静默处理
return "错误" "error":
print("WebSocket 错误事件: ", JSON.stringify(data))
_: _:
return "未知状态" print("未处理的 WebSocket 事件: ", event)

View File

@@ -1 +1 @@
uid://dmbgtbf6gyk6t uid://stpl2jdeqo0d

View File

@@ -27,6 +27,13 @@ extends Node
# 结构: {event_name: [{"callback": Callable, "target": Node}, ...]} # 结构: {event_name: [{"callback": Callable, "target": Node}, ...]}
var event_listeners: Dictionary = {} var event_listeners: Dictionary = {}
# ============ 生命周期方法 ============
# 初始化事件系统
# 在节点准备就绪时调用
func _ready():
print("EventSystem 初始化完成")
# ============ 事件监听器管理 ============ # ============ 事件监听器管理 ============
# 注册事件监听器 # 注册事件监听器
@@ -52,11 +59,6 @@ func connect_event(event_name: String, callback: Callable, target: Node = null):
if not event_listeners.has(event_name): if not event_listeners.has(event_name):
event_listeners[event_name] = [] event_listeners[event_name] = []
# 避免重复注册同一个监听器
for listener in event_listeners[event_name]:
if listener.callback == callback and listener.target == target:
return
# 创建监听器信息 # 创建监听器信息
var listener_info = { var listener_info = {
"callback": callback, "callback": callback,
@@ -65,6 +67,7 @@ func connect_event(event_name: String, callback: Callable, target: Node = null):
# 添加到监听器列表 # 添加到监听器列表
event_listeners[event_name].append(listener_info) event_listeners[event_name].append(listener_info)
print("注册事件监听器: ", event_name, " -> ", callback)
# 移除事件监听器 # 移除事件监听器
# #
@@ -90,6 +93,7 @@ func disconnect_event(event_name: String, callback: Callable, target: Node = nul
# 匹配callback和target # 匹配callback和target
if listener.callback == callback and listener.target == target: if listener.callback == callback and listener.target == target:
listeners.remove_at(i) listeners.remove_at(i)
print("移除事件监听器: ", event_name, " -> ", callback)
break break
# ============ 事件发送 ============ # ============ 事件发送 ============
@@ -113,11 +117,13 @@ func disconnect_event(event_name: String, callback: Callable, target: Node = nul
# - 事件发送是同步的,所有监听器会立即执行 # - 事件发送是同步的,所有监听器会立即执行
# - 如果监听器执行出错,不会影响其他监听器 # - 如果监听器执行出错,不会影响其他监听器
func emit_event(event_name: String, data: Variant = null): func emit_event(event_name: String, data: Variant = null):
print("发送事件: ", event_name, " 数据: ", data)
# 检查是否有监听器 # 检查是否有监听器
if not event_listeners.has(event_name): if not event_listeners.has(event_name):
return return
var listeners = event_listeners[event_name].duplicate() var listeners = event_listeners[event_name]
for listener_info in listeners: for listener_info in listeners:
var target = listener_info.target var target = listener_info.target
var callback = listener_info.callback var callback = listener_info.callback
@@ -155,6 +161,7 @@ func cleanup_invalid_listeners():
# 如果目标节点无效,移除监听器 # 如果目标节点无效,移除监听器
if target != null and not is_instance_valid(target): if target != null and not is_instance_valid(target):
listeners.remove_at(i) listeners.remove_at(i)
print("清理无效监听器: ", event_name)
# ============ 查询方法 ============ # ============ 查询方法 ============
@@ -185,3 +192,4 @@ func get_listener_count(event_name: String) -> int:
# - 使用前请确保所有模块都能正确处理监听器丢失 # - 使用前请确保所有模块都能正确处理监听器丢失
func clear_all_listeners(): func clear_all_listeners():
event_listeners.clear() event_listeners.clear()
print("清空所有事件监听器")

View File

@@ -131,14 +131,13 @@ static func get_grid_rect(grid_pos: Vector2i) -> Rect2:
var world_pos = grid_to_world(grid_pos) var world_pos = grid_to_world(grid_pos)
return Rect2(world_pos, Vector2(GRID_SIZE, GRID_SIZE)) return Rect2(world_pos, Vector2(GRID_SIZE, GRID_SIZE))
# 获取网格信息(调试辅助 # 打印网格信息(调试
static func print_grid_info(world_pos: Vector2) -> Dictionary: static func print_grid_info(world_pos: Vector2) -> void:
var grid_pos = world_to_grid(world_pos) var grid_pos = world_to_grid(world_pos)
var snapped_pos = snap_to_grid(world_pos) var snapped_pos = snap_to_grid(world_pos)
var center_pos = grid_to_world_center(grid_pos) var center_pos = grid_to_world_center(grid_pos)
return {
"world_position": world_pos, print("世界坐标: ", world_pos)
"grid_position": grid_pos, print("网格坐标: ", grid_pos)
"snapped_position": snapped_pos, print("吸附位置: ", snapped_pos)
"center_position": center_pos print("网格中心: ", center_pos)
}

View File

@@ -39,7 +39,7 @@ class_name StringUtils
# #
# 使用示例: # 使用示例:
# if StringUtils.is_valid_email("user@example.com"): # if StringUtils.is_valid_email("user@example.com"):
# # 邮箱格式正确 # print("邮箱格式正确")
static func is_valid_email(email: String) -> bool: static func is_valid_email(email: String) -> bool:
var regex = RegEx.new() var regex = RegEx.new()
regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
@@ -60,7 +60,7 @@ static func is_valid_email(email: String) -> bool:
# #
# 使用示例: # 使用示例:
# if StringUtils.is_valid_username("user_123"): # if StringUtils.is_valid_username("user_123"):
# # 用户名格式正确 # print("用户名格式正确")
static func is_valid_username(username: String) -> bool: static func is_valid_username(username: String) -> bool:
# 检查长度 # 检查长度
if username.is_empty() or username.length() > 50: if username.is_empty() or username.length() > 50:
@@ -92,7 +92,7 @@ static func is_valid_username(username: String) -> bool:
# 使用示例: # 使用示例:
# var result = StringUtils.validate_password_strength("MyPass123!") # var result = StringUtils.validate_password_strength("MyPass123!")
# if result.valid: # if result.valid:
# # 可按需展示 result.message # print("密码强度: ", result.message)
static func validate_password_strength(password: String) -> Dictionary: static func validate_password_strength(password: String) -> Dictionary:
var result = {"valid": false, "message": "", "strength": 0} var result = {"valid": false, "message": "", "strength": 0}

Binary file not shown.

View File

@@ -1,19 +0,0 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://c5ujr1fj14n58"
path="res://.godot/imported/Spawn Fixed.mp3-cdebcc11ab16736c9b4a0906adccb431.mp3str"
[deps]
source_file="res://assets/audio/music/Spawn Fixed.mp3"
dest_files=["res://.godot/imported/Spawn Fixed.mp3-cdebcc11ab16736c9b4a0906adccb431.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c8on1etsscvcg"
path="res://.godot/imported/Image (1).png-aa55b4ad122083e1e56d7de11ec05750.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/auth/Image (1).png"
dest_files=["res://.godot/imported/Image (1).png-aa55b4ad122083e1e56d7de11ec05750.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dh07ed5tsiyhp"
path="res://.godot/imported/Image (2).png-d93edf5c7f9059af2b89d1fb72733335.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/auth/Image (2).png"
dest_files=["res://.godot/imported/Image (2).png-d93edf5c7f9059af2b89d1fb72733335.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://kvgofnnxp31q"
path="res://.godot/imported/Image (3).png-0fd2a2989e780fcbaf4376bb6e288f40.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/auth/Image (3).png"
dest_files=["res://.godot/imported/Image (3).png-0fd2a2989e780fcbaf4376bb6e288f40.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0pt270gaevbk"
path="res://.godot/imported/微信图片_20251218232924_983_1425.png-0163d5f3fcf9765f1d8d0ec22851e7b0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/auth/微信图片_20251218232924_983_1425.png"
dest_files=["res://.godot/imported/微信图片_20251218232924_983_1425.png-0163d5f3fcf9765f1d8d0ec22851e7b0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6wlro1vixo8a"
path="res://.godot/imported/清空内部组件的登录框架 (1).png-4be35396d0272fde2f195d02324c3439.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/auth/清空内部组件的登录框架 (1).png"
dest_files=["res://.godot/imported/清空内部组件的登录框架 (1).png-4be35396d0272fde2f195d02324c3439.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dyma4hpodhdxi"
path="res://.godot/imported/登录背景.png-89ec90e6b5b2b96eba5cf8e1b1d50ed8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/auth/登录背景.png"
dest_files=["res://.godot/imported/登录背景.png-89ec90e6b5b2b96eba5cf8e1b1d50ed8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cnrffaqbtw8f5"
path="res://.godot/imported/输入框.png-74076fbd98c6a5dee8485c2e25f4d583.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/auth/输入框.png"
dest_files=["res://.godot/imported/输入框.png-74076fbd98c6a5dee8485c2e25f4d583.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cchjgp6qh7u61"
path="res://.godot/imported/缩略框背景.png-1ffcbaafc0bc1c1b17eae2ad5370d0bc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/chat/缩略框背景.png"
dest_files=["res://.godot/imported/缩略框背景.png-1ffcbaafc0bc1c1b17eae2ad5370d0bc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ct0cl4h2i6ydn"
path="res://.godot/imported/装饰.png-ee12c19262540c6b6c0fc47732c88ff9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/chat/装饰.png"
dest_files=["res://.godot/imported/装饰.png-ee12c19262540c6b6c0fc47732c88ff9.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://q0ijn5y0tbw3"
path="res://.godot/imported/装饰2.png-d3c5ac2817d592f843ba8179eb83dd5b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/chat/装饰2.png"
dest_files=["res://.godot/imported/装饰2.png-d3c5ac2817d592f843ba8179eb83dd5b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://clmgyxpeh5742"
path="res://.godot/imported/输入框背景.png-0abeb0259c1b08c235f188bb6bf412c1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/chat/输入框背景.png"
dest_files=["res://.godot/imported/输入框背景.png-0abeb0259c1b08c235f188bb6bf412c1.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -159,16 +159,3 @@ func _move(_delta: float) -> void:
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down") var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = dir * move_speed velocity = dir * move_speed
move_and_slide() move_and_slide()
```
## 10. 🔄 Plan Mode Protocol (MANDATORY)
- **Planning Phase**:
- Whenever using **Plan Mode** to outline a task, a TODO list MUST be outputted to `docs/ai_docs/plan/[feature_name].md`.
- **Execution & Reporting**:
- Every time a TODO item is completed, the corresponding `.md` document MUST be updated.
- After updating the document, report to the user with the following:
1. **Completed Items**: What was just finished.
2. **User Acceptance Rules**: Instructions on how the user can test/verify the current progress.
3. **Next Step**: The next TODO item to be tackled.
- **Strict Confirmation**:
- After reporting progress, **Claude MUST stop and wait**.
- Do NOT proceed to the next TODO until the user has replied with confirmation/approval.

View File

@@ -22,8 +22,7 @@ SceneManager="*res://_Core/managers/SceneManager.gd"
EventSystem="*res://_Core/systems/EventSystem.gd" EventSystem="*res://_Core/systems/EventSystem.gd"
NetworkManager="*res://_Core/managers/NetworkManager.gd" NetworkManager="*res://_Core/managers/NetworkManager.gd"
ResponseHandler="*res://_Core/managers/ResponseHandler.gd" ResponseHandler="*res://_Core/managers/ResponseHandler.gd"
ChatManager="*res://_Core/managers/ChatManager.gd" WebSocketManager="*res://_Core/managers/WebSocketManager.gd"
LocationManager="*res://_Core/managers/LocationManager.gd"
[debug] [debug]

View File

@@ -14,7 +14,6 @@ extends Control
# 场景节点引用 # 场景节点引用
@onready var auth_scene: Control = $AuthScene @onready var auth_scene: Control = $AuthScene
@onready var main_game_ui: Control = $MainGameUI @onready var main_game_ui: Control = $MainGameUI
@onready var chat_ui: Control = %ChatUI
@onready var user_label: Label = $MainGameUI/TopBar/HBoxContainer/UserLabel @onready var user_label: Label = $MainGameUI/TopBar/HBoxContainer/UserLabel
@onready var logout_button: Button = $MainGameUI/TopBar/HBoxContainer/LogoutButton @onready var logout_button: Button = $MainGameUI/TopBar/HBoxContainer/LogoutButton
@@ -51,6 +50,9 @@ func _ready():
# 初始化游戏状态 # 初始化游戏状态
setup_game() setup_game()
# [TEST] 临时绕过登录
# call_deferred("_on_login_success", "LocalTester")
# 连接登录成功信号 # 连接登录成功信号
auth_scene.login_success.connect(_on_login_success) auth_scene.login_success.connect(_on_login_success)
@@ -75,7 +77,36 @@ func show_main_game():
auth_scene.visible = false auth_scene.visible = false
main_game_ui.visible = true main_game_ui.visible = true
user_label.text = "当前用户: " + current_user user_label.text = "当前用户: " + current_user
update_player_status() # update_player_status()
# print("进入主游戏界面")
# [TEST] 进入测试环境
_setup_test_environment()
func _setup_test_environment():
print("正在初始化测试环境: 广场 + 玩家")
# 1. 隐藏UI
current_state = GameState.MAIN_GAME
auth_scene.visible = false
main_game_ui.visible = false
# 2. 加载地图
var map_res = load("res://Scenes/Maps/square.tscn")
if map_res:
var map_instance = map_res.instantiate()
add_child(map_instance)
# 3. 加载玩家 - 交由 BaseLevel 或场景脚本动态处理
# var player_res = load("res://Scenes/characters/player.tscn")
# if player_res:
# var player_instance = player_res.instantiate()
# player_instance.position = Vector2(800, 600) # 设置初始位置
# map_instance.add_child(player_instance)
# else:
# print("错误: 无法加载玩家场景")
else:
print("错误: 无法加载广场地图")
func update_player_status(): func update_player_status():
level_label.text = "等级: " + str(player_level) level_label.text = "等级: " + str(player_level)
@@ -83,69 +114,42 @@ func update_player_status():
exp_label.text = "经验: " + str(player_exp) + "/" + str(player_max_exp) exp_label.text = "经验: " + str(player_exp) + "/" + str(player_max_exp)
energy_label.text = "体力: " + str(player_energy) + "/" + str(player_max_energy) energy_label.text = "体力: " + str(player_energy) + "/" + str(player_max_energy)
func _on_login_success(username: String): func _on_login_success(username: String, token: String):
# 登录成功后的处理 # 登录成功后的处理
current_user = username current_user = username
print("用户 ", username, " 登录成功!")
# 连接到聊天服务器(在进入游戏界面之前) # 连接到游戏服务器
# 注意token 已在 AuthScene._on_controller_login_success 中设置 WebSocketManager.set_auth_token(token)
ChatManager.connect_to_chat_server() WebSocketManager.connect_to_server()
# 连接到位置同步服务器 show_main_game()
LocationManager.connect_to_server()
# 直接进入游戏地图不显示MainGameUI
_setup_game_environment()
func _on_logout_pressed(): func _on_logout_pressed():
# 登出处理 # 登出处理
current_user = "" current_user = ""
# 断开聊天服务器连接
ChatManager.disconnect_from_chat_server()
show_auth_scene() show_auth_scene()
# 游戏功能按钮处理 # 游戏功能按钮处理
func _on_explore_pressed(): func _on_explore_pressed():
print("探索小镇功能")
show_game_message("🗺️ 探索功能开发中...") show_game_message("🗺️ 探索功能开发中...")
func _on_inventory_pressed(): func _on_inventory_pressed():
print("背包功能")
show_game_message("🎒 背包功能开发中...") show_game_message("🎒 背包功能开发中...")
func _on_shop_pressed(): func _on_shop_pressed():
print("商店功能")
show_game_message("🏪 商店功能开发中...") show_game_message("🏪 商店功能开发中...")
func _on_friends_pressed(): func _on_friends_pressed():
print("好友功能")
show_game_message("👥 好友功能开发中...") show_game_message("👥 好友功能开发中...")
func show_game_message(_message: String): func show_game_message(message: String):
# TODO: 用 Toast 或游戏内提示框替代占位实现 print("游戏消息: ", message)
pass # 这里可以添加UI提示框显示消息
# 设置游戏环境(登录后直接加载地图)
func _setup_game_environment():
# 防止登录切场景时输入状态残留导致角色“卡移动键”
_release_movement_actions()
# 1. 隐藏UI
current_state = GameState.MAIN_GAME
auth_scene.visible = false
main_game_ui.visible = false
# 2. 隐藏聊天框需要按Enter才显示
if is_instance_valid(chat_ui) and chat_ui.has_method("hide_chat"):
chat_ui.hide_chat()
# 3. 使用 SceneManager 切换到广场地图
SceneManager.change_scene("square", false) # false = 不使用过渡效果
func _release_movement_actions() -> void:
Input.action_release("move_left")
Input.action_release("move_right")
Input.action_release("move_up")
Input.action_release("move_down")
Input.flush_buffered_events()
# 处理全局输入 # 处理全局输入
func _input(event): func _input(event):
@@ -156,4 +160,5 @@ func _input(event):
get_tree().quit() get_tree().quit()
GameState.MAIN_GAME: GameState.MAIN_GAME:
# 在游戏中按ESC可能显示菜单或返回登录 # 在游戏中按ESC可能显示菜单或返回登录
show_auth_scene() # show_auth_scene()
pass

View File

@@ -1,16 +1,13 @@
[gd_scene load_steps=4 format=3 uid="uid://cjabtnqbdd2ey"] [gd_scene load_steps=3 format=3 uid="uid://cjabtnqbdd2ey"]
[ext_resource type="Script" uid="uid://ghehm4srs0ho" path="res://scenes/MainScene.gd" id="1_script"] [ext_resource type="Script" path="res://scenes/MainScene.gd" id="1_script"]
[ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/AuthScene.tscn" id="2_main"] [ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/AuthScene.tscn" id="2_main"]
[ext_resource type="PackedScene" uid="uid://bv7k2nan4xj8q" path="res://scenes/ui/ChatUI.tscn" id="3_chat_ui"]
[node name="Main" type="Control"] [node name="Main" type="Control"]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_script") script = ExtResource("1_script")
[node name="AuthScene" parent="." instance=ExtResource("2_main")] [node name="AuthScene" parent="." instance=ExtResource("2_main")]
@@ -22,15 +19,12 @@ layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="TopBar" type="Panel" parent="MainGameUI"] [node name="TopBar" type="Panel" parent="MainGameUI"]
layout_mode = 1 layout_mode = 1
anchors_preset = 10 anchors_preset = 10
anchor_right = 1.0 anchor_right = 1.0
offset_bottom = 60.0 offset_bottom = 60.0
grow_horizontal = 2
[node name="HBoxContainer" type="HBoxContainer" parent="MainGameUI/TopBar"] [node name="HBoxContainer" type="HBoxContainer" parent="MainGameUI/TopBar"]
layout_mode = 1 layout_mode = 1
@@ -41,8 +35,6 @@ offset_left = 20.0
offset_top = 10.0 offset_top = 10.0
offset_right = -20.0 offset_right = -20.0
offset_bottom = -10.0 offset_bottom = -10.0
grow_horizontal = 2
grow_vertical = 2
[node name="UserLabel" type="Label" parent="MainGameUI/TopBar/HBoxContainer"] [node name="UserLabel" type="Label" parent="MainGameUI/TopBar/HBoxContainer"]
layout_mode = 2 layout_mode = 2
@@ -60,23 +52,19 @@ anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_top = 60.0 offset_top = 60.0
grow_horizontal = 2
grow_vertical = 2
[node name="CenterContainer" type="CenterContainer" parent="MainGameUI/MainContent"] [node name="CenterContainer" type="CenterContainer" parent="MainGameUI/MainContent"]
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="MainGameUI/MainContent/CenterContainer"] [node name="VBoxContainer" type="VBoxContainer" parent="MainGameUI/MainContent/CenterContainer"]
layout_mode = 2 layout_mode = 2
[node name="StatusPanel" type="Panel" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer"] [node name="StatusPanel" type="Panel" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(400, 150)
layout_mode = 2 layout_mode = 2
custom_minimum_size = Vector2(400, 150)
[node name="StatusContainer" type="MarginContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel"] [node name="StatusContainer" type="MarginContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel"]
layout_mode = 1 layout_mode = 1
@@ -87,8 +75,6 @@ offset_left = 20.0
offset_top = 20.0 offset_top = 20.0
offset_right = -20.0 offset_right = -20.0
offset_bottom = -20.0 offset_bottom = -20.0
grow_horizontal = 2
grow_vertical = 2
[node name="StatusGrid" type="GridContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel/StatusContainer"] [node name="StatusGrid" type="GridContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel/StatusContainer"]
layout_mode = 2 layout_mode = 2
@@ -115,24 +101,21 @@ layout_mode = 2
columns = 2 columns = 2
[node name="ExploreButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"] [node name="ExploreButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
custom_minimum_size = Vector2(150, 50)
layout_mode = 2 layout_mode = 2
custom_minimum_size = Vector2(150, 50)
text = "🗺️ 探索小镇" text = "🗺️ 探索小镇"
[node name="InventoryButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"] [node name="InventoryButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
custom_minimum_size = Vector2(150, 50)
layout_mode = 2 layout_mode = 2
custom_minimum_size = Vector2(150, 50)
text = "🎒 背包" text = "🎒 背包"
[node name="ShopButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"] [node name="ShopButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
custom_minimum_size = Vector2(150, 50)
layout_mode = 2 layout_mode = 2
custom_minimum_size = Vector2(150, 50)
text = "🏪 商店" text = "🏪 商店"
[node name="FriendsButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"] [node name="FriendsButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
custom_minimum_size = Vector2(150, 50)
layout_mode = 2 layout_mode = 2
custom_minimum_size = Vector2(150, 50)
text = "👥 好友" text = "👥 好友"
[node name="ChatUI" parent="MainGameUI" instance=ExtResource("3_chat_ui")]
layout_mode = 1

View File

@@ -3,14 +3,11 @@ extends Node2D
# 基础关卡脚本 # 基础关卡脚本
# 负责处理通用的关卡逻辑,如玩家生成 # 负责处理通用的关卡逻辑,如玩家生成
const CHAT_UI_SCENE: PackedScene = preload("res://scenes/ui/ChatUI.tscn")
const CHAT_UI_NODE_NAME: String = "ChatUI" @onready var spawn_points = $SpawnPoints if has_node("SpawnPoints") else null
const UI_LAYER_NODE_NAME: String = "UILayer" @onready var players_container = $Objects/Players if has_node("Objects/Players") else self
const UI_LAYER_ORDER: int = 10
func _ready(): func _ready():
_ensure_chat_ui()
# 延时一帧确保所有子节点就绪 # 延时一帧确保所有子节点就绪
call_deferred("_spawn_player") call_deferred("_spawn_player")
@@ -18,42 +15,46 @@ func _ready():
# 获取当前场景名字作为 Session ID # 获取当前场景名字作为 Session ID
_current_session_id = SceneManager.get_current_scene_name() _current_session_id = SceneManager.get_current_scene_name()
if _current_session_id == "": if _current_session_id == "":
_current_session_id = "square" _current_session_id = "square" # Fallback for direct run
# 如果是私人场景,生成唯一的 Session ID (e.g., room_123) # 如果是私人场景,生成唯一的 Session ID (e.g., room_123)
if _current_session_id in PRIVATE_SCENES: if _current_session_id in PRIVATE_SCENES:
_current_session_id = _current_session_id + "_" + str(AuthManager.current_user_id) _current_session_id = _current_session_id + "_" + str(AuthManager.current_user_id)
print("BaseLevel: 进入私密房间实例: ", _current_session_id)
print("BaseLevel: Preparing to join session: ", _current_session_id)
# 如果 WebSocket 已连接,直接加入 # 如果 WebSocket 已连接,直接加入
if LocationManager._socket.get_ready_state() == WebSocketPeer.STATE_OPEN: if WebSocketManager._socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
_join_session_with_player(_current_session_id) _join_session_with_player(_current_session_id)
else: else:
# 否则等待连接成功信号 # 否则等待连接成功信号
LocationManager.connected_to_server.connect(func(): _join_session_with_player(_current_session_id)) WebSocketManager.connected_to_server.connect(func(): _join_session_with_player(_current_session_id))
# 连接远程玩家相关信号 # 连接远程玩家相关信号
LocationManager.session_joined.connect(_on_session_joined) WebSocketManager.session_joined.connect(_on_session_joined)
LocationManager.user_joined.connect(_on_user_joined) WebSocketManager.user_joined.connect(_on_user_joined)
LocationManager.user_left.connect(_on_user_left) WebSocketManager.user_left.connect(_on_user_left)
LocationManager.position_updated.connect(_on_position_updated) WebSocketManager.position_updated.connect(_on_position_updated)
func _ensure_chat_ui() -> void: # Debug: Simulate fake player to verify rendering
var ui_layer := get_node_or_null(UI_LAYER_NODE_NAME) as CanvasLayer # get_tree().create_timer(2.0).timeout.connect(_debug_fake_activity)
if ui_layer == null:
ui_layer = CanvasLayer.new()
ui_layer.name = UI_LAYER_NODE_NAME
ui_layer.layer = UI_LAYER_ORDER
add_child(ui_layer)
var chat_ui := ui_layer.get_node_or_null(CHAT_UI_NODE_NAME) func _exit_tree():
if chat_ui == null: # 场景卸载时不需要再做操作,交给 DoorTeleport 或其他切换逻辑显示处理
chat_ui = CHAT_UI_SCENE.instantiate() pass
chat_ui.name = CHAT_UI_NODE_NAME
ui_layer.add_child(chat_ui)
if chat_ui.has_method("hide_chat"): func _debug_fake_activity():
chat_ui.call_deferred("hide_chat") print("BaseLevel: Starting fake player simulation")
var fake_id = "fake_ghost"
_on_user_joined({"user": {"userId": fake_id, "username": "Ghost"}, "position": {"x": 500, "y": 500}})
for i in range(20):
await get_tree().create_timer(0.5).timeout
var new_pos = Vector2(500 + i * 20, 500 + i * 10)
_on_position_updated({"userId": fake_id, "position": {"x": new_pos.x, "y": new_pos.y}})
print("BaseLevel: Ghost moved to ", new_pos)
var remote_players: Dictionary = {} # userId -> RemotePlayer instance var remote_players: Dictionary = {} # userId -> RemotePlayer instance
var remote_player_scene = preload("res://scenes/characters/remote_player.tscn") var remote_player_scene = preload("res://scenes/characters/remote_player.tscn")
@@ -75,6 +76,8 @@ func _on_session_joined(data: Dictionary):
if current_map == "": if current_map == "":
current_map = "square" current_map = "square"
print("BaseLevel: 同步会话 - 当前场景: ", current_map, ", 收到用户数: ", data.users.size())
# 1. 收集服务器返回的、且 mapId 匹配当前场景的用户ID # 1. 收集服务器返回的、且 mapId 匹配当前场景的用户ID
var valid_user_ids: Array = [] var valid_user_ids: Array = []
var valid_users: Dictionary = {} # userId -> user data var valid_users: Dictionary = {} # userId -> user data
@@ -88,16 +91,26 @@ func _on_session_joined(data: Dictionary):
if user.has("position") and user.position != null: if user.has("position") and user.position != null:
if typeof(user.position) == TYPE_DICTIONARY and user.position.has("mapId"): if typeof(user.position) == TYPE_DICTIONARY and user.position.has("mapId"):
user_map_id = str(user.position.mapId) user_map_id = str(user.position.mapId)
print("BaseLevel: 用户 ", user.userId, " 的位置数据: ", user.position)
else:
print("BaseLevel: 用户 ", user.userId, " 没有位置数据")
print("BaseLevel: 用户 ", user.userId, " mapId=", user_map_id, ", 当前场景=", current_map)
if user_map_id == current_map: if user_map_id == current_map:
var uid = str(user.userId) var uid = str(user.userId)
valid_user_ids.append(uid) valid_user_ids.append(uid)
valid_users[uid] = user valid_users[uid] = user
print("BaseLevel: 添加有效用户: ", uid)
else:
# mapId 不匹配,这是"幽灵玩家"
print("BaseLevel: 跳过 mapId 不匹配的玩家: ", user.get("userId"), " (mapId=", user_map_id, ", expected=", current_map, ")")
# 2. 清理幽灵:移除本地有但不在有效列表中的玩家 # 2. 清理幽灵:移除本地有但不在有效列表中的玩家
var local_user_ids = remote_players.keys() var local_user_ids = remote_players.keys()
for user_id in local_user_ids: for user_id in local_user_ids:
if str(user_id) not in valid_user_ids: if str(user_id) not in valid_user_ids:
print("BaseLevel: 清理幽灵玩家: ", user_id)
_remove_remote_player(user_id) _remove_remote_player(user_id)
# 3. 添加或更新有效的玩家 # 3. 添加或更新有效的玩家
@@ -107,11 +120,14 @@ func _on_session_joined(data: Dictionary):
# 已存在,更新位置 # 已存在,更新位置
_update_remote_player_position(user) _update_remote_player_position(user)
else: else:
# 不存在,创建新玩家
print("BaseLevel: 创建远程玩家: ", uid)
_add_remote_player(user) _add_remote_player(user)
func _on_user_joined(data: Dictionary): func _on_user_joined(data: Dictionary):
var user = data.get("user", {}) var user = data.get("user", {})
if user.has("userId"): if user.has("userId"):
print("BaseLevel: 新玩家加入: ", user.userId)
if user.userId == AuthManager.current_user_id: if user.userId == AuthManager.current_user_id:
return return
@@ -124,6 +140,7 @@ func _on_user_joined(data: Dictionary):
func _on_user_left(data: Dictionary): func _on_user_left(data: Dictionary):
var user_id = data.get("userId") var user_id = data.get("userId")
if user_id: if user_id:
print("BaseLevel: 玩家离开: ", user_id)
_remove_remote_player(user_id) _remove_remote_player(user_id)
func _on_position_updated(data: Dictionary): func _on_position_updated(data: Dictionary):
@@ -135,7 +152,9 @@ func _on_position_updated(data: Dictionary):
# 根据后端协议: { userId:..., position: {x,y...}, ... } # 根据后端协议: { userId:..., position: {x,y...}, ... }
var pos_data = data.get("position", {}) var pos_data = data.get("position", {})
if pos_data.is_empty(): if pos_data.is_empty():
pos_data = data pos_data = data # 兼容扁平格式
# print("BaseLevel: 收到位置更新: ", user_id, " Data: ", pos_data) # Debug log
# 检查 mapId 是否匹配当前场景 # 检查 mapId 是否匹配当前场景
if pos_data.has("mapId") and str(pos_data.mapId) != "": if pos_data.has("mapId") and str(pos_data.mapId) != "":
@@ -144,6 +163,7 @@ func _on_position_updated(data: Dictionary):
current_map = "square" current_map = "square"
if str(pos_data.mapId) != current_map: if str(pos_data.mapId) != current_map:
print("BaseLevel: 收到异地位置更新,移除幽灵玩家: ", user_id, " (在该玩家在 ", pos_data.mapId, ")")
_remove_remote_player(user_id) _remove_remote_player(user_id)
return return
@@ -165,7 +185,7 @@ func _add_remote_player(user_data: Dictionary):
if remote_player.has_method("setup"): if remote_player.has_method("setup"):
remote_player.setup(user_data) remote_player.setup(user_data)
else: else:
# 回退到手动设置位置 # Fallback: 手动设置属性 (如果脚本没更新)
remote_player.position = Vector2.ZERO remote_player.position = Vector2.ZERO
if user_data.has("position"): if user_data.has("position"):
var p = user_data.position var p = user_data.position
@@ -179,6 +199,7 @@ func _add_remote_player(user_data: Dictionary):
add_child(remote_player) add_child(remote_player)
remote_players[user_id] = remote_player remote_players[user_id] = remote_player
print("BaseLevel: 已创建远程玩家: ", user_id)
func _remove_remote_player(user_id): func _remove_remote_player(user_id):
var uid = str(user_id) var uid = str(user_id)
@@ -200,7 +221,8 @@ func _update_remote_player_position(user: Dictionary):
func _join_session_with_player(session_id: String): func _join_session_with_player(session_id: String):
# 检查是否有Token如果没有则等待 # 检查是否有Token如果没有则等待
if LocationManager._auth_token == "": if WebSocketManager._auth_token == "":
print("BaseLevel: Token not ready, waiting...")
# 轮询等待Token就绪 (简单重试机制) # 轮询等待Token就绪 (简单重试机制)
await get_tree().create_timer(0.5).timeout await get_tree().create_timer(0.5).timeout
_join_session_with_player(session_id) _join_session_with_player(session_id)
@@ -214,18 +236,19 @@ func _join_session_with_player(session_id: String):
var pos = _local_player.global_position if is_instance_valid(_local_player) else Vector2.ZERO var pos = _local_player.global_position if is_instance_valid(_local_player) else Vector2.ZERO
LocationManager.join_session(session_id, pos) WebSocketManager.join_session(session_id, pos)
# 进入会话后立即同步一次位置 # 强制广播一次位置更新,确保旧房间的玩家立即收到 "已切换地图" 的通知
# 这能解决"需要移动两步幽灵才消失"的问题
await get_tree().create_timer(0.1).timeout await get_tree().create_timer(0.1).timeout
LocationManager.send_position_update(session_id, pos) WebSocketManager.send_position_update(session_id, pos)
func _process(delta): func _process(delta):
# 发送位置更新 (节流机制) # 发送位置更新 (节流机制)
if not _player_spawned or not _local_player: if not _player_spawned or not _local_player:
return # Wait for player to be spawned return # Wait for player to be spawned
if LocationManager._socket.get_ready_state() != WebSocketPeer.STATE_OPEN: if WebSocketManager._socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
return # WebSocket not connected return # WebSocket not connected
if not is_instance_valid(_local_player): if not is_instance_valid(_local_player):
@@ -240,14 +263,19 @@ func _process(delta):
_position_update_timer += delta _position_update_timer += delta
if _position_update_timer >= POSITION_UPDATE_INTERVAL: if _position_update_timer >= POSITION_UPDATE_INTERVAL:
_position_update_timer = 0.0 _position_update_timer = 0.0
# 使用 _current_session_id 确保有正确的 fallback
var map_id = _current_session_id if _current_session_id != "" else "square" var map_id = _current_session_id if _current_session_id != "" else "square"
LocationManager.send_position_update(map_id, _local_player.global_position) WebSocketManager.send_position_update(map_id, _local_player.global_position)
func _spawn_player(): func _spawn_player():
# 1. 确定出生位置 # 1. 确定出生位置
var spawn_pos = Vector2.ZERO var spawn_pos = Vector2.ZERO
var spawn_name = SceneManager.get_next_spawn_name() var spawn_name = SceneManager.get_next_spawn_name()
print("BaseLevel: Checking spawn point for name: '", spawn_name, "'")
# 查找逻辑:优先查找名为 spawn_name 的节点,其次找 DefaultSpawn # 查找逻辑:优先查找名为 spawn_name 的节点,其次找 DefaultSpawn
var target_node_name = spawn_name if spawn_name != "" else "DefaultSpawn" var target_node_name = spawn_name if spawn_name != "" else "DefaultSpawn"
var marker_node = null var marker_node = null
@@ -263,13 +291,15 @@ func _spawn_player():
# 如果找到了标记点,使用其位置 # 如果找到了标记点,使用其位置
if marker_node: if marker_node:
spawn_pos = marker_node.global_position spawn_pos = marker_node.global_position
print("BaseLevel: Found spawn marker '", target_node_name, "' at ", spawn_pos)
else: else:
# 策略 C: 检查 SceneManager 是否有备用坐标 # 策略 C: 检查 SceneManager 是否有备用坐标 (兼容旧逻辑)
var pos_param = SceneManager.get_next_scene_position() var pos_param = SceneManager.get_next_scene_position()
if pos_param != null: if pos_param != null:
spawn_pos = pos_param spawn_pos = pos_param
print("BaseLevel: Using explicit position from SceneManager: ", spawn_pos)
else: else:
push_warning("BaseLevel: 出生点 '%s' 不存在,使用默认坐标 (0, 0)" % target_node_name) print("BaseLevel: Warning - Could not find marker '", target_node_name, "' and no explicit position set. Using (0,0)")
# 2. 实例化玩家 # 2. 实例化玩家
var player_scene = preload("res://scenes/characters/player.tscn") var player_scene = preload("res://scenes/characters/player.tscn")
@@ -283,5 +313,6 @@ func _spawn_player():
else: else:
add_child(player) add_child(player)
_local_player = player print("BaseLevel: Player spawned at ", player.global_position)
_local_player = player # Save reference
_player_spawned = true _player_spawned = true

View File

@@ -1 +1 @@
uid://b43tvo8cykfrq uid://c5ml4722ptwp2

View File

@@ -2,8 +2,8 @@ extends Area2D
# 场景名称 # 场景名称
@export var target_scene_name: String = "" @export var target_scene_name: String = ""
@export var target_position: Vector2 = Vector2.ZERO # 目标场景的生成位置 (Vector2.ZERO 表示不设置) @export var target_position: Vector2 = Vector2.ZERO # 兼容旧逻辑
@export var target_spawn_name: String = "" # 目标场景的 Marker2D 名称 (例如 "FromSquare") @export var target_spawn_name: String = "" # 新逻辑:指定目标场景的 Marker2D 名称 (例如 "FromSquare") # 目标场景的生成位置 (Vector2.ZERO 表示使用默认位置/不设置)
# Called when the node enters the scene tree for the first time. # Called when the node enters the scene tree for the first time.
func _ready() -> void: func _ready() -> void:
@@ -11,15 +11,25 @@ func _ready() -> void:
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void: func _on_body_entered(body: Node2D) -> void:
# 检查进入对象是否为玩家 # 检查进入的物体是否为玩家
# 我们可以通过多种方式检查:
# 1. 检查是否在 "player" 组中 (推荐,但需要设置)
# 2. 检查是否有特定方法 (如 _handle_movement)
# 3. 检查类名 (如果 scripts/characters/player.gd 有 class_name)
# 这里使用方法检查作为一种鲁棒的方式,或者检查是否为 CharacterBody2D 且父节点层级符合预期
# 最简单直接的方式是检查是否有 _handle_movement 方法,这是 player.gd 特有的
if body.has_method("_handle_movement"): if body.has_method("_handle_movement"):
print("玩家进入传送门,正在切换到场景: ", target_scene_name)
_teleport_player() _teleport_player()
func _teleport_player() -> void: func _teleport_player() -> void:
if target_scene_name == "": if target_scene_name == "":
push_error("DoorTeleport: target_scene_name 为空") print("Error: Target scene name is empty!")
return return
print("Teleporting to scene: ", target_scene_name)
# 设置参数 # 设置参数
if target_spawn_name != "": if target_spawn_name != "":
SceneManager.set_next_spawn_name(target_spawn_name) SceneManager.set_next_spawn_name(target_spawn_name)

View File

@@ -1 +1 @@
uid://rlkavptfhr4y uid://b068cnbw3a8wt

View File

@@ -11,4 +11,4 @@ texture = ExtResource("1_jrtph")
[node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="."] [node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="."]
position = Vector2(152, 16) position = Vector2(152, 16)
polygon = PackedVector2Array(-456, -48, -192, -48, -192, -72, -152, -72, -120, -72, -120, -56, 168, -48, 168, -696, -472, -688, -472, -48) polygon = PackedVector2Array(-456, -48, -192, -48, -192, -112, -120, -112, -120, -56, 168, -48, 168, -696, -472, -688, -472, -48)

View File

@@ -0,0 +1 @@
uid://3wghcufucve5

View File

@@ -1,6 +1,6 @@
[gd_scene load_steps=40 format=4 uid="uid://5cc0c6cpnhe8"] [gd_scene load_steps=39 format=4 uid="uid://5cc0c6cpnhe8"]
[ext_resource type="Script" uid="uid://b43tvo8cykfrq" path="res://scenes/Maps/BaseLevel.gd" id="1_m4als"] [ext_resource type="Script" uid="uid://c5ml4722ptwp2" path="res://scenes/Maps/BaseLevel.gd" id="1_m4als"]
[ext_resource type="Texture2D" uid="uid://baa5wkuyqouh6" path="res://assets/sprites/environment/standard_brick_128_128.jpg" id="1_rb5kq"] [ext_resource type="Texture2D" uid="uid://baa5wkuyqouh6" path="res://assets/sprites/environment/standard_brick_128_128.jpg" id="1_rb5kq"]
[ext_resource type="Texture2D" uid="uid://dwlnclqw6lsa7" path="res://assets/sprites/environment/grass_256_256.png" id="2_dly5q"] [ext_resource type="Texture2D" uid="uid://dwlnclqw6lsa7" path="res://assets/sprites/environment/grass_256_256.png" id="2_dly5q"]
[ext_resource type="Texture2D" uid="uid://ccqxsarxnnf4e" path="res://assets/sprites/environment/ground.png" id="2_rb5kq"] [ext_resource type="Texture2D" uid="uid://ccqxsarxnnf4e" path="res://assets/sprites/environment/ground.png" id="2_rb5kq"]
@@ -18,12 +18,18 @@
[ext_resource type="PackedScene" uid="uid://c7k8yay002w4" path="res://scenes/prefabs/items/welcome_board.tscn" id="16_edt5w"] [ext_resource type="PackedScene" uid="uid://c7k8yay002w4" path="res://scenes/prefabs/items/welcome_board.tscn" id="16_edt5w"]
[ext_resource type="PackedScene" uid="uid://bvfyllcy5fi8o" path="res://scenes/Maps/datawhale_home.tscn" id="16_m4als"] [ext_resource type="PackedScene" uid="uid://bvfyllcy5fi8o" path="res://scenes/Maps/datawhale_home.tscn" id="16_m4als"]
[ext_resource type="PackedScene" uid="uid://rdmrm7j4iokr" path="res://scenes/prefabs/items/notice_board.tscn" id="16_rixdf"] [ext_resource type="PackedScene" uid="uid://rdmrm7j4iokr" path="res://scenes/prefabs/items/notice_board.tscn" id="16_rixdf"]
[ext_resource type="Script" uid="uid://d2od22agputjt" path="res://scenes/prefabs/items/WelcomeBoard.gd" id="16_u1t8b"] [ext_resource type="Script" uid="uid://b068cnbw3a8wt" path="res://scenes/Maps/DoorTeleport.gd" id="18_0xqio"]
[ext_resource type="Script" uid="uid://rlkavptfhr4y" path="res://scenes/Maps/DoorTeleport.gd" id="18_0xqio"]
[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_7nixu"] [sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_7nixu"]
texture = ExtResource("1_rb5kq") texture = ExtResource("1_rb5kq")
texture_region_size = Vector2i(32, 32) texture_region_size = Vector2i(32, 32)
2:7/0 = 0
0:7/0 = 0
1:7/0 = 0
3:7/0 = 0
3:6/0 = 0
3:5/0 = 0
3:4/0 = 0
3:3/0 = 0 3:3/0 = 0
3:2/0 = 0 3:2/0 = 0
3:1/0 = 0 3:1/0 = 0
@@ -34,12 +40,21 @@ texture_region_size = Vector2i(32, 32)
0:1/0 = 0 0:1/0 = 0
0:2/0 = 0 0:2/0 = 0
0:3/0 = 0 0:3/0 = 0
0:4/0 = 0
0:5/0 = 0
0:6/0 = 0
1:5/0 = 0
1:4/0 = 0
1:3/0 = 0 1:3/0 = 0
1:2/0 = 0 1:2/0 = 0
1:1/0 = 0 1:1/0 = 0
2:2/0 = 0 2:2/0 = 0
2:1/0 = 0 2:1/0 = 0
2:3/0 = 0 2:3/0 = 0
2:4/0 = 0
2:5/0 = 0
2:6/0 = 0
1:6/0 = 0
[sub_resource type="TileSet" id="TileSet_y0nn0"] [sub_resource type="TileSet" id="TileSet_y0nn0"]
tile_size = Vector2i(32, 32) tile_size = Vector2i(32, 32)
@@ -81,6 +96,7 @@ sources/0 = SubResource("TileSetAtlasSource_1xyas")
[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_tc2jc"] [sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_tc2jc"]
texture = ExtResource("2_rb5kq") texture = ExtResource("2_rb5kq")
texture_region_size = Vector2i(32, 32) texture_region_size = Vector2i(32, 32)
7:87/0 = 0
2:45/0 = 0 2:45/0 = 0
3:45/0 = 0 3:45/0 = 0
4:45/0 = 0 4:45/0 = 0
@@ -1079,11 +1095,14 @@ tile_set = SubResource("TileSet_0xqio")
z_index = -1 z_index = -1
position = Vector2(0, 320) position = Vector2(0, 320)
[node name="NPC" parent="." instance=ExtResource("15_0xqio")] [node name="NoticeBoard" parent="." instance=ExtResource("16_rixdf")]
position = Vector2(-81, -64) z_index = 1
position = Vector2(-184, -76)
[node name="DataWhaleHome" parent="." instance=ExtResource("16_m4als")] [node name="WelcomeBoard" parent="." instance=ExtResource("16_edt5w")]
position = Vector2(8, -128) z_index = 1
y_sort_enabled = true
position = Vector2(128, -80)
[node name="DefaultSpawn" type="Marker2D" parent="."] [node name="DefaultSpawn" type="Marker2D" parent="."]
y_sort_enabled = true y_sort_enabled = true
@@ -1093,8 +1112,14 @@ position = Vector2(647, 500)
y_sort_enabled = true y_sort_enabled = true
position = Vector2(648, 24) position = Vector2(648, 24)
[node name="NPC" parent="." instance=ExtResource("15_0xqio")]
position = Vector2(-81, -64)
[node name="DataWhaleHome" parent="." instance=ExtResource("16_m4als")]
position = Vector2(8, -128)
[node name="DoorArea" type="Area2D" parent="."] [node name="DoorArea" type="Area2D" parent="."]
position = Vector2(2, 14) position = Vector2(0, 5)
script = ExtResource("18_0xqio") script = ExtResource("18_0xqio")
target_scene_name = "room" target_scene_name = "room"
target_spawn_name = "FromSquare" target_spawn_name = "FromSquare"
@@ -1104,17 +1129,8 @@ position = Vector2(644, -62.5)
shape = SubResource("RectangleShape2D_edt5w") shape = SubResource("RectangleShape2D_edt5w")
[node name="Community" parent="." instance=ExtResource("16_0xqio")] [node name="Community" parent="." instance=ExtResource("16_0xqio")]
y_sort_enabled = true
position = Vector2(646, 3) position = Vector2(646, 3)
[node name="NoticeBoard" parent="." instance=ExtResource("16_rixdf")]
position = Vector2(-216, -72)
[node name="WelcomeBoard" parent="." instance=ExtResource("16_edt5w")]
y_sort_enabled = true
position = Vector2(128, -80)
script = ExtResource("16_u1t8b")
[node name="Boundaries" type="Node2D" parent="."] [node name="Boundaries" type="Node2D" parent="."]
[node name="StaticBody2D" type="StaticBody2D" parent="Boundaries"] [node name="StaticBody2D" type="StaticBody2D" parent="Boundaries"]

View File

@@ -14,8 +14,6 @@ const MOVE_SPEED = 200.0
var last_direction := "down" var last_direction := "down"
func _ready() -> void: func _ready() -> void:
_reset_movement_input_state()
# 检查是否有初始位置设置 # 检查是否有初始位置设置
call_deferred("_check_spawn_position") call_deferred("_check_spawn_position")
@@ -28,17 +26,6 @@ func _ready() -> void:
ray_cast.enabled = true ray_cast.enabled = true
ray_cast.target_position = Vector2(0, 60) ray_cast.target_position = Vector2(0, 60)
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_IN:
_reset_movement_input_state()
func _reset_movement_input_state() -> void:
Input.action_release("move_left")
Input.action_release("move_right")
Input.action_release("move_up")
Input.action_release("move_down")
Input.flush_buffered_events()
func _check_spawn_position() -> void: func _check_spawn_position() -> void:
var spawn_pos = SceneManager.get_next_scene_position() var spawn_pos = SceneManager.get_next_scene_position()
if spawn_pos != null: if spawn_pos != null:
@@ -56,13 +43,6 @@ func _handle_interaction() -> void:
collider.interact() collider.interact()
func _handle_movement(_delta: float) -> void: func _handle_movement(_delta: float) -> void:
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
if _is_text_input_focused():
velocity = Vector2.ZERO
_play_idle_animation()
move_and_slide()
return
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md) # 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
var direction := Input.get_vector( var direction := Input.get_vector(
"move_left", "move_right", "move_left", "move_right",
@@ -110,7 +90,3 @@ func _update_animation_state(direction: Vector2) -> void:
func _play_idle_animation() -> void: func _play_idle_animation() -> void:
if animation_player: if animation_player:
animation_player.play("idle_" + last_direction) animation_player.play("idle_" + last_direction)
func _is_text_input_focused() -> bool:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
return focus_owner is LineEdit or focus_owner is TextEdit

View File

@@ -55,10 +55,15 @@ func setup(data: Dictionary):
global_position = new_pos global_position = new_pos
target_position = new_pos target_position = new_pos
# 如果有名字显示需求,可在此扩展
# if data.has("username") and has_node("Label"):
# $Label.text = data.username
# 更新目标位置 # 更新目标位置
func update_position(new_pos: Vector2): func update_position(new_pos: Vector2):
target_position = new_pos target_position = new_pos
# 动画更新逻辑 (复用 PlayerController 的命名规范)
func _update_animation(move_vec: Vector2): func _update_animation(move_vec: Vector2):
if not animation_player: if not animation_player:
return return

View File

@@ -102,7 +102,7 @@ func _validate_texture():
return false return false
# ============================================================================ # ============================================================================
# 信息方法 # 调试方法
# ============================================================================ # ============================================================================
# 获取瓦片信息 # 获取瓦片信息
@@ -113,3 +113,10 @@ func get_tile_info() -> Dictionary:
"texture_size": texture.get_size() if texture else Vector2.ZERO, "texture_size": texture.get_size() if texture else Vector2.ZERO,
"auto_snap": auto_snap "auto_snap": auto_snap
} }
# 打印瓦片信息
func print_info():
var info = get_tile_info()
print("=== 草地瓦片信息 ===")
for key in info:
print(key, ": ", info[key])

View File

@@ -1,9 +1,11 @@
extends StaticBody2D extends StaticBody2D
func interact(): func interact():
print("Interacted with Notice Board")
# Check if dialog already exists # Check if dialog already exists
if get_tree().root.has_node("NoticeDialog"): if get_tree().root.has_node("NoticeDialog"):
return null return
var dialog = preload("res://scenes/ui/notice_dialog.tscn").instantiate() var dialog = preload("res://scenes/ui/notice_dialog.tscn").instantiate()
dialog.name = "NoticeDialog" dialog.name = "NoticeDialog"

View File

@@ -4,7 +4,7 @@
[ext_resource type="Texture2D" uid="uid://b4aildrnhbpl4" path="res://assets/materials/NoticeBoard.png" id="2_sprite"] [ext_resource type="Texture2D" uid="uid://b4aildrnhbpl4" path="res://assets/materials/NoticeBoard.png" id="2_sprite"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_nb"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_nb"]
size = Vector2(160, 40) size = Vector2(160, 53.333332)
[node name="NoticeBoard" type="StaticBody2D"] [node name="NoticeBoard" type="StaticBody2D"]
scale = Vector2(0.6, 0.6) scale = Vector2(0.6, 0.6)
@@ -16,5 +16,5 @@ scale = Vector2(0.5, 0.5)
texture = ExtResource("2_sprite") texture = ExtResource("2_sprite")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="CollisionShape2D" type="CollisionShape2D" parent="."]
position = Vector2(0, 6.6666665) position = Vector2(0, 13.333335)
shape = SubResource("RectangleShape2D_nb") shape = SubResource("RectangleShape2D_nb")

View File

@@ -1,17 +1,19 @@
[gd_scene load_steps=3 format=3 uid="uid://c7k8yay002w4"] [gd_scene load_steps=4 format=3 uid="uid://c7k8yay002w4"]
[ext_resource type="Script" uid="uid://d2od22agputjt" path="res://scenes/prefabs/items/WelcomeBoard.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_sprite"] [ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_sprite"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_board"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_board"]
size = Vector2(112, 18.5) size = Vector2(112, 26.5)
[node name="WelcomeBoard" type="StaticBody2D"] [node name="WelcomeBoard" type="StaticBody2D"]
collision_layer = 3 collision_layer = 3
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(0.25, 0.25) scale = Vector2(0.25, 0.25)
texture = ExtResource("2_sprite") texture = ExtResource("2_sprite")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="CollisionShape2D" type="CollisionShape2D" parent="."]
position = Vector2(0, 14.75) position = Vector2(0, 18.75)
shape = SubResource("RectangleShape2D_board") shape = SubResource("RectangleShape2D_board")

View File

@@ -1,211 +0,0 @@
extends PanelContainer
# ============================================================================
# ChatMessage.gd - 聊天消息气泡组件
# ============================================================================
# 显示单条聊天消息的 UI 组件
#
# 核心职责:
# - 显示消息发送者、内容、时间戳
# - 区分自己和他人的消息样式
# - 自动格式化时间戳
#
# 使用方式:
# var message := chat_message_scene.instantiate()
# message.set_message("PlayerName", "Hello!", timestamp, false)
#
# 注意事项:
# - 使用 @onready 缓存节点引用
# - 最大宽度限制为 400 像素
# ============================================================================
class_name ChatMessage
# ============================================================================
# 导出参数
# ============================================================================
# 最大宽度(像素)
@export var max_width: int = 400
# ============================================================================
# 节点引用
# ============================================================================
# 用户名标签
var username_label: Label
# 时间戳标签
var timestamp_label: Label
# 内容标签
var content_label: RichTextLabel
# 用户信息容器
var user_info_container: HBoxContainer
# ============================================================================
# 生命周期方法
# ============================================================================
func _ready() -> void:
_cache_node_refs()
func _cache_node_refs() -> void:
if not username_label:
username_label = get_node_or_null("VBoxContainer/UserInfoContainer/UsernameLabel")
if not timestamp_label:
timestamp_label = get_node_or_null("VBoxContainer/UserInfoContainer/TimestampLabel")
if not content_label:
content_label = get_node_or_null("VBoxContainer/ContentLabel")
if not user_info_container:
user_info_container = get_node_or_null("VBoxContainer/UserInfoContainer")
# 内容换行与自适应高度
if content_label:
content_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
content_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content_label.fit_content = true
content_label.scroll_active = false
# ============================================================================
# 成员变量
# ============================================================================
# 是否为自己发送的消息
var _is_self: bool = false
# ============================================================================
# 公共 API
# ============================================================================
# 设置消息内容
#
# 参数:
# from_user: String - 发送者用户名
# content: String - 消息内容
# timestamp: float - Unix 时间戳
# is_self: bool - 是否为自己发送的消息(默认 false
#
# 使用示例:
# message.set_message("Alice", "Hello!", 1703500800.0, false)
func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void:
_is_self = is_self
_cache_node_refs()
var safe_from_user := from_user
if safe_from_user.strip_edges().is_empty():
safe_from_user = "" if is_self else "玩家"
# 设置用户名(带空值检查)
if username_label:
username_label.text = safe_from_user
else:
push_error("ChatMessage: username_label is null!")
return
# 设置内容
if content_label:
content_label.clear() # 清除默认文本和所有内容
content_label.append_text(content) # 作为纯文本追加,避免 BBCode 解析导致内容不显示
else:
push_error("ChatMessage: content_label is null!")
return
# 设置时间戳
if timestamp_label:
timestamp_label.text = _format_timestamp(timestamp)
else:
push_error("ChatMessage: timestamp_label is null!")
return
# 应用样式
_apply_style()
# ============================================================================
# 内部方法 - 样式处理
# ============================================================================
# 应用样式(自己和他人的消息不同)
func _apply_style() -> void:
if not username_label or not timestamp_label or not user_info_container:
return
# 重要:设置垂直 size flags 让 Panel 适应内容高度
size_flags_vertical = Control.SIZE_SHRINK_BEGIN
if _is_self:
# 自己的消息:右侧对齐,蓝色背景
size_flags_horizontal = Control.SIZE_SHRINK_END
user_info_container.alignment = BoxContainer.ALIGNMENT_END
# 设置面板样式
add_theme_stylebox_override("panel", _get_self_style())
# 设置文字颜色 - ID使用金色 #FFD700
username_label.add_theme_color_override("font_color", Color(1.0, 0.8431373, 0.0))
timestamp_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
if content_label:
content_label.add_theme_color_override("default_color", Color(0.95, 0.97, 1.0))
else:
# 他人的消息:左侧对齐,灰色背景
size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN
# 设置面板样式
add_theme_stylebox_override("panel", _get_other_style())
# 设置文字颜色 - ID使用蓝色 #69c0ff
username_label.add_theme_color_override("font_color", Color(0.4117647, 0.7529412, 1.0))
timestamp_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
if content_label:
content_label.add_theme_color_override("default_color", Color(0.15, 0.15, 0.15))
# 获取自己消息的样式
func _get_self_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.6, 1.0, 0.8)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 2
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 8
style.content_margin_bottom = 8
return style
# 获取他人消息的样式
func _get_other_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.9, 0.9, 0.9, 0.8)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 2
style.corner_radius_bottom_right = 10
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 8
style.content_margin_bottom = 8
return style
# ============================================================================
# 内部方法 - 工具函数
# ============================================================================
# 格式化时间戳
#
# 参数:
# timestamp: float - Unix 时间戳
#
# 返回值:
# String - 格式化的时间字符串
func _format_timestamp(timestamp: float) -> String:
if timestamp == 0:
return ""
var datetime := Time.get_datetime_dict_from_unix_time(timestamp)
# 格式化为 HH:MM
return "%02d:%02d" % [datetime.hour, datetime.minute]

View File

@@ -1 +0,0 @@
uid://djqrgj3h0lif7

View File

@@ -1,36 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://dqx8k3n8yqjvu"]
[ext_resource type="Script" path="res://scenes/prefabs/ui/ChatMessage.gd" id="1"]
[node name="ChatMessage" type="PanelContainer"]
layout_mode = 2
offset_right = 400.0
size_flags_horizontal = 3
size_flags_vertical = 6
script = ExtResource("1")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 4
[node name="UserInfoContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="UsernameLabel" type="Label" parent="VBoxContainer/UserInfoContainer"]
layout_mode = 2
theme_override_colors/font_color = Color(0.2, 0.4, 0.8, 1)
theme_override_font_sizes/font_size = 14
text = "Username"
[node name="TimestampLabel" type="Label" parent="VBoxContainer/UserInfoContainer"]
layout_mode = 2
theme_override_colors/font_color = Color(0.5, 0.5, 0.5, 1)
theme_override_font_sizes/font_size = 12
text = "12:34"
[node name="ContentLabel" type="RichTextLabel" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
fit_content = true

View File

@@ -26,7 +26,7 @@ extends Control
# ============ 信号定义 ============ # ============ 信号定义 ============
# 登录成功信号 - 传递给上层场景 # 登录成功信号 - 传递给上层场景
signal login_success(username: String) signal login_success(username: String, token: String)
# ============ UI节点引用 ============ # ============ UI节点引用 ============
@@ -36,10 +36,10 @@ signal login_success(username: String)
@onready var register_panel: Panel = $CenterContainer/RegisterPanel # 注册面板 @onready var register_panel: Panel = $CenterContainer/RegisterPanel # 注册面板
@onready var title_label: Label = $CenterContainer/LoginPanel/VBoxContainer/TitleLabel # 标题标签 @onready var title_label: Label = $CenterContainer/LoginPanel/VBoxContainer/TitleLabel # 标题标签
@onready var subtitle_label: Label = $CenterContainer/LoginPanel/VBoxContainer/SubtitleLabel # 副标题标签 @onready var subtitle_label: Label = $CenterContainer/LoginPanel/VBoxContainer/SubtitleLabel # 副标题标签
@onready var whale_frame: TextureRect = $CenterContainer/WhaleFrame # 鲸鱼装饰框 @onready var whale_frame: TextureRect = $WhaleFrame # 鲸鱼装饰框
# 登录表单输入控件 # 登录表单输入控件
@onready var login_username: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameInput @onready var login_username: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameContainer/UsernameInput
@onready var login_password: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/PasswordContainer/PasswordInput @onready var login_password: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/PasswordContainer/PasswordInput
@onready var login_verification: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/VerificationContainer/VerificationInputContainer/VerificationInput @onready var login_verification: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/VerificationContainer/VerificationInputContainer/VerificationInput
@@ -103,6 +103,8 @@ func _ready():
_connect_signals() # 连接信号 _connect_signals() # 连接信号
_setup_ui() # 设置UI初始状态 _setup_ui() # 设置UI初始状态
print("认证场景视图已加载")
# 测试网络连接 # 测试网络连接
auth_manager.test_network_connection() auth_manager.test_network_connection()
@@ -326,9 +328,7 @@ func _on_login_enter(_text: String):
# ============ 控制器信号处理 ============ # ============ 控制器信号处理 ============
# 登录成功处理 # 登录成功处理
func _on_controller_login_success(username: String) -> void: func _on_controller_login_success(username: String, token: String):
_release_focus_owner()
# 清空表单 # 清空表单
login_username.text = "" login_username.text = ""
login_password.text = "" login_password.text = ""
@@ -337,21 +337,8 @@ func _on_controller_login_success(username: String) -> void:
_hide_field_error(login_password_error) _hide_field_error(login_password_error)
_hide_field_error(login_verification_error) _hide_field_error(login_verification_error)
# 设置 token 给 ChatManager用于 WebSocket 聊天认证)
var token: String = auth_manager.get_access_token()
if not token.is_empty():
ChatManager.set_game_token(token)
# 同时设置 LocationManager token用于位置同步
LocationManager.set_auth_token(token)
# 发送登录成功信号给上层 # 发送登录成功信号给上层
login_success.emit(username) login_success.emit(username, token)
func _release_focus_owner() -> void:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if is_instance_valid(focus_owner):
focus_owner.release_focus()
# 登录失败处理 # 登录失败处理
func _on_controller_login_failed(_message: String): func _on_controller_login_failed(_message: String):
@@ -406,9 +393,9 @@ func _on_controller_form_validation_failed(field: String, message: String):
register_confirm.grab_focus() register_confirm.grab_focus()
# 网络状态变化处理 # 网络状态变化处理
func _on_controller_network_status_changed(_network_connected: bool, _message: String): func _on_controller_network_status_changed(network_connected: bool, message: String):
# 可以在这里添加网络状态指示器 # 可以在这里添加网络状态指示器
pass print("网络状态: ", "连接" if network_connected else "断开", " - ", message)
# 按钮状态变化处理 # 按钮状态变化处理
func _on_controller_button_state_changed(button_name: String, is_loading: bool, text: String): func _on_controller_button_state_changed(button_name: String, is_loading: bool, text: String):

View File

@@ -1,14 +1,10 @@
[gd_scene load_steps=13 format=3 uid="uid://by7m8snb4xllf"] [gd_scene load_steps=10 format=3 uid="uid://by7m8snb4xllf"]
[ext_resource type="Texture2D" uid="uid://bx17oy8lvaca4" path="res://assets/ui/auth/bg_auth_scene.png" id="1_background"] [ext_resource type="Texture2D" uid="uid://bx17oy8lvaca4" path="res://assets/ui/auth/bg_auth_scene.png" id="1_background"]
[ext_resource type="Texture2D" uid="uid://de4q4s1gxivtf" path="res://assets/ui/auth/login_frame_smart_transparent.png" id="2_frame"]
[ext_resource type="Script" uid="uid://b514h2wuido0h" path="res://scenes/ui/AuthScene.gd" id="3_script"] [ext_resource type="Script" uid="uid://b514h2wuido0h" path="res://scenes/ui/AuthScene.gd" id="3_script"]
[ext_resource type="Texture2D" uid="uid://dyma4hpodhdxi" path="res://assets/ui/auth/登录背景.png" id="3_wh4n4"]
[ext_resource type="Texture2D" uid="uid://cnrffaqbtw8f5" path="res://assets/ui/auth/输入框.png" id="4_lnw07"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_26vyf"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1"]
[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_cjyup"]
texture = ExtResource("4_lnw07")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hover"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hover"]
bg_color = Color(0.3, 0.6, 0.9, 1) bg_color = Color(0.3, 0.6, 0.9, 1)
@@ -63,8 +59,6 @@ Button/styles/hover = SubResource("StyleBoxFlat_hover")
Button/styles/normal = SubResource("StyleBoxFlat_normal") Button/styles/normal = SubResource("StyleBoxFlat_normal")
Button/styles/pressed = SubResource("StyleBoxFlat_pressed") Button/styles/pressed = SubResource("StyleBoxFlat_pressed")
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1"]
[node name="AuthScene" type="Control"] [node name="AuthScene" type="Control"]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
@@ -87,14 +81,22 @@ texture = ExtResource("1_background")
expand_mode = 1 expand_mode = 1
stretch_mode = 6 stretch_mode = 6
[node name="ColorRect" type="ColorRect" parent="."] [node name="WhaleFrame" type="TextureRect" parent="."]
modulate = Color(0.84313726, 0.92941177, 0.98039216, 0.47058824)
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 8
anchor_right = 1.0 anchor_left = 0.5
anchor_bottom = 1.0 anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -300.0
offset_top = -300.0
offset_right = 300.0
offset_bottom = 300.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
texture = ExtResource("2_frame")
expand_mode = 1
stretch_mode = 5
[node name="CenterContainer" type="CenterContainer" parent="."] [node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1 layout_mode = 1
@@ -110,45 +112,35 @@ offset_bottom = 236.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
[node name="WhaleFrame" type="TextureRect" parent="CenterContainer"]
custom_minimum_size = Vector2(500, 0)
layout_mode = 2
texture = ExtResource("3_wh4n4")
expand_mode = 4
stretch_mode = 5
[node name="LoginPanel" type="Panel" parent="CenterContainer"] [node name="LoginPanel" type="Panel" parent="CenterContainer"]
custom_minimum_size = Vector2(300, 0) custom_minimum_size = Vector2(350, 400)
layout_mode = 2 layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxEmpty_26vyf") theme_override_styles/panel = SubResource("StyleBoxEmpty_1")
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel"] [node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel"]
layout_mode = 1 layout_mode = 1
anchors_preset = 8 anchors_preset = 15
anchor_left = 0.5 anchor_right = 1.0
anchor_top = 0.5 anchor_bottom = 1.0
anchor_right = 0.5 offset_left = 30.0
anchor_bottom = 0.5 offset_top = 30.0
offset_left = -140.0 offset_right = -30.0
offset_top = -185.5 offset_bottom = -30.0
offset_right = 140.0
offset_bottom = 185.5
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
[node name="TitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"] [node name="TitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0, 0, 0, 1) theme_override_colors/font_color = Color(0, 0, 0, 1)
theme_override_colors/font_shadow_color = Color(0.011764706, 0.12156863, 0.101960786, 0) theme_override_font_sizes/font_size = 24
theme_override_font_sizes/font_size = 32
text = "Whaletown" text = "Whaletown"
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1 vertical_alignment = 1
[node name="SubtitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"] [node name="SubtitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.53333336, 0.53333336, 0.53333336, 1) theme_override_colors/font_color = Color(0, 0, 0, 1)
theme_override_font_sizes/font_size = 16 theme_override_font_sizes/font_size = 14
text = "开始你的小镇之旅!" text = "开始你的小镇之旅!"
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1 vertical_alignment = 1
@@ -188,13 +180,10 @@ theme_override_font_sizes/font_size = 12
text = "用户名不能为空" text = "用户名不能为空"
horizontal_alignment = 2 horizontal_alignment = 2
[node name="UsernameInput" type="LineEdit" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm"] [node name="UsernameInput" type="LineEdit" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0, 0, 0, 1) theme_override_colors/font_color = Color(0, 0, 0, 1)
theme_override_colors/font_placeholder_color = Color(0.5, 0.5, 0.5, 1) theme_override_colors/font_placeholder_color = Color(0.5, 0.5, 0.5, 1)
theme_override_colors/selection_color = Color(0.5, 0.5, 0.5, 1)
theme_override_styles/normal = SubResource("StyleBoxTexture_cjyup")
placeholder_text = "用户名/手机/邮箱" placeholder_text = "用户名/手机/邮箱"
[node name="PasswordContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm"] [node name="PasswordContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm"]
@@ -563,8 +552,6 @@ theme = SubResource("Theme_button")
text = "返回登录" text = "返回登录"
[node name="ToastContainer" type="Control" parent="."] [node name="ToastContainer" type="Control" parent="."]
modulate = Color(0.84313726, 0.92941177, 0.98039216, 1)
self_modulate = Color(0.84313726, 0.92941177, 0.9843137, 1)
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0

View File

@@ -1,351 +0,0 @@
extends Control
# ============================================================================
# ChatUI.gd - 聊天界面控制器Enter 显示/隐藏版本)
# ============================================================================
# 聊天系统的用户界面控制器
#
# 核心职责:
# - 显示聊天消息历史
# - 处理用户输入
# - 显示连接状态
# - Enter 显示/隐藏 + 点击外部取消输入状态 + 5秒自动隐藏
# - 只有按 Enter 才会取消倒计时
# - Call Down: 通过 EventSystem 订阅聊天事件
#
# 使用方式:
# var chat_ui := preload("res://scenes/ui/ChatUI.tscn").instantiate()
# add_child(chat_ui)
#
# 注意事项:
# - 遵循 "Signal Up, Call Down" 架构
# - 使用 @onready 缓存节点引用
# - 所有 UI 操作通过 ChatManager
# ============================================================================
# ============================================================================
# 节点引用
# ============================================================================
# 聊天面板
@onready var chat_panel: Control = %ChatPanel
# 聊天历史容器
@onready var chat_history: ScrollContainer = %ChatHistory
# 消息列表
@onready var message_list: VBoxContainer = %MessageList
# 聊天输入框
@onready var chat_input: LineEdit = %ChatInput
# ============================================================================
# 预加载资源
# ============================================================================
# 聊天消息场景
@onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn")
# ============================================================================
# 成员变量
# ============================================================================
# 是否显示聊天框
var _is_chat_visible: bool = false
# 隐藏计时器
var _hide_timer: Timer = null
# 是否在输入中(输入时不隐藏)
var _is_typing: bool = false
# 当前用户名
var _current_username: String = ""
# ============================================================================
# 生命周期方法
# ============================================================================
# 准备就绪
func _ready() -> void:
# 初始隐藏聊天框
hide_chat()
# 创建隐藏计时器
_create_hide_timer()
# 订阅事件Call Down via EventSystem
_subscribe_to_events()
# 连接 UI 信号
_connect_ui_signals()
# 清理
func _exit_tree() -> void:
# 取消事件订阅
if EventSystem:
EventSystem.disconnect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
EventSystem.disconnect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
EventSystem.disconnect_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
EventSystem.disconnect_event(EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
# 清理计时器
if _hide_timer:
_hide_timer.queue_free()
# ============================================================================
# 输入处理
# ============================================================================
# 处理全局输入
func _input(event: InputEvent) -> void:
# 检查是否按下 Enter 键
if event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER):
_handle_enter_pressed()
# 处理 Enter 键按下
func _handle_enter_pressed() -> void:
# 如果聊天框未显示,显示它
if not _is_chat_visible:
show_chat()
# 使用 call_deferred 避免在同一个事件周期内触发 LineEdit 的 text_submitted 信号
call_deferred("_grab_input_focus")
return
# 如果聊天框已显示且输入框有焦点,检查输入框内容
if chat_input.has_focus():
# 如果输入框有内容,发送消息
if not chat_input.text.is_empty():
_on_send_button_pressed()
return
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
chat_input.grab_focus()
# 延迟获取输入框焦点(避免事件冲突)
func _grab_input_focus() -> void:
if chat_input:
chat_input.grab_focus()
# 处理 GUI 输入(鼠标点击)
func _gui_input(event: InputEvent) -> void:
# 检查鼠标点击
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
_handle_click_outside()
# 处理点击聊天框外部区域
func _handle_click_outside() -> void:
# 检查点击是否在聊天面板外部
if not chat_panel.get_global_rect().has_point(get_global_mouse_position()):
# 延迟释放输入框焦点,避免事件冲突
if chat_input.has_focus():
call_deferred("_release_input_focus")
# 延迟释放输入框焦点(由 call_deferred 调用)
func _release_input_focus() -> void:
if chat_input and chat_input.has_focus():
chat_input.release_focus()
# ============================================================================
# 显示/隐藏逻辑
# ============================================================================
# 显示聊天框
func show_chat() -> void:
_is_chat_visible = true
if is_instance_valid(chat_panel):
chat_panel.show()
# 停止隐藏计时器
_stop_hide_timer()
# 隐藏聊天框
func hide_chat() -> void:
_is_chat_visible = false
_is_typing = false
if is_instance_valid(chat_panel):
chat_panel.hide()
if is_instance_valid(chat_input) and chat_input.has_focus():
chat_input.release_focus()
# 停止隐藏计时器
_stop_hide_timer()
# 创建隐藏计时器
func _create_hide_timer() -> void:
_hide_timer = Timer.new()
_hide_timer.wait_time = 5.0 # 5 秒
_hide_timer.one_shot = true
_hide_timer.timeout.connect(_on_hide_timeout)
add_child(_hide_timer)
# 开始隐藏倒计时
func _start_hide_timer() -> void:
if _is_typing:
return # 输入时不隐藏
if not is_instance_valid(_hide_timer):
return
if not _hide_timer.is_inside_tree():
return
_stop_hide_timer() # 先停止之前的计时器
_hide_timer.start()
# 停止隐藏倒计时
func _stop_hide_timer() -> void:
if is_instance_valid(_hide_timer) and _hide_timer.is_inside_tree():
_hide_timer.stop()
# 隐藏计时器超时
func _on_hide_timeout() -> void:
hide_chat()
# ============================================================================
# UI 事件处理
# ============================================================================
# 连接 UI 信号
func _connect_ui_signals() -> void:
# 输入框回车
chat_input.text_submitted.connect(_on_chat_input_submitted)
# 输入框焦点变化
chat_input.focus_entered.connect(_on_input_focus_entered)
chat_input.focus_exited.connect(_on_input_focus_exited)
# 输入框获得焦点
func _on_input_focus_entered() -> void:
_is_typing = true
_stop_hide_timer() # 停止隐藏计时器
# 输入框失去焦点
func _on_input_focus_exited() -> void:
_is_typing = false
if not is_inside_tree():
return
# 开始 5 秒倒计时
if not _is_chat_visible:
return
_start_hide_timer()
# 发送按钮点击处理
func _on_send_button_pressed() -> void:
var content: String = chat_input.text.strip_edges()
if content.is_empty():
return
# 发送消息
ChatManager.send_chat_message(content, "local")
# 清空输入框
chat_input.clear()
# 重新聚焦输入框
chat_input.grab_focus()
# 聊天输入提交(回车键)处理
func _on_chat_input_submitted(text: String) -> void:
_on_send_button_pressed()
# ============================================================================
# 事件订阅Call Down
# ============================================================================
# 订阅事件
func _subscribe_to_events() -> void:
# 订阅聊天消息接收事件
EventSystem.connect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
# 订阅聊天错误事件
EventSystem.connect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
# 订阅连接状态变化事件
EventSystem.connect_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
# 订阅登录成功事件
EventSystem.connect_event(EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
# ============================================================================
# 事件处理器
# ============================================================================
# 处理接收到的聊天消息
func _on_chat_message_received(data: Dictionary) -> void:
var from_user: String = data.get("from_user", "")
var content: String = data.get("content", "")
var timestamp: float = data.get("timestamp", 0.0)
var is_self: bool = bool(data.get("is_self", false))
if not data.has("is_self") and not _current_username.is_empty() and from_user == _current_username:
is_self = true
# 添加到消息历史
add_message_to_history(from_user, content, timestamp, is_self)
# 处理聊天错误
func _on_chat_error(data: Dictionary) -> void:
var error_code: String = data.get("error_code", "")
var message: String = data.get("message", "")
push_error("ChatUI: [%s] %s" % [error_code, message])
# 处理连接状态变化
func _on_connection_state_changed(data: Dictionary) -> void:
# 连接状态变化处理当前不更新UI
pass
# 处理登录成功
func _on_login_success(data: Dictionary) -> void:
_current_username = data.get("username", "")
# ============================================================================
# 公共 API - 消息管理
# ============================================================================
# 添加消息到历史
#
# 参数:
# from_user: String - 发送者用户名
# content: String - 消息内容
# timestamp: float - 时间戳
# is_self: bool - 是否为自己发送的消息(默认 false
func add_message_to_history(from_user: String, content: String, timestamp: float, is_self: bool) -> void:
# 如果聊天框隐藏,自动显示
if not _is_chat_visible:
show_chat()
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
var row := HBoxContainer.new()
row.layout_mode = 2 # 让 VBoxContainer 接管布局,否则会重叠在同一位置
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.alignment = BoxContainer.ALIGNMENT_END if is_self else BoxContainer.ALIGNMENT_BEGIN
# 创建消息节点
var message_node: ChatMessage = chat_message_scene.instantiate()
# 先加入场景树,再设置内容(避免 ChatMessage._ready 尚未执行导致节点引用为空)
message_list.add_child(row)
row.add_child(message_node)
# 设置消息内容
message_node.set_message(from_user, content, timestamp, is_self)
# 自动滚动到底部
call_deferred("_scroll_to_bottom")
# ============================================================================
# 内部方法 - UI 更新
# ============================================================================
# 滚动到底部
func _scroll_to_bottom() -> void:
# 等待一帧,确保 UI 更新完成
await get_tree().process_frame
# 滚动到底部
if is_instance_valid(chat_history):
chat_history.scroll_vertical = chat_history.get_v_scroll_bar().max_value

View File

@@ -1 +0,0 @@
uid://pibdlvhb12q8

View File

@@ -1,120 +0,0 @@
[gd_scene load_steps=8 format=3 uid="uid://bv7k2nan4xj8q"]
[ext_resource type="Script" uid="uid://pibdlvhb12q8" path="res://scenes/ui/ChatUI.gd" id="1"]
[ext_resource type="Texture2D" uid="uid://cchjgp6qh7u61" path="res://assets/ui/chat/缩略框背景.png" id="2_7dhmv"]
[ext_resource type="Texture2D" uid="uid://clmgyxpeh5742" path="res://assets/ui/chat/输入框背景.png" id="3_fbft8"]
[ext_resource type="Texture2D" uid="uid://q0ijn5y0tbw3" path="res://assets/ui/chat/装饰2.png" id="4_xo31h"]
[ext_resource type="Texture2D" uid="uid://ct0cl4h2i6ydn" path="res://assets/ui/chat/装饰.png" id="5_xlxdo"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xo31h"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1ahvy"]
[node name="ChatUI" type="Control"]
unique_name_in_owner = true
custom_minimum_size = Vector2(10, 20)
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
[node name="ChatPanel" type="Control" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchor_left = 0.007
anchor_top = 0.98700005
anchor_right = 0.007
anchor_bottom = 0.98700005
offset_left = 0.36800003
offset_top = -240.01605
offset_right = 340.368
offset_bottom = -0.016052246
grow_vertical = 0
[node name="TextureRect" type="TextureRect" parent="ChatPanel"]
layout_mode = 1
anchors_preset = -1
anchor_top = 1.0
anchor_bottom = 1.0
offset_top = -240.0
offset_right = 340.0
texture = ExtResource("2_7dhmv")
expand_mode = 1
[node name="VBoxContainer" type="VBoxContainer" parent="ChatPanel"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 15.0
offset_top = 15.0
offset_right = -15.0
offset_bottom = -55.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 8
[node name="ChatHistory" type="ScrollContainer" parent="ChatPanel/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="MessageList" type="VBoxContainer" parent="ChatPanel/VBoxContainer/ChatHistory"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
theme_override_constants/separation = 8
[node name="TextureRect2" type="TextureRect" parent="ChatPanel"]
layout_mode = 0
offset_left = 10.0
offset_top = 10.0
offset_right = 20.0
offset_bottom = 20.0
texture = ExtResource("4_xo31h")
expand_mode = 1
[node name="TextureRect3" type="TextureRect" parent="ChatPanel"]
layout_mode = 0
offset_left = 320.0
offset_top = 180.0
offset_right = 330.0
offset_bottom = 190.0
texture = ExtResource("5_xlxdo")
expand_mode = 1
[node name="TextureRect4" type="TextureRect" parent="ChatPanel"]
layout_mode = 1
anchors_preset = -1
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 15.0
offset_top = -40.0
offset_right = 325.0
offset_bottom = -10.0
texture = ExtResource("3_fbft8")
expand_mode = 1
[node name="InputContainer" type="HBoxContainer" parent="ChatPanel"]
self_modulate = Color(1, 1, 1, 0.03529412)
layout_mode = 0
offset_left = 25.0
offset_top = 202.5
offset_right = 315.0
offset_bottom = 227.5
theme_override_constants/separation = 4
[node name="ChatInput" type="LineEdit" parent="ChatPanel/InputContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
localize_numeral_system = false
theme_override_font_sizes/font_size = 12
theme_override_styles/normal = SubResource("StyleBoxEmpty_xo31h")
theme_override_styles/read_only = SubResource("StyleBoxEmpty_xo31h")
theme_override_styles/focus = SubResource("StyleBoxEmpty_1ahvy")

View File

@@ -31,29 +31,25 @@ var pages = [
var current_page = 0 var current_page = 0
var tween: Tween var tween: Tween
var mock_pages = [] var mock_pages = []
var _chat_ui: Control
var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP
var _chat_ui_mouse_disabled: bool = false
func _ready(): func _ready():
# Pause the game # Pause the game
get_tree().paused = true get_tree().paused = true
_disable_chat_ui_mouse_input()
$CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer/CloseButton.pressed.connect(_on_close_pressed) $CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer/CloseButton.pressed.connect(_on_close_pressed)
prev_btn.pressed.connect(_on_prev_pressed) prev_btn.pressed.connect(_on_prev_pressed)
next_btn.pressed.connect(_on_next_pressed) next_btn.pressed.connect(_on_next_pressed)
# Local-only setup: use built-in pages directly, no network dependency. # Network Integration - Use direct callback for better error handling
# Short timeout (2.0s) so mock data appears quickly if server is down
NetworkManager.get_request("/notices", _on_notices_response, 2.0)
# Initial Setup (with generic "Loading" state)
mock_pages = pages.duplicate(true) mock_pages = pages.duplicate(true)
current_page = 0 pages = [{"text": "[center]Loading notices...[/center]", "image_color": Color(0.9, 0.9, 0.9)}]
content_container.modulate.a = 1.0
_setup_dots() _setup_dots()
_update_ui(false) _update_ui(false)
func _exit_tree():
_restore_chat_ui_mouse_input()
func _on_notices_response(success: bool, data: Dictionary, _error_info: Dictionary): func _on_notices_response(success: bool, data: Dictionary, _error_info: Dictionary):
var new_pages = [] var new_pages = []
if success and data.has("data") and data["data"] is Array: if success and data.has("data") and data["data"] is Array:
@@ -70,8 +66,7 @@ func _on_notices_response(success: bool, data: Dictionary, _error_info: Dictiona
current_page = 0 current_page = 0
_setup_dots() _setup_dots()
content_container.modulate.a = 1.0 _update_ui(true)
_update_ui(false)
func _setup_dots(): func _setup_dots():
@@ -159,23 +154,3 @@ func _on_close_pressed():
# Unpause the game # Unpause the game
get_tree().paused = false get_tree().paused = false
queue_free() queue_free()
func _disable_chat_ui_mouse_input():
var current_scene = get_tree().current_scene
if current_scene:
_chat_ui = current_scene.get_node_or_null("UILayer/ChatUI") as Control
if _chat_ui == null:
_chat_ui = get_tree().root.find_child("ChatUI", true, false) as Control
if _chat_ui:
_chat_ui_prev_mouse_filter = _chat_ui.mouse_filter
_chat_ui.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chat_ui_mouse_disabled = true
func _restore_chat_ui_mouse_input():
if _chat_ui_mouse_disabled and is_instance_valid(_chat_ui):
_chat_ui.mouse_filter = _chat_ui_prev_mouse_filter
_chat_ui_mouse_disabled = false
_chat_ui = null

View File

@@ -1 +1 @@
uid://c227m0okmjt2t uid://cxi5rchnmk07p

View File

@@ -1,12 +1,6 @@
extends CanvasLayer extends CanvasLayer
var _chat_ui: Control
var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP
var _chat_ui_mouse_disabled: bool = false
func _ready(): func _ready():
_disable_chat_ui_mouse_input()
# Connect close button (X) # Connect close button (X)
var header_close = find_child("CloseButton", true, false) var header_close = find_child("CloseButton", true, false)
if header_close: if header_close:
@@ -17,9 +11,6 @@ func _ready():
if start_btn: if start_btn:
start_btn.pressed.connect(_on_close_pressed) start_btn.pressed.connect(_on_close_pressed)
func _exit_tree():
_restore_chat_ui_mouse_input()
func _on_close_pressed(): func _on_close_pressed():
queue_free() queue_free()
@@ -27,23 +18,3 @@ func _input(event):
# Allow ESC to close # Allow ESC to close
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
queue_free() queue_free()
func _disable_chat_ui_mouse_input():
var current_scene = get_tree().current_scene
if current_scene:
_chat_ui = current_scene.get_node_or_null("UILayer/ChatUI") as Control
if _chat_ui == null:
_chat_ui = get_tree().root.find_child("ChatUI", true, false) as Control
if _chat_ui:
_chat_ui_prev_mouse_filter = _chat_ui.mouse_filter
_chat_ui.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chat_ui_mouse_disabled = true
func _restore_chat_ui_mouse_input():
if _chat_ui_mouse_disabled and is_instance_valid(_chat_ui):
_chat_ui.mouse_filter = _chat_ui_prev_mouse_filter
_chat_ui_mouse_disabled = false
_chat_ui = null

View File

@@ -1 +1 @@
uid://cohijfo0yeo34 uid://cu6x4dxhsylw2

View File

@@ -1,6 +1,6 @@
[gd_scene load_steps=3 format=3 uid="uid://rdmro1jxs6ga"] [gd_scene load_steps=3 format=3 uid="uid://rdmro1jxs6ga"]
[ext_resource type="Script" uid="uid://c227m0okmjt2t" path="res://scenes/ui/NoticeDialog.gd" id="1_script"] [ext_resource type="Script" uid="uid://cxi5rchnmk07p" path="res://scenes/ui/NoticeDialog.gd" id="1_script"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"]
bg_color = Color(0.95, 0.95, 0.95, 1) bg_color = Color(0.95, 0.95, 0.95, 1)

View File

@@ -1,6 +1,6 @@
[gd_scene load_steps=5 format=3 uid="uid://d8mam0n1a3b5"] [gd_scene load_steps=5 format=3 uid="uid://d8mam0n1a3b5"]
[ext_resource type="Script" uid="uid://cohijfo0yeo34" path="res://scenes/prefabs/ui/WelcomeDialog.gd" id="1_vs5b1"] [ext_resource type="Script" uid="uid://cu6x4dxhsylw2" path="res://scenes/ui/WelcomeDialog.gd" id="1_vs5b1"]
[ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_dy5hw"] [ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_dy5hw"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]

118
test_client.js Normal file
View File

@@ -0,0 +1,118 @@
const WebSocket = require('ws');
const http = require('http');
// Mock Data
// const TOKEN = "mock_token_for_validation"; // Assuming manual auth is bypassed or working
const WS_URL = 'ws://localhost:3000/location-broadcast';
const SESSION_ID = 'debug_room';
function loginAndStart() {
// 1. Try Register first (in case user doesn't exist)
const registerData = JSON.stringify({
username: 'testuser1',
password: 'password123',
nickname: 'TestUser'
});
const regOptions = {
hostname: 'localhost',
port: 3000,
path: '/auth/register',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': registerData.length }
};
const regReq = http.request(regOptions, (res) => {
// Regardless of success (maybe already exists), try login
performLogin();
});
regReq.on('error', () => performLogin()); // Proceed to login on error
regReq.write(registerData);
regReq.end();
}
function performLogin() {
const postData = JSON.stringify({
identifier: 'testuser1', // Using a test account
password: 'password123'
});
const options = {
hostname: 'localhost',
port: 3000,
path: '/auth/login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': postData.length
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 201 || res.statusCode === 200) {
const json = JSON.parse(data);
const realToken = json.data.access_token;
console.log("Got Token:", realToken.substring(0, 10) + "...");
startClients(realToken);
} else {
console.error("Login Failed:", res.statusCode, data);
}
});
});
req.on('error', (e) => {
console.error(`Login Request Error: ${e.message}`);
});
req.write(postData);
req.end();
}
function startClients(validToken) {
console.log("Starting Test Clients...");
createClient('Listener', validToken);
setTimeout(() => createClient('Sender', validToken), 1000);
}
function createClient(name, token) {
const ws = new WebSocket(WS_URL);
ws.on('open', () => {
// console.log(`[${name}] Connected`);
setTimeout(() => {
// console.log(`[${name}] Sending Join Session...`);
const joinMsg = {
event: 'join_session',
data: {
sessionId: SESSION_ID,
token: token
}
};
ws.send(JSON.stringify(joinMsg));
}, 500);
});
ws.on('message', (data) => {
console.log(`[${name}] Rx: ${data.toString()}`);
});
ws.on('close', (code, reason) => {
console.log(`[${name}] Closed: ${code} ${reason}`);
});
ws.on('error', (err) => {
console.error(`[${name}] Error:`, err.message);
});
}
// Start
loginAndStart();
// Timeout
setTimeout(() => {
console.log("❌ Test Timed Out - No broadcast received.");
process.exit(1);
}, 15000);

View File

@@ -1,357 +0,0 @@
extends GutTest
# ============================================================================
# test_chat_manager.gd - ChatManager 单元测试
# ============================================================================
# 测试聊天系统业务逻辑的功能
#
# 测试覆盖:
# - 初始化测试
# - Token 管理
# - 频率限制
# - 消息历史管理
# - 错误处理
# - 信号发射
# ============================================================================
var chat_manager: ChatManager
# ============================================================================
# 测试设置和清理
# ============================================================================
func before_each():
# 每个测试前创建新实例
chat_manager = ChatManager.new()
add_child(chat_manager)
func after_each():
# 每个测试后清理
if is_instance_valid(chat_manager):
chat_manager.queue_free()
chat_manager = null
# ============================================================================
# 初始化测试
# ============================================================================
func test_chat_manager_initialization():
# 测试管理器初始化
assert_not_null(chat_manager, "ChatManager 应该成功初始化")
assert_not_null(chat_manager._websocket_manager, "WebSocket 管理器应该被创建")
assert_not_null(chat_manager._socket_client, "Socket.IO 客户端应该被创建")
assert_false(chat_manager.is_chat_connected(), "初始状态应该是未连接")
# ============================================================================
# Token 管理测试
# ============================================================================
func test_set_game_token():
# 测试设置游戏 token
chat_manager.set_game_token("test_token_123")
assert_eq(chat_manager.get_game_token(), "test_token_123",
"Token 应该被正确设置")
func test_get_game_token_initially_empty():
# 测试初始 token 为空
assert_eq(chat_manager.get_game_token(), "",
"初始 token 应该为空字符串")
func test_set_empty_token():
# 测试设置空 token
chat_manager.set_game_token("")
assert_eq(chat_manager.get_game_token(), "",
"空 token 应该被接受")
func test_update_token():
# 测试更新 token
chat_manager.set_game_token("token1")
assert_eq(chat_manager.get_game_token(), "token1", "第一个 token 应该被设置")
chat_manager.set_game_token("token2")
assert_eq(chat_manager.get_game_token(), "token2", "token 应该被更新")
# ============================================================================
# 频率限制测试
# ============================================================================
func test_can_send_message_initially():
# 测试初始状态可以发送消息
assert_true(chat_manager.can_send_message(),
"初始状态应该可以发送消息")
func test_can_send_message_after_one_send():
# 测试发送一条消息后仍可发送
chat_manager._record_message_timestamp()
assert_true(chat_manager.can_send_message(),
"发送一条消息后应该仍可以发送")
func test_rate_limit_exceeded():
# 测试达到频率限制
# 记录 10 条消息(达到限制)
for i in range(10):
chat_manager._record_message_timestamp()
assert_false(chat_manager.can_send_message(),
"达到频率限制后不应该可以发送消息")
func test_rate_limit_window_expires():
# 测试频率限制窗口过期
# 记录 10 条消息
for i in range(10):
chat_manager._record_message_timestamp()
# 模拟时间流逝(将时间戳设置为很久以前)
var old_time := Time.get_unix_time_from_system() - 100.0
chat_manager._message_timestamps = []
for i in range(10):
chat_manager._message_timestamps.append(old_time)
assert_true(chat_manager.can_send_message(),
"旧消息过期后应该可以发送新消息")
func test_get_time_until_next_message():
# 测试获取下次可发送消息的等待时间
chat_manager._message_timestamps = []
var wait_time := chat_manager.get_time_until_next_message()
assert_eq(wait_time, 0.0, "没有消息时等待时间应该为 0")
# ============================================================================
# 消息历史管理测试
# ============================================================================
func test_add_message_to_history():
# 测试添加消息到历史
var message := {
"from_user": "Alice",
"content": "Hello!",
"timestamp": 1000.0,
"is_self": false
}
chat_manager._add_message_to_history(message)
assert_eq(chat_manager._message_history.size(), 1, "历史应该有 1 条消息")
assert_eq(chat_manager._message_history[0].from_user, "Alice",
"消息发送者应该是 Alice")
func test_message_history_limit():
# 测试消息历史限制(最多 100 条)
# 添加 101 条消息
for i in range(101):
var message := {
"from_user": "User" + str(i),
"content": "Message " + str(i),
"timestamp": float(i),
"is_self": false
}
chat_manager._add_message_to_history(message)
assert_eq(chat_manager._message_history.size(), 100,
"消息历史应该被限制在 100 条")
func test_get_message_history():
# 测试获取消息历史
var message1 := {"from_user": "Alice", "content": "Hi", "timestamp": 100.0}
var message2 := {"from_user": "Bob", "content": "Hello", "timestamp": 200.0}
chat_manager._add_message_to_history(message1)
chat_manager._add_message_to_history(message2)
var history := chat_manager.get_message_history()
assert_eq(history.size(), 2, "应该返回 2 条消息")
assert_eq(history[0].content, "Hi", "第一条消息内容应该匹配")
func test_clear_message_history():
# 测试清空消息历史
var message := {"from_user": "Alice", "content": "Hi", "timestamp": 100.0}
chat_manager._add_message_to_history(message)
assert_eq(chat_manager._message_history.size(), 1, "应该有 1 条消息")
chat_manager.clear_message_history()
assert_eq(chat_manager._message_history.size(), 0, "历史应该被清空")
# ============================================================================
# 错误处理测试
# ============================================================================
func test_error_message_mapping():
# 测试错误消息映射
var error_codes := ["AUTH_FAILED", "RATE_LIMIT", "CONTENT_FILTERED",
"CONTENT_TOO_LONG", "PERMISSION_DENIED", "SESSION_EXPIRED",
"ZULIP_ERROR", "INTERNAL_ERROR"]
for code in error_codes:
assert_true(ChatManager.CHAT_ERROR_MESSAGES.has(code),
"错误码 " + code + " 应该有对应的错误消息")
func test_handle_error():
# 测试错误处理
watch_signals(chat_manager)
chat_manager._handle_error("AUTH_FAILED", "测试错误")
assert_signal_emitted(chat_manager, "chat_error_occurred",
"应该发射 chat_error_occurred 信号")
# ============================================================================
# 常量测试
# ============================================================================
func test_websocket_url_constant():
# 测试 WebSocket URL 常量
assert_eq(ChatManager.WEBSOCKET_URL, "wss://whaletownend.xinghangee.icu/game",
"WebSocket URL 应该匹配")
func test_rate_limit_constants():
# 测试频率限制常量
assert_eq(ChatManager.RATE_LIMIT_MESSAGES, 10,
"频率限制消息数应该是 10")
assert_eq(ChatManager.RATE_LIMIT_WINDOW, 60.0,
"频率限制时间窗口应该是 60 秒")
func test_message_limit_constants():
# 测试消息限制常量
assert_eq(ChatManager.MAX_MESSAGE_LENGTH, 1000,
"最大消息长度应该是 1000")
assert_eq(ChatManager.MAX_MESSAGE_HISTORY, 100,
"最大消息历史应该是 100")
# ============================================================================
# 信号测试
# ============================================================================
func test_chat_message_sent_signal():
# 测试消息发送信号
watch_signals(chat_manager)
chat_manager.emit_signal("chat_message_sent", "msg123", 1000.0)
assert_signal_emitted(chat_manager, "chat_message_sent",
"应该发射 chat_message_sent 信号")
func test_chat_message_received_signal():
# 测试消息接收信号
watch_signals(chat_manager)
chat_manager.emit_signal("chat_message_received", "Alice", "Hello!", true, 1000.0)
assert_signal_emitted(chat_manager, "chat_message_received",
"应该发射 chat_message_received 信号")
func test_chat_error_occurred_signal():
# 测试错误发生信号
watch_signals(chat_manager)
chat_manager.emit_signal("chat_error_occurred", "AUTH_FAILED", "认证失败")
assert_signal_emitted(chat_manager, "chat_error_occurred",
"应该发射 chat_error_occurred 信号")
func test_chat_connection_state_changed_signal():
# 测试连接状态变化信号
watch_signals(chat_manager)
chat_manager.emit_signal("chat_connection_state_changed",
WebSocketManager.ConnectionState.CONNECTED)
assert_signal_emitted(chat_manager, "chat_connection_state_changed",
"应该发射 chat_connection_state_changed 信号")
# ============================================================================
# 边界条件测试
# ============================================================================
func test_empty_message_content():
# 测试空消息内容
var message := {
"from_user": "Alice",
"content": "",
"timestamp": 1000.0,
"is_self": false
}
chat_manager._add_message_to_history(message)
assert_eq(chat_manager._message_history.size(), 1,
"空消息应该被添加到历史")
func test_very_long_message_content():
# 测试超长消息内容(边界测试)
var long_content := "x".repeat(1000)
var message := {
"from_user": "Alice",
"content": long_content,
"timestamp": 1000.0,
"is_self": false
}
chat_manager._add_message_to_history(message)
assert_eq(chat_manager._message_history[0].content.length(), 1000,
"超长消息应该被保留")
func test_unicode_in_message():
# 测试消息中的 Unicode 字符
var message := {
"from_user": "玩家",
"content": "你好,世界!🎮🎉",
"timestamp": 1000.0,
"is_self": false
}
chat_manager._add_message_to_history(message)
assert_eq(chat_manager._message_history[0].content, "你好,世界!🎮🎉",
"Unicode 内容应该被正确保留")
func test_zero_timestamp():
# 测试零时间戳
var message := {
"from_user": "Alice",
"content": "Hello",
"timestamp": 0.0,
"is_self": false
}
chat_manager._add_message_to_history(message)
assert_eq(chat_manager._message_history[0].timestamp, 0.0,
"零时间戳应该被保留")
# ============================================================================
# 消息时间戳记录测试
# ============================================================================
func test_record_message_timestamp():
# 测试记录消息时间戳
chat_manager._record_message_timestamp()
assert_eq(chat_manager._message_timestamps.size(), 1,
"应该记录 1 个时间戳")
func test_multiple_message_timestamps():
# 测试记录多个消息时间戳
for i in range(5):
chat_manager._record_message_timestamp()
assert_eq(chat_manager._message_timestamps.size(), 5,
"应该记录 5 个时间戳")
func test_timestamps_in_order():
# 测试时间戳应该按顺序记录
chat_manager._record_message_timestamp()
await get_tree().process_frame
chat_manager._record_message_timestamp()
assert_gt(chat_manager._message_timestamps[1],
chat_manager._message_timestamps[0],
"后来的时间戳应该更大")

View File

@@ -1 +0,0 @@
uid://dgmrscyt2lsnt

View File

@@ -1,263 +0,0 @@
extends GutTest
# ============================================================================
# test_socketio_client.gd - SocketIOClient 单元测试
# ============================================================================
# 测试 Socket.IO 协议封装的功能
#
# 测试覆盖:
# - 初始化测试
# - 连接状态管理
# - 消息格式化
# - 事件监听器管理
# ============================================================================
var socket_client: SocketIOClient
# ============================================================================
# 测试设置和清理
# ============================================================================
func before_each():
# 每个测试前创建新实例
socket_client = SocketIOClient.new()
add_child(socket_client)
func after_each():
# 每个测试后清理
if is_instance_valid(socket_client):
socket_client.queue_free()
socket_client = null
# ============================================================================
# 初始化测试
# ============================================================================
func test_socket_initialization():
# 测试客户端初始化
assert_not_null(socket_client, "SocketIOClient 应该成功初始化")
assert_eq(socket_client.is_socket_connected(), false, "初始状态应该是未连接")
# ============================================================================
# 连接状态测试
# ============================================================================
func test_initial_connection_state():
# 测试初始连接状态为 DISCONNECTED
assert_eq(socket_client._connection_state, SocketIOClient.ConnectionState.DISCONNECTED,
"初始连接状态应该是 DISCONNECTED")
func test_get_connection_state_when_disconnected():
# 测试获取未连接状态
var state := socket_client._get_connection_state()
assert_eq(state, SocketIOClient.ConnectionState.DISCONNECTED,
"获取的连接状态应该是 DISCONNECTED")
# ============================================================================
# 事件监听器测试
# ============================================================================
func test_add_event_listener():
# 测试添加事件监听器
var callback_called := false
var test_callback := func(data: Dictionary):
callback_called = true
socket_client.add_event_listener("test_event", test_callback)
assert_true(socket_client._event_listeners.has("test_event"),
"事件监听器应该被添加")
assert_eq(socket_client._event_listeners["test_event"].size(), 1,
"应该有 1 个监听器")
func test_add_multiple_event_listeners():
# 测试添加多个监听器到同一事件
var callback1 := func(data: Dictionary): pass
var callback2 := func(data: Dictionary): pass
socket_client.add_event_listener("test_event", callback1)
socket_client.add_event_listener("test_event", callback2)
assert_eq(socket_client._event_listeners["test_event"].size(), 2,
"应该有 2 个监听器")
func test_remove_event_listener():
# 测试移除事件监听器
var callback := func(data: Dictionary): pass
socket_client.add_event_listener("test_event", callback)
assert_eq(socket_client._event_listeners["test_event"].size(), 1,
"监听器应该被添加")
socket_client.remove_event_listener("test_event", callback)
assert_eq(socket_client._event_listeners["test_event"].size(), 0,
"监听器应该被移除")
func test_remove_nonexistent_event_listener():
# 测试移除不存在的监听器不应该报错
var callback := func(data: Dictionary): pass
socket_client.remove_event_listener("nonexistent_event", callback)
# 如果没有报错,测试通过
assert_true(true, "移除不存在的监听器不应该报错")
# ============================================================================
# 消息格式化测试(模拟)
# ============================================================================
func test_message_data_structure():
# 测试消息数据结构
var message_data := {
"t": "chat",
"content": "Hello, world!",
"scope": "local"
}
# 验证数据结构
assert_true(message_data.has("t"), "消息应该有 't' 字段")
assert_eq(message_data["t"], "chat", "事件类型应该是 'chat'")
assert_eq(message_data["content"], "Hello, world!", "内容应该匹配")
assert_eq(message_data["scope"], "local", "范围应该是 'local'")
func test_login_message_structure():
# 测试登录消息数据结构
var login_data := {
"type": "login",
"token": "test_token_123"
}
# 验证数据结构
assert_true(login_data.has("type"), "登录消息应该有 'type' 字段")
assert_eq(login_data["type"], "login", "类型应该是 'login'")
assert_eq(login_data["token"], "test_token_123", "token 应该匹配")
func test_error_message_structure():
# 测试错误消息数据结构
var error_data := {
"t": "error",
"code": "AUTH_FAILED",
"message": "认证失败"
}
# 验证数据结构
assert_eq(error_data["t"], "error", "事件类型应该是 'error'")
assert_eq(error_data["code"], "AUTH_FAILED", "错误码应该匹配")
assert_eq(error_data["message"], "认证失败", "错误消息应该匹配")
# ============================================================================
# JSON 序列化测试
# ============================================================================
func test_json_serialization():
# 测试 JSON 序列化
var data := {
"t": "chat",
"content": "Test message",
"scope": "local"
}
var json_string := JSON.stringify(data)
var json := JSON.new()
var result := json.parse(json_string)
assert_eq(result, OK, "JSON 序列化应该成功")
assert_true(json.data.has("t"), "解析后的数据应该有 't' 字段")
assert_eq(json.data["content"], "Test message", "内容应该匹配")
func test_json_serialization_with_unicode():
# 测试包含 Unicode 字符的 JSON 序列化
var data := {
"t": "chat",
"content": "你好,世界!🎮",
"scope": "local"
}
var json_string := JSON.stringify(data)
var json := JSON.new()
var result := json.parse(json_string)
assert_eq(result, OK, "Unicode JSON 序列化应该成功")
assert_eq(json.data["content"], "你好,世界!🎮", "Unicode 内容应该匹配")
# ============================================================================
# 信号测试
# ============================================================================
func test_connected_signal():
# 测试连接成功信号
watch_signals(socket_client)
# 手动触发信号
socket_client.emit_signal("connected")
assert_signal_emitted(socket_client, "connected",
"应该发射 connected 信号")
func test_disconnected_signal():
# 测试断开连接信号
watch_signals(socket_client)
# 手动触发信号
socket_client.emit_signal("disconnected", true)
assert_signal_emitted(socket_client, "disconnected",
"应该发射 disconnected 信号")
func test_event_received_signal():
# 测试事件接收信号
watch_signals(socket_client)
var test_data := {"t": "test", "value": 123}
socket_client.emit_signal("event_received", "test", test_data)
assert_signal_emitted(socket_client, "event_received",
"应该发射 event_received 信号")
func test_error_occurred_signal():
# 测试错误发生信号
watch_signals(socket_client)
socket_client.emit_signal("error_occurred", "Test error")
assert_signal_emitted(socket_client, "error_occurred",
"应该发射 error_occurred 信号")
# ============================================================================
# 边界条件测试
# ============================================================================
func test_empty_message_content():
# 测试空消息内容
var data := {
"t": "chat",
"content": "",
"scope": "local"
}
assert_eq(data["content"], "", "空内容应该被保留")
func test_very_long_message_content():
# 测试超长消息内容(边界测试)
var long_content := "x".repeat(1000)
var data := {
"t": "chat",
"content": long_content,
"scope": "local"
}
assert_eq(data["content"].length(), 1000, "超长内容应该被保留")
func test_special_characters_in_message():
# 测试特殊字符
var data := {
"t": "chat",
"content": "Test with \"quotes\" and 'apostrophes'\n\tNew lines and tabs",
"scope": "local"
}
var json_string := JSON.stringify(data)
var json := JSON.new()
var result := json.parse(json_string)
assert_eq(result, OK, "特殊字符 JSON 序列化应该成功")
assert_true(json.data["content"].contains("\n"), "换行符应该被保留")

View File

@@ -1,271 +0,0 @@
extends GutTest
# ============================================================================
# test_websocket_manager.gd - WebSocketManager 单元测试
# ============================================================================
# 测试 WebSocket 连接管理的功能
#
# 测试覆盖:
# - 初始化测试
# - 连接状态管理
# - 重连机制
# - Socket.IO 客户端访问
# - 信号发射
# ============================================================================
var ws_manager: WebSocketManager
# ============================================================================
# 测试设置和清理
# ============================================================================
func before_each():
# 每个测试前创建新实例
ws_manager = WebSocketManager.new()
add_child(ws_manager)
func after_each():
# 每个测试后清理
if is_instance_valid(ws_manager):
ws_manager.queue_free()
ws_manager = null
# ============================================================================
# 初始化测试
# ============================================================================
func test_websocket_manager_initialization():
# 测试管理器初始化
assert_not_null(ws_manager, "WebSocketManager 应该成功初始化")
assert_not_null(ws_manager._socket_client, "Socket.IO 客户端应该被创建")
assert_eq(ws_manager.is_websocket_connected(), false, "初始状态应该是未连接")
func test_initial_connection_state():
# 测试初始连接状态
assert_eq(ws_manager.get_connection_state(),
WebSocketManager.ConnectionState.DISCONNECTED,
"初始连接状态应该是 DISCONNECTED")
# ============================================================================
# 连接状态测试
# ============================================================================
func test_connection_state_enum():
# 测试连接状态枚举值
assert_eq(WebSocketManager.ConnectionState.DISCONNECTED, 0,
"DISCONNECTED 应该是 0")
assert_eq(WebSocketManager.ConnectionState.CONNECTING, 1,
"CONNECTING 应该是 1")
assert_eq(WebSocketManager.ConnectionState.CONNECTED, 2,
"CONNECTED 应该是 2")
assert_eq(WebSocketManager.ConnectionState.RECONNECTING, 3,
"RECONNECTING 应该是 3")
assert_eq(WebSocketManager.ConnectionState.ERROR, 4,
"ERROR 应该是 4")
func test_get_state_description():
# 测试状态描述方法
ws_manager._connection_state = WebSocketManager.ConnectionState.DISCONNECTED
var desc := ws_manager.get_state_description()
assert_eq(desc, "未连接", "DISCONNECTED 状态描述应该是 '未连接'")
ws_manager._connection_state = WebSocketManager.ConnectionState.CONNECTED
desc = ws_manager.get_state_description()
assert_eq(desc, "已连接", "CONNECTED 状态描述应该是 '已连接'")
ws_manager._connection_state = WebSocketManager.ConnectionState.CONNECTING
desc = ws_manager.get_state_description()
assert_eq(desc, "连接中", "CONNECTING 状态描述应该是 '连接中'")
# ============================================================================
# 自动重连配置测试
# ============================================================================
func test_enable_auto_reconnect():
# 测试启用自动重连
ws_manager.enable_auto_reconnect(true, 5, 3.0)
assert_true(ws_manager._auto_reconnect_enabled, "自动重连应该被启用")
assert_eq(ws_manager._max_reconnect_attempts, 5, "最大重连次数应该是 5")
assert_eq(ws_manager._reconnect_base_delay, 3.0, "基础重连延迟应该是 3.0 秒")
func test_disable_auto_reconnect():
# 测试禁用自动重连
ws_manager.enable_auto_reconnect(false)
assert_false(ws_manager._auto_reconnect_enabled, "自动重连应该被禁用")
func test_get_reconnect_info():
# 测试获取重连信息
ws_manager.enable_auto_reconnect(true, 5, 3.0)
ws_manager._reconnect_attempt = 2
var info := ws_manager.get_reconnect_info()
assert_true(info.has("enabled"), "重连信息应该包含 'enabled'")
assert_true(info.has("attempt"), "重连信息应该包含 'attempt'")
assert_true(info.has("max_attempts"), "重连信息应该包含 'max_attempts'")
assert_true(info.has("next_delay"), "重连信息应该包含 'next_delay'")
assert_eq(info["enabled"], true, "自动重连应该启用")
assert_eq(info["attempt"], 2, "当前重连尝试次数应该是 2")
assert_eq(info["max_attempts"], 5, "最大重连次数应该是 5")
# ============================================================================
# 重连延迟计算测试
# ============================================================================
func test_reconnect_delay_calculation():
# 测试重连延迟计算(指数退避)
ws_manager._reconnect_base_delay = 2.0
ws_manager._reconnect_attempt = 1
var delay1 := ws_manager._calculate_reconnect_delay()
assert_eq(delay1, 2.0, "第 1 次重连延迟应该是 2.0 秒")
ws_manager._reconnect_attempt = 2
var delay2 := ws_manager._calculate_reconnect_delay()
assert_eq(delay2, 4.0, "第 2 次重连延迟应该是 4.0 秒")
ws_manager._reconnect_attempt = 3
var delay3 := ws_manager._calculate_reconnect_delay()
assert_eq(delay3, 8.0, "第 3 次重连延迟应该是 8.0 秒")
func test_max_reconnect_delay():
# 测试最大重连延迟限制
ws_manager._reconnect_base_delay = 10.0
ws_manager._reconnect_attempt = 10
var delay := ws_manager._calculate_reconnect_delay()
assert_eq(delay, WebSocketManager.MAX_RECONNECT_DELAY,
"重连延迟应该被限制在最大值")
# ============================================================================
# Socket.IO 客户端访问测试
# ============================================================================
func test_get_socket_client():
# 测试获取 Socket.IO 客户端
var socket_client := ws_manager.get_socket_client()
assert_not_null(socket_client, "应该返回 Socket.IO 客户端")
assert_true(socket_client is SocketIOClient,
"返回的应该是 SocketIOClient 实例")
# ============================================================================
# 信号测试
# ============================================================================
func test_connection_state_changed_signal():
# 测试连接状态变化信号
watch_signals(ws_manager)
# 手动触发信号
ws_manager.emit_signal("connection_state_changed", WebSocketManager.ConnectionState.CONNECTED)
assert_signal_emitted(ws_manager, "connection_state_changed",
"应该发射 connection_state_changed 信号")
func test_connection_lost_signal():
# 测试连接丢失信号
watch_signals(ws_manager)
ws_manager.emit_signal("connection_lost")
assert_signal_emitted(ws_manager, "connection_lost",
"应该发射 connection_lost 信号")
func test_reconnection_succeeded_signal():
# 测试重连成功信号
watch_signals(ws_manager)
ws_manager.emit_signal("reconnection_succeeded")
assert_signal_emitted(ws_manager, "reconnection_succeeded",
"应该发射 reconnection_succeeded 信号")
func test_reconnection_failed_signal():
# 测试重连失败信号
watch_signals(ws_manager)
ws_manager.emit_signal("reconnection_failed", 3, 5)
assert_signal_emitted(ws_manager, "reconnection_failed",
"应该发射 reconnection_failed 信号")
# ============================================================================
# 常量测试
# ============================================================================
func test_websocket_url_constant():
# 测试 WebSocket URL 常量
assert_eq(WebSocketManager.WEBSOCKET_URL, "wss://whaletownend.xinghangee.icu/game",
"WebSocket URL 应该匹配")
func test_default_reconnect_constants():
# 测试默认重连常量
assert_eq(WebSocketManager.DEFAULT_MAX_RECONNECT_ATTEMPTS, 5,
"默认最大重连次数应该是 5")
assert_eq(WebSocketManager.DEFAULT_RECONNECT_BASE_DELAY, 3.0,
"默认基础重连延迟应该是 3.0 秒")
func test_max_reconnect_delay_constant():
# 测试最大重连延迟常量
assert_eq(WebSocketManager.MAX_RECONNECT_DELAY, 30.0,
"最大重连延迟应该是 30.0 秒")
# ============================================================================
# 边界条件测试
# ============================================================================
func test_zero_max_reconnect_attempts():
# 测试零次重连尝试
ws_manager.enable_auto_reconnect(true, 0, 1.0)
ws_manager._reconnect_attempt = 1
var should_reconnect := ws_manager._reconnect_attempt < ws_manager._max_reconnect_attempts
assert_false(should_reconnect, "最大重连次数为 0 时不应该重连")
func test_very_large_reconnect_attempts():
# 测试非常大的重连次数
ws_manager.enable_auto_reconnect(true, 1000, 1.0)
ws_manager._reconnect_attempt = 999
var should_reconnect := ws_manager._reconnect_attempt < ws_manager._max_reconnect_attempts
assert_true(should_reconnect, "应该允许更多重连尝试")
func test_zero_base_delay():
# 测试零基础延迟
ws_manager._reconnect_base_delay = 0.0
ws_manager._reconnect_attempt = 1
var delay := ws_manager._calculate_reconnect_delay()
# 即使基础延迟为 0也应该返回一个合理的值
assert_true(delay >= 0.0, "延迟应该 >= 0")
# ============================================================================
# 状态转换测试
# ============================================================================
func test_state_disconnected_to_connecting():
# 测试从 DISCONNECTED 到 CONNECTING 的状态转换
ws_manager._set_connection_state(WebSocketManager.ConnectionState.CONNECTING)
assert_eq(ws_manager.get_connection_state(), WebSocketManager.ConnectionState.CONNECTING,
"状态应该转换为 CONNECTING")
func test_state_connecting_to_connected():
# 测试从 CONNECTING 到 CONNECTED 的状态转换
ws_manager._set_connection_state(WebSocketManager.ConnectionState.CONNECTING)
ws_manager._set_connection_state(WebSocketManager.ConnectionState.CONNECTED)
assert_eq(ws_manager.get_connection_state(), WebSocketManager.ConnectionState.CONNECTED,
"状态应该转换为 CONNECTED")
func test_state_connected_to_reconnecting():
# 测试从 CONNECTED 到 RECONNECTING 的状态转换
ws_manager._set_connection_state(WebSocketManager.ConnectionState.CONNECTED)
ws_manager._set_connection_state(WebSocketManager.ConnectionState.RECONNECTING)
assert_eq(ws_manager.get_connection_state(), WebSocketManager.ConnectionState.RECONNECTING,
"状态应该转换为 RECONNECTING")