feat:实现聊天系统核心功能

- 添加 SocketIOClient.gd 实现 Socket.IO 协议封装
- 添加 WebSocketManager.gd 管理连接生命周期和自动重连
- 添加 ChatManager.gd 实现聊天业务逻辑与会话管理
  - 支持当前会话缓存(最多 100 条消息)
  - 支持历史消息按需加载(每次 100 条)
  - 每次登录/重连自动重置会话缓存
  - 客户端频率限制(10 条/分钟)
  - Token 管理与认证
- 添加 ChatMessage.gd/tscn 消息气泡 UI 组件
- 添加 ChatUI.gd/tscn 聊天界面
- 在 EventNames.gd 添加 7 个聊天事件常量
- 在 AuthManager.gd 添加 game_token 管理方法
- 添加完整的单元测试(128 个测试用例)
  - test_socketio_client.gd (42 个测试)
  - test_websocket_manager.gd (38 个测试)
  - test_chat_manager.gd (48 个测试)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
王浩
2026-01-07 17:42:31 +08:00
parent e3c4d08021
commit fb7cba4088
22 changed files with 3734 additions and 1 deletions

View File

@@ -0,0 +1,167 @@
extends Panel
# ============================================================================
# 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
# ============================================================================
# 节点引用
# ============================================================================
# 用户名标签
@onready var username_label: Label = %UsernameLabel
# 时间戳标签
@onready var timestamp_label: Label = %TimestampLabel
# 内容标签
@onready var content_label: RichTextLabel = %ContentLabel
# 用户信息容器
@onready var user_info_container: HBoxContainer = %UserInfoContainer
# ============================================================================
# 成员变量
# ============================================================================
# 是否为自己发送的消息
var _is_self: bool = false
# ============================================================================
# 生命周期方法
# ============================================================================
# 准备就绪
func _ready() -> void:
# 应用最大宽度限制
custom_minimum_size.x = min(max_width, get_parent().size.x)
# ============================================================================
# 公共 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
# 设置用户名
username_label.text = from_user
# 设置内容
content_label.text = content
# 设置时间戳
timestamp_label.text = _format_timestamp(timestamp)
# 应用样式
_apply_style()
# ============================================================================
# 内部方法 - 样式处理
# ============================================================================
# 应用样式(自己和他人的消息不同)
func _apply_style() -> void:
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())
# 设置文字颜色
username_label.add_theme_color_override("font_color", Color.WHITE)
timestamp_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
else:
# 他人的消息:左侧对齐,灰色背景
size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN
# 设置面板样式
add_theme_stylebox_override("panel", _get_other_style())
# 设置文字颜色
username_label.add_theme_color_override("font_color", Color(0.2, 0.4, 0.8))
timestamp_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
# 获取自己消息的样式
func _get_self_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.6, 1.0, 0.3)
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.5)
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

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

View File

@@ -0,0 +1,45 @@
[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="Panel"]
offset_right = 400.0
offset_bottom = 100.0
size_flags_horizontal = 3
script = ExtResource("1")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 4
[node name="UserInfoContainer" type="HBoxContainer" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="UsernameLabel" type="Label" parent="VBoxContainer/UserInfoContainer"]
unique_name_in_owner = true
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"]
unique_name_in_owner = true
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"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
text = "Message content here"
fit_content = true

371
scenes/ui/ChatUI.gd Normal file
View File

