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:
371
scenes/ui/ChatUI.gd
Normal file
371
scenes/ui/ChatUI.gd
Normal 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
1
scenes/ui/ChatUI.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://pibdlvhb12q8
|
||||
77
scenes/ui/ChatUI.tscn
Normal file
77
scenes/ui/ChatUI.tscn
Normal 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 = "发送"
|
||||
Reference in New Issue
Block a user