forked from datawhale/whale-town-front
Compare commits
3 Commits
625fe0ff6c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603e7d9fc6 | ||
|
|
e989b4adf1 | ||
|
|
6e70aac0b9 |
@@ -122,6 +122,10 @@ var _current_map: String = ""
|
||||
# 游戏 token
|
||||
var _game_token: String = ""
|
||||
|
||||
# 发送后本地回显去重(避免服务端也回发导致重复显示)
|
||||
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
|
||||
var _pending_self_messages: Array[Dictionary] = []
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
# ============================================================================
|
||||
@@ -240,7 +244,10 @@ func send_chat_message(content: String, scope: String = "local") -> void:
|
||||
|
||||
# 发送消息(JSON 字符串)
|
||||
var json_string := JSON.stringify(message_data)
|
||||
_websocket_manager.send_message(json_string)
|
||||
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()
|
||||
@@ -253,6 +260,24 @@ func send_chat_message(content: String, scope: String = "local") -> void:
|
||||
"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
|
||||
})
|
||||
|
||||
print("📤 发送聊天消息: ", content)
|
||||
|
||||
# 消息发送完成回调
|
||||
@@ -427,6 +452,7 @@ func _on_history_loaded(messages: Array) -> void:
|
||||
"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 # 标记为历史消息
|
||||
})
|
||||
|
||||
@@ -504,6 +530,8 @@ func _on_data_received(message: String) -> void:
|
||||
_handle_login_success(data)
|
||||
"login_error":
|
||||
_handle_login_error(data)
|
||||
"chat":
|
||||
_handle_chat_render(data)
|
||||
"chat_sent":
|
||||
_handle_chat_sent(data)
|
||||
"chat_error":
|
||||
@@ -578,12 +606,26 @@ func _handle_chat_error(data: Dictionary) -> void:
|
||||
"message": error_message
|
||||
})
|
||||
|
||||
# 处理接收到的聊天消息
|
||||
# 处理接收到的聊天消息
|
||||
func _handle_chat_render(data: Dictionary) -> void:
|
||||
var from_user: String = data.get("from", "")
|
||||
var content: String = data.get("txt", "")
|
||||
var show_bubble: bool = data.get("bubble", false)
|
||||
var timestamp: float = data.get("timestamp", 0.0)
|
||||
# 兼容不同后端字段命名:
|
||||
# - 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
|
||||
|
||||
print("📨 收到聊天消息: ", from_user, " -> ", content)
|
||||
|
||||
@@ -592,7 +634,7 @@ func _handle_chat_render(data: Dictionary) -> void:
|
||||
"from_user": from_user,
|
||||
"content": content,
|
||||
"timestamp": timestamp,
|
||||
"is_self": false
|
||||
"is_self": is_self
|
||||
})
|
||||
|
||||
# 发射信号
|
||||
@@ -603,9 +645,44 @@ func _handle_chat_render(data: Dictionary) -> void:
|
||||
"from_user": from_user,
|
||||
"content": content,
|
||||
"show_bubble": show_bubble,
|
||||
"timestamp": timestamp
|
||||
"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", "")
|
||||
@@ -665,6 +742,24 @@ 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)
|
||||
|
||||
@@ -1,865 +0,0 @@
|
||||
# WhaleTown 聊天系统实施计划
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
为 WhaleTown 游戏实现基于 Socket.IO 的实时聊天系统,对接现有的 Zulip 集成后端。
|
||||
|
||||
**后端地址**: `wss://whaletownend.xinghangee.icu/game`
|
||||
|
||||
**技术限制**: Godot 原生支持 WebSocket 但不支持 Socket.IO 协议,需要实现轻量级 Socket.IO 协议封装。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心架构原则
|
||||
|
||||
严格遵循项目规范:
|
||||
- **Signal Up, Call Down** - 高层通过事件通知低层
|
||||
- **严格分层** - `_Core`(框架层)、`scenes`(游戏层)、`UI`(界面层)
|
||||
- **类型安全** - 所有变量使用严格类型标注
|
||||
- **命名规范** - `class_name PascalCase`,函数/变量 `snake_case`,常量 `SCREAMING_SNAKE_CASE`
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
### 新建文件
|
||||
|
||||
```
|
||||
_Core/
|
||||
systems/
|
||||
SocketIOClient.gd # Socket.IO 协议封装(核心)
|
||||
managers/
|
||||
ChatManager.gd # 聊天系统业务逻辑管理器
|
||||
WebSocketManager.gd # WebSocket 连接生命周期管理
|
||||
|
||||
scenes/
|
||||
ui/
|
||||
ChatUI.tscn # 聊天界面场景
|
||||
ChatUI.gd # 聊天界面控制器
|
||||
prefabs/ui/
|
||||
ChatMessage.tscn # 单条消息气泡预制体
|
||||
ChatMessage.gd # 消息气泡组件
|
||||
|
||||
tests/
|
||||
unit/
|
||||
test_chat_manager.gd # ChatManager 单元测试
|
||||
test_socketio_client.gd # SocketIOClient 单元测试
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
- [_Core/EventNames.gd](_Core/EventNames.gd) - 添加聊天事件常量
|
||||
- [project.godot](project.godot) - 添加 ChatManager 到自动加载
|
||||
|
||||
---
|
||||
|
||||
## 🔧 核心组件设计
|
||||
|
||||
### 1. SocketIOClient.gd - 协议封装层
|
||||
|
||||
**位置**: `_Core/systems/SocketIOClient.gd`
|
||||
|
||||
**职责**:
|
||||
- 封装 Godot 的 `WebSocketPeer`
|
||||
- 实现 Socket.IO 消息协议(简化版 JSON 格式)
|
||||
- 管理事件监听器
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name SocketIOClient
|
||||
extends Node
|
||||
|
||||
# 信号
|
||||
signal connected()
|
||||
signal disconnected()
|
||||
signal event_received(event_name: String, data: Dictionary)
|
||||
signal error_occurred(error: String)
|
||||
|
||||
# 连接管理
|
||||
func connect_to_server(url: String) -> void
|
||||
func disconnect_from_server() -> void
|
||||
func is_connected() -> bool
|
||||
|
||||
# 事件发送(对应 socket.emit)
|
||||
func emit(event_name: String, data: Dictionary) -> void
|
||||
|
||||
# 事件监听(对应 socket.on)
|
||||
func add_event_listener(event_name: String, callback: Callable) -> void
|
||||
|
||||
# 内部处理
|
||||
func _process(delta: float) -> void # 轮询 WebSocket 消息
|
||||
```
|
||||
|
||||
**协议实现要点**:
|
||||
- 后端使用简化版 Socket.IO(纯 JSON,无二进制协议)
|
||||
- 发送消息: `{"type": "login", "token": "..."}` 或 `{"t": "chat", "content": "..."}`
|
||||
- 接收消息: 通过 `"t"` 字段识别事件类型
|
||||
- 所有消息使用 `JSON.stringify()` 序列化
|
||||
|
||||
---
|
||||
|
||||
### 2. WebSocketManager.gd - 连接管理
|
||||
|
||||
**位置**: `_Core/managers/WebSocketManager.gd`
|
||||
|
||||
**职责**:
|
||||
- 管理连接状态(断开、连接中、已连接、重连中)
|
||||
- 自动重连(指数退避:3s, 6s, 12s, 24s, 30s)
|
||||
- 错误恢复
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name WebSocketManager
|
||||
extends Node
|
||||
|
||||
enum ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
RECONNECTING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
# 信号
|
||||
signal connection_state_changed(new_state: ConnectionState)
|
||||
|
||||
# 连接管理
|
||||
func connect_to_game_server() -> void
|
||||
func disconnect() -> void
|
||||
func is_connected() -> bool
|
||||
|
||||
# 自动重连
|
||||
func enable_auto_reconnect(enabled: bool, max_attempts: int = 5, base_delay: float = 3.0)
|
||||
|
||||
# 访问 Socket.IO 客户端
|
||||
func get_socket_client() -> SocketIOClient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. ChatManager.gd - 业务逻辑核心
|
||||
|
||||
**位置**: `_Core/managers/ChatManager.gd`
|
||||
|
||||
**职责**:
|
||||
- 聊天消息发送/接收协调
|
||||
- 客户端频率限制(10条/分钟)
|
||||
- 消息历史管理(最多100条)
|
||||
- **Signal Up**: 通过信号和 EventSystem 向上通知
|
||||
- 整合 AuthManager 获取 token
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name ChatManager
|
||||
extends Node
|
||||
|
||||
# 信号(Signal Up)
|
||||
signal chat_message_sent(message_id: String, timestamp: float)
|
||||
signal chat_message_received(from_user: String, content: String, show_bubble: bool, timestamp: float)
|
||||
signal chat_error_occurred(error_code: String, message: String)
|
||||
signal chat_connection_state_changed(state: WebSocketManager.ConnectionState)
|
||||
|
||||
# 聊天操作
|
||||
func send_chat_message(content: String, scope: String = "local") -> void
|
||||
func update_player_position(x: float, y: float, map_id: String) -> void
|
||||
|
||||
# 连接管理
|
||||
func connect_to_chat_server() -> void
|
||||
func disconnect_from_chat_server() -> void
|
||||
|
||||
# 频率限制
|
||||
func can_send_message() -> bool
|
||||
func get_time_until_next_message() -> float
|
||||
|
||||
# 内部事件处理
|
||||
func _on_socket_connected() -> void
|
||||
func _on_socket_event_received(event_name: String, data: Dictionary) -> void
|
||||
func _handle_login_success(data: Dictionary) -> void
|
||||
func _handle_chat_render(data: Dictionary) -> void
|
||||
func _handle_error_response(data: Dictionary) -> void
|
||||
```
|
||||
|
||||
**关键实现**:
|
||||
- **登录流程**: 从 AuthManager 获取 token → 发送 login 消息 → 等待 login_success
|
||||
- **消息发送**: 检查频率限制 → 通过 SocketIOClient 发送 → 记录历史
|
||||
- **消息接收**: 接收 chat_render → 通过 EventSystem 发送事件(Signal Up)
|
||||
|
||||
---
|
||||
|
||||
### 4. EventNames.gd - 事件注册表
|
||||
|
||||
**位置**: [_Core/EventNames.gd](_Core/EventNames.gd)
|
||||
|
||||
**添加内容**:
|
||||
```gdscript
|
||||
# ============================================================================
|
||||
# 聊天事件
|
||||
# ============================================================================
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. ChatUI.tscn & ChatUI.gd - 用户界面
|
||||
|
||||
**位置**: `scenes/ui/ChatUI.tscn` 和 `scenes/ui/ChatUI.gd`
|
||||
|
||||
**UI 结构**:
|
||||
```
|
||||
ChatUI (Control)
|
||||
├── ChatPanel (Panel) - 主容器
|
||||
│ ├── ChatHistory (ScrollContainer) - 消息历史
|
||||
│ │ └── MessageList (VBoxContainer) - 消息列表
|
||||
│ ├── InputContainer (HBoxContainer)
|
||||
│ │ ├── ChatInput (LineEdit) - 输入框
|
||||
│ │ └── SendButton (Button) - 发送按钮
|
||||
│ └── StatusLabel (Label) - 连接状态
|
||||
```
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
# 节点引用
|
||||
@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
|
||||
|
||||
# 生命周期
|
||||
func _ready() -> void:
|
||||
_subscribe_to_events() # Call Down - 订阅 EventSystem
|
||||
|
||||
# UI 事件
|
||||
func _on_send_button_pressed() -> void:
|
||||
var content: String = chat_input.text
|
||||
ChatManager.send_chat_message(content, "local")
|
||||
|
||||
# 订阅事件(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)
|
||||
|
||||
# 事件处理器
|
||||
func _on_chat_message_received(data: Dictionary) -> void:
|
||||
var from_user: String = data["from_user"]
|
||||
var content: String = data["content"]
|
||||
add_message_to_history(from_user, content, data["timestamp"], false)
|
||||
|
||||
func add_message_to_history(from_user: String, content: String, timestamp: float, is_self: bool) -> void:
|
||||
var message_node: ChatMessage = chat_message_scene.instantiate()
|
||||
message_list.add_child(message_node)
|
||||
message_node.set_message(from_user, content, timestamp, is_self)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. ChatMessage.tscn & ChatMessage.gd - 消息气泡
|
||||
|
||||
**位置**: `scenes/prefabs/ui/ChatMessage.tscn`
|
||||
|
||||
**UI 结构**:
|
||||
```
|
||||
ChatMessage (Panel)
|
||||
├── UserInfo (HBoxContainer)
|
||||
│ ├── UsernameLabel (Label)
|
||||
│ └── TimestampLabel (Label)
|
||||
└── ContentLabel (RichTextLabel)
|
||||
```
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name ChatMessage
|
||||
extends Panel
|
||||
|
||||
@export var max_width: int = 400
|
||||
|
||||
@onready var username_label: Label = %UsernameLabel
|
||||
@onready var timestamp_label: Label = %TimestampLabel
|
||||
@onready var content_label: RichTextLabel = %ContentLabel
|
||||
|
||||
func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void:
|
||||
username_label.text = from_user
|
||||
content_label.text = content
|
||||
# 格式化时间戳和样式
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 数据流与事件通信
|
||||
|
||||
### 发送消息流程
|
||||
|
||||
```
|
||||
用户点击发送按钮
|
||||
↓
|
||||
ChatUI._on_send_button_pressed()
|
||||
↓
|
||||
ChatManager.send_chat_message(content, "local")
|
||||
↓
|
||||
检查频率限制
|
||||
↓
|
||||
SocketIOClient.emit("chat", {t: "chat", content: "...", scope: "local"})
|
||||
↓
|
||||
WebSocketPeer.put_packet(json_bytes)
|
||||
↓
|
||||
服务器响应 chat_sent
|
||||
↓
|
||||
ChatManager._handle_chat_sent()
|
||||
↓
|
||||
EventSystem.emit_event(CHAT_MESSAGE_SENT, data) ← Signal Up
|
||||
↓
|
||||
ChatUI 可以订阅此事件更新 UI
|
||||
```
|
||||
|
||||
### 接收消息流程
|
||||
|
||||
```
|
||||
WebSocketPeer 接收数据
|
||||
↓
|
||||
SocketIOClient._process() 轮询
|
||||
↓
|
||||
解析 JSON,提取 "t" 字段(事件类型)
|
||||
↓
|
||||
event_received.emit("chat_render", data)
|
||||
↓
|
||||
ChatManager._on_socket_event_received()
|
||||
↓
|
||||
_handle_chat_render(data)
|
||||
↓
|
||||
EventSystem.emit_event(CHAT_MESSAGE_RECEIVED, data) ← Signal Up
|
||||
↓
|
||||
ChatUI._on_chat_message_received(data) ← Call Down via EventSystem
|
||||
↓
|
||||
创建 ChatMessage 节点并添加到 UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 错误处理策略
|
||||
|
||||
### 错误码映射(在 ChatManager.gd 中实现)
|
||||
|
||||
```gdscript
|
||||
const CHAT_ERROR_MESSAGES: Dictionary = {
|
||||
"AUTH_FAILED": "聊天认证失败,请重新登录",
|
||||
"RATE_LIMIT": "消息发送过于频繁,请稍后再试",
|
||||
"CONTENT_FILTERED": "消息内容包含违规内容",
|
||||
"CONTENT_TOO_LONG": "消息内容过长(最大1000字符)",
|
||||
"PERMISSION_DENIED": "您没有权限发送消息",
|
||||
"SESSION_EXPIRED": "会话已过期,请重新连接",
|
||||
"ZULIP_ERROR": "消息服务暂时不可用",
|
||||
"INTERNAL_ERROR": "服务器内部错误"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理流程
|
||||
|
||||
```
|
||||
服务器返回 error
|
||||
↓
|
||||
ChatManager._handle_error_response(data)
|
||||
↓
|
||||
提取 error_code 和 message
|
||||
↓
|
||||
映射为用户友好的错误消息
|
||||
↓
|
||||
EventSystem.emit_event(CHAT_ERROR_OCCURRED, {...}) ← Signal Up
|
||||
↓
|
||||
ChatUI._on_chat_error(data) ← Call Down
|
||||
↓
|
||||
显示错误提示(Toast 或 Label)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置与常量
|
||||
|
||||
### ChatManager.gd 常量
|
||||
|
||||
```gdscript
|
||||
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_MESSAGE_HISTORY: int = 100
|
||||
```
|
||||
|
||||
### project.godot 自动加载
|
||||
|
||||
```ini
|
||||
[autoload]
|
||||
ChatManager="*res://_Core/managers/ChatManager.gd"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 集成点
|
||||
|
||||
### 1. AuthManager 集成
|
||||
|
||||
**需求**: ChatManager 需要获取 access_token 用于 WebSocket 聊天认证
|
||||
|
||||
**解决方案**: AuthManager 在登录成功后自动提取并保存 access_token 和 refresh_token
|
||||
|
||||
**Token 管理架构**:
|
||||
```gdscript
|
||||
# 内存存储(快速访问)
|
||||
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时间戳)
|
||||
|
||||
# 本地存储(ConfigFile持久化)
|
||||
const AUTH_CONFIG_PATH: String = "user://auth.cfg"
|
||||
```
|
||||
|
||||
**登录流程**:
|
||||
1. 用户登录成功后,服务器返回 `access_token` 和 `refresh_token`
|
||||
2. AuthManager 调用 `_save_tokens_to_memory(data)` 保存到内存
|
||||
3. AuthManager 调用 `_save_tokens_to_local(data)` 保存到本地ConfigFile
|
||||
4. AuthScene 在登录成功后调用 `ChatManager.set_game_token(token)` 设置token
|
||||
|
||||
**Token 存储内容**:
|
||||
- **内存**: access_token, refresh_token, user_info, token_expiry
|
||||
- **本地 (user://auth.cfg)**: refresh_token, user_id, username, saved_at
|
||||
|
||||
**注意事项**:
|
||||
- `access_token` 仅保存在内存中,不存储到本地(安全考虑)
|
||||
- `refresh_token` 加密存储到本地,用于下次登录时自动刷新token
|
||||
- Token 过期后需要使用 refresh_token 刷新
|
||||
|
||||
### 2. EventSystem 集成
|
||||
|
||||
**ChatManager 发送事件**(Signal Up):
|
||||
```gdscript
|
||||
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||||
"from_user": from_user,
|
||||
"content": content,
|
||||
"show_bubble": show_bubble,
|
||||
"timestamp": timestamp
|
||||
})
|
||||
```
|
||||
|
||||
**ChatUI 订阅事件**(Call Down):
|
||||
```gdscript
|
||||
EventSystem.connect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
```
|
||||
|
||||
### 3. 自动连接时机
|
||||
|
||||
在游戏进入主场景时自动连接聊天:
|
||||
```gdscript
|
||||
# MainScene.gd 或 GameManager.gd
|
||||
func _ready():
|
||||
ChatManager.connect_to_chat_server()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 API 规范(来自 api.md)
|
||||
|
||||
### 消息类型
|
||||
|
||||
#### 1. 登录
|
||||
```json
|
||||
// 发送
|
||||
// token 字段应该使用登录接口返回的 access_token
|
||||
{"type": "login", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
|
||||
|
||||
// 成功响应
|
||||
{"t": "login_success", "sessionId": "...", "currentMap": "...", "username": "..."}
|
||||
|
||||
// 失败响应
|
||||
{"t": "error", "code": "AUTH_FAILED", "message": "..."}
|
||||
```
|
||||
|
||||
**Token 来源**:
|
||||
- 登录接口 (`/auth/login`) 返回 `access_token` (JWT访问令牌)
|
||||
- AuthManager 在登录成功后保存 access_token 到内存
|
||||
- AuthScene 在登录成功后设置 token 给 ChatManager
|
||||
- ChatManager 使用该 token 发送 WebSocket 登录消息
|
||||
|
||||
#### 2. 发送聊天
|
||||
```json
|
||||
// 发送
|
||||
{"t": "chat", "content": "Hello", "scope": "local"}
|
||||
|
||||
// 成功响应
|
||||
{"t": "chat_sent", "messageId": "...", "timestamp": 1703500800000}
|
||||
```
|
||||
|
||||
#### 3. 接收聊天
|
||||
```json
|
||||
// 服务器推送
|
||||
{"t": "chat_render", "from": "other_player", "txt": "Hi!", "bubble": true, "timestamp": 1703500800000}
|
||||
```
|
||||
|
||||
#### 4. 位置更新
|
||||
```json
|
||||
// 发送
|
||||
{"t": "position", "x": 150, "y": 200, "mapId": "novice_village"}
|
||||
|
||||
// 响应
|
||||
{"t": "position_updated", "stream": "Novice Village", "topic": "General"}
|
||||
```
|
||||
|
||||
#### 5. 登出
|
||||
```json
|
||||
// 发送
|
||||
{"type": "logout"}
|
||||
|
||||
// 响应
|
||||
{"t": "logout_success"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
**test_socketio_client.gd**:
|
||||
- 测试消息格式化(JSON 序列化)
|
||||
- 测试事件监听器注册
|
||||
- 测试连接状态管理
|
||||
|
||||
**test_chat_manager.gd**:
|
||||
- 测试消息发送流程
|
||||
- 测试频率限制(10条/分钟)
|
||||
- 测试消息历史管理(最多100条)
|
||||
|
||||
### 集成测试
|
||||
|
||||
**test_chat_integration.gd**:
|
||||
- 测试完整的连接 → 登录 → 发送消息 → 接收消息流程
|
||||
- 测试自动重连机制
|
||||
- 测试错误处理流程
|
||||
|
||||
### 手动测试清单
|
||||
|
||||
- [ ] 成功连接到游戏服务器
|
||||
- [ ] 使用有效 token 登录成功
|
||||
- [ ] 发送聊天消息成功
|
||||
- [ ] 接收到其他玩家消息
|
||||
- [ ] 位置更新发送成功
|
||||
- [ ] 频率限制生效(10条/分钟)
|
||||
- [ ] 连接状态在 UI 正确显示
|
||||
- [ ] 断线后自动重连成功
|
||||
- [ ] 错误消息正确显示
|
||||
- [ ] 消息历史正确显示
|
||||
|
||||
---
|
||||
|
||||
## 📅 实施顺序
|
||||
|
||||
### 阶段 1: 基础设施(第1-2步)
|
||||
1. 创建 `_Core/systems/SocketIOClient.gd` - WebSocket 协议封装
|
||||
2. 创建 `_Core/managers/WebSocketManager.gd` - 连接管理
|
||||
3. 测试与后端的 WebSocket 连接
|
||||
|
||||
### 阶段 2: 业务逻辑(第3-4步)
|
||||
4. 创建 `_Core/managers/ChatManager.gd` - 聊天管理器
|
||||
5. 实现登录流程(从 AuthManager 获取 token)
|
||||
6. 实现消息发送/接收逻辑
|
||||
7. 添加频率限制和错误处理
|
||||
|
||||
### 阶段 3: 用户界面(第5-6步)
|
||||
8. 创建 `scenes/prefabs/ui/ChatMessage.tscn` - 消息气泡
|
||||
9. 创建 `scenes/ui/ChatUI.tscn` - 聊天界面
|
||||
10. 实现 `ChatUI.gd` - 事件订阅和 UI 交互
|
||||
|
||||
### 阶段 4: 集成(第7步)
|
||||
11. 更新 `_Core/EventNames.gd` - 添加聊天事件
|
||||
12. 更新 `project.godot` - 添加 ChatManager 到自动加载
|
||||
13. 集成 AuthManager(添加 get_game_token 方法)
|
||||
14. 在主场景中初始化聊天连接
|
||||
|
||||
### 阶段 5: 测试与优化(第8-9步)
|
||||
15. 编写单元测试
|
||||
16. 编写集成测试
|
||||
17. 手动测试清单验证
|
||||
18. 性能优化(消息历史限制、UI 更新优化)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键注意事项
|
||||
|
||||
1. **Token 获取**: 需要确认 `/auth/login` 返回的 token 是否就是 WebSocket 登录需要的 token
|
||||
2. **协议简化**: 后端使用简化版 Socket.IO(纯 JSON),不需要实现完整的 Socket.IO 二进制协议
|
||||
3. **频率限制**: 客户端和服务器都会限制,客户端检查是为了更好的用户体验
|
||||
4. **消息历史**: 限制在内存中保存最多 100 条消息,避免内存泄漏
|
||||
5. **UI 更新**: 使用 `@onready` 缓存节点引用,避免在 `_process` 中使用 `get_node()`
|
||||
6. **类型安全**: 所有变量必须使用严格类型标注(`var name: String = ""`)
|
||||
7. **Signal Up, Call Down**: ChatManager 通过 EventSystem 发送事件,ChatUI 通过 EventSystem 订阅事件
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
- [api.md](api.md) - Zulip 集成 API 文档
|
||||
- [test_zulip.js](test_zulip.js) - 后端测试客户端(Node.js + Socket.IO)
|
||||
- [_Core/EventNames.gd](_Core/EventNames.gd) - 事件名称常量
|
||||
- [_Core/systems/EventSystem.gd](_Core/systems/EventSystem.gd) - 事件系统实现
|
||||
- [_Core/managers/NetworkManager.gd](_Core/managers/NetworkManager.gd) - HTTP 请求管理器(参考模式)
|
||||
- [scenes/ui/AuthScene.gd](scenes/ui/AuthScene.gd) - UI 控制器参考模式
|
||||
|
||||
---
|
||||
|
||||
## ✅ 实施进度(2025-01-06)
|
||||
|
||||
### 已完成 ✅
|
||||
|
||||
#### 阶段 1: 基础设施 ✅
|
||||
- [x] **SocketIOClient.gd** - Socket.IO 协议封装(284 行)
|
||||
- WebSocket 连接管理
|
||||
- 消息发送/接收(JSON 格式)
|
||||
- 事件监听器系统
|
||||
- 连接状态管理
|
||||
|
||||
- [x] **WebSocketManager.gd** - 连接生命周期管理(329 行)
|
||||
- 连接状态枚举(DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING, ERROR)
|
||||
- 自动重连机制(指数退避:3s → 6s → 12s → 24s → 30s)
|
||||
- 错误恢复逻辑
|
||||
|
||||
#### 阶段 2: 业务逻辑 ✅
|
||||
- [x] **ChatManager.gd** - 聊天业务逻辑核心(641 行)
|
||||
- Token 管理(set_game_token / get_game_token)
|
||||
- 消息发送/接收协调
|
||||
- 客户端频率限制(10条/分钟)
|
||||
- **会话与历史分离**:
|
||||
- 当前会话缓存:最多 100 条消息(内存中,性能优化)
|
||||
- 历史消息:存储在 Zulip 后端,按需加载(每次 100 条)
|
||||
- 会话重置:每次登录/重连时清空缓存,重新接收消息
|
||||
- 会话管理方法:
|
||||
- `reset_session()` - 清空当前会话缓存
|
||||
- `load_history(count)` - 从 Zulip 加载历史消息
|
||||
- `_on_history_loaded(messages)` - 历史消息加载完成回调
|
||||
- **Signal Up**: 通过 EventSystem 发送事件
|
||||
- 错误处理和映射
|
||||
|
||||
- [x] **EventNames.gd** - 添加 7 个聊天事件常量
|
||||
```gdscript
|
||||
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"
|
||||
```
|
||||
|
||||
- [x] **project.godot** - 添加 ChatManager 到自动加载
|
||||
```ini
|
||||
ChatManager="*res://_Core/managers/ChatManager.gd"
|
||||
```
|
||||
|
||||
- [x] **AuthManager.gd** - Token 管理集成
|
||||
- 添加 `_game_token: String` 成员变量
|
||||
- 添加 `set_game_token()` 方法
|
||||
- 添加 `get_game_token()` 方法
|
||||
|
||||
#### 阶段 3: 用户界面 ✅
|
||||
- [x] **ChatMessage.tscn & ChatMessage.gd** - 消息气泡组件(185 行)
|
||||
- 区分自己/他人消息样式(不同背景色和对齐)
|
||||
- 自动格式化时间戳(HH:MM)
|
||||
- 响应式布局(最大宽度 400px)
|
||||
|
||||
- [x] **ChatUI.tscn & ChatUI.gd** - 聊天界面(279 行脚本 + 场景)
|
||||
- 消息历史显示(ScrollContainer)
|
||||
- 输入框和发送按钮
|
||||
- 连接状态显示
|
||||
- 最小化/最大化功能
|
||||
- **Call Down**: 通过 EventSystem 订阅事件
|
||||
|
||||
#### 阶段 4: 测试 ✅ (MANDATORY)
|
||||
- [x] **test_socketio_client.gd** - SocketIOClient 单元测试(361 行,42 个测试用例)
|
||||
- 初始化测试
|
||||
- 连接状态管理测试
|
||||
- 事件监听器管理测试
|
||||
- JSON 序列化测试(含 Unicode)
|
||||
- 信号测试
|
||||
- 边界条件测试
|
||||
|
||||
- [x] **test_websocket_manager.gd** - WebSocketManager 单元测试(331 行,38 个测试用例)
|
||||
- 初始化测试
|
||||
- 连接状态管理测试
|
||||
- 自动重连机制测试
|
||||
- 重连延迟计算测试(指数退避)
|
||||
- Socket.IO 客户端访问测试
|
||||
- 常量测试
|
||||
- 状态转换测试
|
||||
|
||||
- [x] **test_chat_manager.gd** - ChatManager 单元测试(432 行,48 个测试用例)
|
||||
- 初始化测试
|
||||
- Token 管理测试
|
||||
- 频率限制测试(10条/分钟)
|
||||
- 消息历史管理测试(最多100条)
|
||||
- 错误处理测试
|
||||
- 信号测试
|
||||
- 边界条件测试(空消息、超长消息、Unicode)
|
||||
|
||||
**测试覆盖统计**:
|
||||
- 总测试文件: 3 个
|
||||
- 总测试用例: 128 个
|
||||
- 测试代码行数: 1,124 行
|
||||
|
||||
---
|
||||
|
||||
### 待完成 ⚠️
|
||||
|
||||
#### 集成工作
|
||||
- [ ] **主场景集成**
|
||||
- 在 MainScene.gd 或 GameManager.gd 中添加 `ChatManager.connect_to_chat_server()`
|
||||
- 在用户登录成功后设置 token:`ChatManager.set_game_token(token)`
|
||||
- 添加聊天 UI 到游戏界面
|
||||
|
||||
- [ ] **Token 获取流程**
|
||||
- 需要确认 `/auth/login` 返回的数据中是否包含 token
|
||||
- 如果包含,在登录成功回调中保存并设置到 ChatManager
|
||||
- 如果不包含,需要单独获取游戏 token 的接口
|
||||
|
||||
#### 测试与验证
|
||||
- [ ] **手动测试**
|
||||
- [ ] 成功连接到游戏服务器
|
||||
- [ ] 使用有效 token 登录成功
|
||||
- [ ] 发送聊天消息成功
|
||||
- [ ] 接收到其他玩家消息
|
||||
- [ ] 位置更新发送成功
|
||||
- [ ] 频率限制生效(10条/分钟)
|
||||
- [ ] 连接状态在 UI 正确显示
|
||||
- [ ] 断线后自动重连成功
|
||||
- [ ] 错误消息正确显示
|
||||
- [ ] 消息历史正确显示
|
||||
|
||||
- [ ] **运行单元测试**
|
||||
```bash
|
||||
godot --headless -s addons/gut/gut_cmdline.gd -gdir=res://tests/unit -ginclude_subdirs
|
||||
```
|
||||
|
||||
#### 功能增强(可选)
|
||||
- [ ] **世界内聊天气泡**
|
||||
- 在玩家头顶显示聊天气泡
|
||||
- 根据 `bubble` 字段决定是否显示
|
||||
- 自动消失机制(3-5 秒)
|
||||
|
||||
- [ ] **聊天命令系统**
|
||||
- 支持 `/help`, `/whisper`, `/invite` 等命令
|
||||
- 命令解析和执行
|
||||
|
||||
- [ ] **聊天历史持久化**
|
||||
- 保存到本地存储
|
||||
- 重启后恢复聊天记录
|
||||
|
||||
- [ ] **消息搜索功能**
|
||||
- 在聊天历史中搜索关键字
|
||||
|
||||
---
|
||||
|
||||
### 使用指南
|
||||
|
||||
#### 基本使用流程
|
||||
|
||||
```gdscript
|
||||
# 1. 用户登录成功后,设置 token
|
||||
func _on_login_success(token: String, username: String):
|
||||
# 设置游戏 token
|
||||
ChatManager.set_game_token(token)
|
||||
|
||||
# 连接到聊天服务器
|
||||
ChatManager.connect_to_chat_server()
|
||||
|
||||
# 2. 显示聊天界面
|
||||
func _show_chat_ui():
|
||||
var chat_ui := preload("res://scenes/ui/ChatUI.tscn").instantiate()
|
||||
add_child(chat_ui)
|
||||
|
||||
# 3. 订阅聊天事件(可选)
|
||||
func _subscribe_to_chat_events():
|
||||
EventSystem.connect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_message_received)
|
||||
EventSystem.connect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error)
|
||||
|
||||
func _on_message_received(data: Dictionary):
|
||||
print("收到消息: ", data["from_user"], " -> ", data["content"])
|
||||
|
||||
func _on_chat_error(data: Dictionary):
|
||||
print("聊天错误: ", data["message"])
|
||||
|
||||
# 4. 发送消息(通过 UI 或代码)
|
||||
func send_test_message():
|
||||
ChatManager.send_chat_message("Hello, world!", "local")
|
||||
|
||||
# 5. 更新玩家位置(可选,用于切换地图聊天频道)
|
||||
func _on_player_moved_to_new_map(position: Vector2, map_id: String):
|
||||
ChatManager.update_player_position(position.x, position.y, map_id)
|
||||
```
|
||||
|
||||
#### Token 配置说明
|
||||
|
||||
根据 [test_zulip.js](test_zulip.js) 的测试代码,游戏 token 就是用户的 Zulip API Key。
|
||||
|
||||
**测试 token**(来自 test_zulip.js):
|
||||
```
|
||||
Ke8BYpbWBUhRrkCUW8kGlnhAWE3jBauf
|
||||
```
|
||||
|
||||
**测试方法**:
|
||||
```gdscript
|
||||
# 在开发阶段,可以手动设置测试 token
|
||||
func _ready():
|
||||
if OS.has_feature("editor"):
|
||||
# 编辑器模式下使用测试 token
|
||||
ChatManager.set_game_token("Ke8BYpbWBUhRrkCUW8kGlnhAWE3jBauf")
|
||||
ChatManager.connect_to_chat_server()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 文件清单
|
||||
|
||||
#### 核心文件(9 个)
|
||||
- `_Core/systems/SocketIOClient.gd` - 284 行
|
||||
- `_Core/managers/WebSocketManager.gd` - 329 行
|
||||
- `_Core/managers/ChatManager.gd` - 643 行(含会话/历史分离)
|
||||
- `_Core/managers/AuthManager.gd` - 修改(添加 token 管理)
|
||||
- `_Core/EventNames.gd` - 修改(添加 7 个常量)
|
||||
- `scenes/prefabs/ui/ChatMessage.tscn` - 场景文件
|
||||
- `scenes/prefabs/ui/ChatMessage.gd` - 185 行
|
||||
- `scenes/ui/ChatUI.tscn` - 场景文件
|
||||
- `scenes/ui/ChatUI.gd` - 279 行
|
||||
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-01-08
|
||||
**实施状态**: ✅ 核心功能完成,所有编译错误已修复
|
||||
**测试覆盖**: ✅ 100% (128 个测试用例)
|
||||
**当前状态**: ⏸️ 等待后端修复 Zulip 集成问题
|
||||
|
||||
## 🎯 已完成的工作
|
||||
|
||||
### 核心功能
|
||||
- ✅ JWT Token 管理系统(access_token + refresh_token)
|
||||
- ✅ Socket.IO 协议封装(WebSocket + JSON)
|
||||
- ✅ 聊天管理器(消息发送/接收、频率限制)
|
||||
- ✅ 会话与历史分离架构
|
||||
- ✅ 用户界面(ChatUI + 消息气泡)
|
||||
- ✅ 主场景集成(自动连接聊天)
|
||||
|
||||
### 代码质量
|
||||
- ✅ 所有编译错误已修复
|
||||
- ✅ 符合项目规范(类型安全、命名规范)
|
||||
- ✅ 单元测试覆盖 100%
|
||||
|
||||
### 待后端解决的问题
|
||||
- ⚠️ Zulip 用户创建失败(需要预创建组织或禁用集成)
|
||||
@@ -1,4 +1,4 @@
|
||||
extends Panel
|
||||
extends PanelContainer
|
||||
|
||||
# ============================================================================
|
||||
# ChatMessage.gd - 聊天消息气泡组件
|
||||
@@ -33,16 +33,41 @@ class_name ChatMessage
|
||||
# ============================================================================
|
||||
|
||||
# 用户名标签
|
||||
@onready var username_label: Label = %UsernameLabel
|
||||
var username_label: Label
|
||||
|
||||
# 时间戳标签
|
||||
@onready var timestamp_label: Label = %TimestampLabel
|
||||
var timestamp_label: Label
|
||||
|
||||
# 内容标签
|
||||
@onready var content_label: RichTextLabel = %ContentLabel
|
||||
var content_label: RichTextLabel
|
||||
|
||||
# 用户信息容器
|
||||
@onready var user_info_container: HBoxContainer = %UserInfoContainer
|
||||
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
|
||||
|
||||
# ============================================================================
|
||||
# 成员变量
|
||||
@@ -51,15 +76,6 @@ class_name ChatMessage
|
||||
# 是否为自己发送的消息
|
||||
var _is_self: bool = false
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
# ============================================================================
|
||||
|
||||
# 准备就绪
|
||||
func _ready() -> void:
|
||||
# 应用最大宽度限制
|
||||
custom_minimum_size.x = min(max_width, get_parent().size.x)
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API
|
||||
# ============================================================================
|
||||
@@ -76,15 +92,33 @@ func _ready() -> void:
|
||||
# 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()
|
||||
|
||||
# 设置用户名
|
||||
username_label.text = from_user
|
||||
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
|
||||
|
||||
# 设置内容
|
||||
content_label.text = content
|
||||
if content_label:
|
||||
content_label.clear() # 清除默认文本和所有内容
|
||||
content_label.append_text(content) # 作为纯文本追加,避免 BBCode 解析导致内容不显示
|
||||
else:
|
||||
push_error("ChatMessage: content_label is null!")
|
||||
return
|
||||
|
||||
# 设置时间戳
|
||||
timestamp_label.text = _format_timestamp(timestamp)
|
||||
if timestamp_label:
|
||||
timestamp_label.text = _format_timestamp(timestamp)
|
||||
else:
|
||||
push_error("ChatMessage: timestamp_label is null!")
|
||||
return
|
||||
|
||||
# 应用样式
|
||||
_apply_style()
|
||||
@@ -93,8 +127,14 @@ func set_message(from_user: String, content: String, timestamp: float, is_self:
|
||||
# 内部方法 - 样式处理
|
||||
# ============================================================================
|
||||
|
||||
# 应用样式(自己和他人的消息不同)
|
||||
# 应用样式(自己和他人的消息不同)
|
||||
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
|
||||
@@ -106,6 +146,8 @@ func _apply_style() -> void:
|
||||
# 设置文字颜色 - 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
|
||||
@@ -117,11 +159,13 @@ func _apply_style() -> void:
|
||||
# 设置文字颜色 - 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.3)
|
||||
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
|
||||
@@ -135,7 +179,7 @@ func _get_self_style() -> StyleBoxFlat:
|
||||
# 获取他人消息的样式
|
||||
func _get_other_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.9, 0.9, 0.9, 0.5)
|
||||
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
|
||||
@@ -165,3 +209,11 @@ func _format_timestamp(timestamp: float) -> String:
|
||||
|
||||
# 格式化为 HH:MM
|
||||
return "%02d:%02d" % [datetime.hour, datetime.minute]
|
||||
|
||||
# 获取所有子节点名称(调试用)
|
||||
func _get_all_children_names(node: Node, _indent: int = 0) -> String:
|
||||
var result := ""
|
||||
for child in node.get_children():
|
||||
result += " ".repeat(_indent) + child.name + "\n"
|
||||
result += _get_all_children_names(child, _indent + 1)
|
||||
return result
|
||||
|
||||
@@ -2,44 +2,35 @@
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/prefabs/ui/ChatMessage.gd" id="1"]
|
||||
|
||||
[node name="ChatMessage" type="Panel"]
|
||||
[node name="ChatMessage" type="PanelContainer"]
|
||||
layout_mode = 2
|
||||
offset_right = 400.0
|
||||
offset_bottom = 100.0
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 6
|
||||
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
|
||||
layout_mode = 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
|
||||
|
||||
@@ -36,10 +36,10 @@ signal login_success(username: String)
|
||||
@onready var register_panel: Panel = $CenterContainer/RegisterPanel # 注册面板
|
||||
@onready var title_label: Label = $CenterContainer/LoginPanel/VBoxContainer/TitleLabel # 标题标签
|
||||
@onready var subtitle_label: Label = $CenterContainer/LoginPanel/VBoxContainer/SubtitleLabel # 副标题标签
|
||||
@onready var whale_frame: TextureRect = $WhaleFrame # 鲸鱼装饰框
|
||||
@onready var whale_frame: TextureRect = $CenterContainer/WhaleFrame # 鲸鱼装饰框
|
||||
|
||||
# 登录表单输入控件
|
||||
@onready var login_username: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameContainer/UsernameInput
|
||||
@onready var login_username: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameInput
|
||||
@onready var login_password: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/PasswordContainer/PasswordInput
|
||||
@onready var login_verification: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/VerificationContainer/VerificationInputContainer/VerificationInput
|
||||
|
||||
|
||||
@@ -272,8 +272,12 @@ func _on_chat_message_received(data: Dictionary) -> void:
|
||||
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, false)
|
||||
add_message_to_history(from_user, content, timestamp, is_self)
|
||||
|
||||
# 处理聊天错误
|
||||
func _on_chat_error(data: Dictionary) -> void:
|
||||
@@ -307,15 +311,23 @@ func add_message_to_history(from_user: String, content: String, timestamp: float
|
||||
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)
|
||||
|
||||
# 添加到列表
|
||||
message_list.add_child(message_node)
|
||||
|
||||
# 自动滚动到底部
|
||||
call_deferred("_scroll_to_bottom")
|
||||
|
||||
|
||||
@@ -61,12 +61,13 @@ theme_override_constants/separation = 8
|
||||
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 = 3
|
||||
size_flags_vertical = 1
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TextureRect2" type="TextureRect" parent="ChatPanel"]
|
||||
|
||||
Reference in New Issue
Block a user