@@ -0,0 +1,371 @@
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: Panel = %ChatPanel
# 聊天历史容器
@onready var chat_history: ScrollContainer = %ChatHistory
# 消息列表
@onready var message_list: VBoxContainer = %MessageList
# 聊天输入框
@onready var chat_input: LineEdit = %ChatInput
# 发送按钮
@onready var send_button: Button = %SendButton
# 状态标签
@onready var status_label: Label = %StatusLabel
# ============================================================================
# 预加载资源
# ============================================================================
# 聊天消息场景
@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:
print("ChatUI 初始化完成")
# 初始隐藏聊天框
hide_chat()
# 创建隐藏计时器
_create_hide_timer()
# 设置初始状态
_update_connection_status(false)
# 订阅事件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_action_pressed("ui_text_submit") or event.is_key_pressed(KEY_ENTER):
_handle_enter_pressed()
# 处理 Enter 键按下
func _handle_enter_pressed() -> void:
# 如果聊天框未显示,显示它
if not _is_chat_visible:
show_chat()
chat_input.grab_focus()
return
# 如果聊天框已显示且输入框有焦点,发送消息
if chat_input.has_focus():
# 发送消息
_on_send_button_pressed()
return
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
chat_input.grab_focus()
print("🔄 重新聚焦输入框,取消隐藏倒计时")
# 处理 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():
chat_input.release_focus()
print("🖱️ 点击外部,取消输入状态")
# ============================================================================
# 显示/隐藏逻辑
# ============================================================================
# 显示聊天框
func show_chat() -> void:
if _is_chat_visible:
return
_is_chat_visible = true
chat_panel.show()
print("👁️ 聊天框已显示")
# 停止隐藏计时器
_stop_hide_timer()
# 隐藏聊天框
func hide_chat() -> void:
if not _is_chat_visible:
return
_is_chat_visible = false
chat_panel.hide()
print("🚫 聊天框已隐藏")
# 停止隐藏计时器
_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 # 输入时不隐藏
_stop_hide_timer() # 先停止之前的计时器
_hide_timer.start()
print("⏱️ 开始 5 秒倒计时...")
# 停止隐藏倒计时
func _stop_hide_timer() -> void:
if _hide_timer:
_hide_timer.stop()
# 隐藏计时器超时
func _on_hide_timeout() -> void:
hide_chat()
# ============================================================================
# UI 事件处理
# ============================================================================
# 连接 UI 信号
func _connect_ui_signals() -> void:
# 发送按钮点击
send_button.pressed.connect(_on_send_button_pressed)
# 输入框回车
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() # 停止隐藏计时器
print("✍️ 开始输入")
# 输入框失去焦点
func _on_input_focus_exited() -> void:
_is_typing = false
# 开始 5 秒倒计时
if not _is_chat_visible:
return
_start_hide_timer()
print("🚫 停止输入,开始 5 秒隐藏倒计时")
# 发送按钮点击处理
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)
# 添加到消息历史
add_message_to_history(from_user, content, timestamp, false)
# 处理聊天错误
func _on_chat_error(data: Dictionary) -> void:
var error_code: String = data.get("error_code", "")
var message: String = data.get("message", "")
print("❌ ChatUI 错误: [", error_code, "] ", message)
# 显示错误消息(临时显示在状态栏)
status_label.text = "" + message
status_label.modulate = Color.RED
# 3秒后恢复状态
var timer := get_tree().create_timer(3.0)
timer.timeout.connect(func():
_update_connection_status(ChatManager.is_connected())
)
# 处理连接状态变化
func _on_connection_state_changed(data: Dictionary) -> void:
var state: WebSocketManager.ConnectionState = data.get("state", WebSocketManager.ConnectionState.DISCONNECTED)
match state:
WebSocketManager.ConnectionState.CONNECTED:
_update_connection_status(true)
_:
_update_connection_status(false)
# 处理登录成功
func _on_login_success(data: Dictionary) -> void:
_current_username = data.get("username", "")
print("✅ ChatUI: 登录成功,用户名: ", _current_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 message_node: ChatMessage = chat_message_scene.instantiate()
# 设置消息内容
message_node.set_message(from_user, content, timestamp, is_self)
# 添加到列表
message_list.add_child(message_node)
# 自动滚动到底部
call_deferred("_scroll_to_bottom")
# ============================================================================
# 内部方法 - UI 更新
# ============================================================================
# 更新连接状态显示
func _update_connection_status(connected: bool) -> void:
if connected:
status_label.text = "● 已连接"
status_label.modulate = Color.GREEN
chat_input.editable = true
send_button.disabled = false
else:
status_label.text = "○ 未连接"
status_label.modulate = Color.GRAY
chat_input.editable = false
send_button.disabled = true
# 滚动到底部
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

1
scenes/ui/ChatUI.gd.uid Normal file
View File

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

77
scenes/ui/ChatUI.tscn Normal file
View File

@@ -0,0 +1,77 @@
[gd_scene load_steps=2 format=3 uid="uid://bv7k2m9n4xj8q"]
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
[node name="ChatUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")
[node name="ChatPanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -450.0
offset_top = -400.0
grow_horizontal = 0
grow_vertical = 0
[node name="VBoxContainer" type="VBoxContainer" parent="ChatPanel"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 8
offset_left = 10.0
offset_top = 10.0
offset_right = -10.0
offset_bottom = -10.0
[node name="HeaderContainer" type="HBoxContainer" parent="ChatPanel/VBoxContainer"]
layout_mode = 2
[node name="StatusLabel" type="Label" parent="ChatPanel/VBoxContainer/HeaderContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.5, 0.5, 0.5, 1)
theme_override_font_sizes/font_size = 12
text = "○ 未连接"
[node name="ChatHistory" type="ScrollContainer" parent="ChatPanel/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
[node name="MessageList" type="VBoxContainer" parent="ChatPanel/VBoxContainer/ChatHistory"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 8
[node name="InputContainer" type="HBoxContainer" parent="ChatPanel/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="ChatInput" type="LineEdit" parent="ChatPanel/VBoxContainer/InputContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "输入消息..."
[node name="SendButton" type="Button" parent="ChatPanel/VBoxContainer/InputContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "发送"