This commit is contained in:
WhaleTown Developer
2026-01-20 20:11:07 +08:00
parent e989b4adf1
commit 603e7d9fc6
4 changed files with 154 additions and 43 deletions

View File

@@ -122,6 +122,10 @@ var _current_map: String = ""
# 游戏 token # 游戏 token
var _game_token: String = "" 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 字符串) # 发送消息JSON 字符串)
var json_string := JSON.stringify(message_data) 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() _record_message_timestamp()
@@ -253,6 +260,24 @@ func send_chat_message(content: String, scope: String = "local") -> void:
"is_self": true "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) print("📤 发送聊天消息: ", content)
# 消息发送完成回调 # 消息发送完成回调
@@ -427,6 +452,7 @@ func _on_history_loaded(messages: Array) -> void:
"content": message.get("content", ""), "content": message.get("content", ""),
"show_bubble": false, "show_bubble": false,
"timestamp": message.get("timestamp", 0.0), "timestamp": message.get("timestamp", 0.0),
"is_self": (not _current_username.is_empty() and message.get("from_user", "") == _current_username),
"is_history": true # 标记为历史消息 "is_history": true # 标记为历史消息
}) })
@@ -504,6 +530,8 @@ func _on_data_received(message: String) -> void:
_handle_login_success(data) _handle_login_success(data)
"login_error": "login_error":
_handle_login_error(data) _handle_login_error(data)
"chat":
_handle_chat_render(data)
"chat_sent": "chat_sent":
_handle_chat_sent(data) _handle_chat_sent(data)
"chat_error": "chat_error":
@@ -580,10 +608,24 @@ func _handle_chat_error(data: Dictionary) -> void:
# 处理接收到的聊天消息 # 处理接收到的聊天消息
func _handle_chat_render(data: Dictionary) -> void: func _handle_chat_render(data: Dictionary) -> void:
var from_user: String = data.get("from", "") # 兼容不同后端字段命名:
var content: String = data.get("txt", "") # - chat_render: {from, txt, bubble, timestamp}
var show_bubble: bool = data.get("bubble", false) # - chat: {content, scope, (可选 from/username/timestamp)}
var timestamp: float = float(data.get("timestamp", "0.0")) 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) print("📨 收到聊天消息: ", from_user, " -> ", content)
@@ -592,7 +634,7 @@ func _handle_chat_render(data: Dictionary) -> void:
"from_user": from_user, "from_user": from_user,
"content": content, "content": content,
"timestamp": timestamp, "timestamp": timestamp,
"is_self": false "is_self": is_self
}) })
# 发射信号 # 发射信号
@@ -603,9 +645,44 @@ func _handle_chat_render(data: Dictionary) -> void:
"from_user": from_user, "from_user": from_user,
"content": content, "content": content,
"show_bubble": show_bubble, "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: func _handle_position_updated(data: Dictionary) -> void:
var stream: String = data.get("stream", "") var stream: String = data.get("stream", "")
@@ -665,6 +742,24 @@ func _record_message_timestamp() -> void:
var current_time := Time.get_unix_time_from_system() var current_time := Time.get_unix_time_from_system()
_message_timestamps.append(current_time) _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: func _add_message_to_history(message: Dictionary) -> void:
_message_history.append(message) _message_history.append(message)

View File

@@ -1,4 +1,4 @@
extends Panel extends PanelContainer
# ============================================================================ # ============================================================================
# ChatMessage.gd - 聊天消息气泡组件 # ChatMessage.gd - 聊天消息气泡组件
@@ -49,22 +49,25 @@ var user_info_container: HBoxContainer
# ============================================================================ # ============================================================================
func _ready() -> void: func _ready() -> void:
# 使用 get_node 获取节点引用 _cache_node_refs()
username_label = get_node_or_null("VBoxContainer/UserInfoContainer/UsernameLabel")
timestamp_label = get_node_or_null("VBoxContainer/UserInfoContainer/TimestampLabel") func _cache_node_refs() -> void:
content_label = get_node_or_null("VBoxContainer/ContentLabel") if not username_label:
user_info_container = get_node_or_null("VBoxContainer/UserInfoContainer") username_label = get_node_or_null("VBoxContainer/UserInfoContainer/UsernameLabel")
if not timestamp_label:
# 调试输出 timestamp_label = get_node_or_null("VBoxContainer/UserInfoContainer/TimestampLabel")
if username_label: if not content_label:
print("✅ UsernameLabel found") content_label = get_node_or_null("VBoxContainer/ContentLabel")
else: if not user_info_container:
print("❌ UsernameLabel NOT found!") user_info_container = get_node_or_null("VBoxContainer/UserInfoContainer")
# 打印所有子节点帮助调试
print("Available children: ", _get_all_children_names(self)) # 内容换行与自适应高度
if content_label:
# 应用最大宽度限制 content_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
custom_minimum_size.x = min(max_width, get_parent().size.x) 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
# ============================================================================ # ============================================================================
# 成员变量 # 成员变量
@@ -89,10 +92,15 @@ var _is_self: bool = false
# message.set_message("Alice", "Hello!", 1703500800.0, false) # message.set_message("Alice", "Hello!", 1703500800.0, false)
func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void: func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void:
_is_self = is_self _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: if username_label:
username_label.text = from_user username_label.text = safe_from_user
else: else:
push_error("ChatMessage: username_label is null!") push_error("ChatMessage: username_label is null!")
return return
@@ -100,7 +108,7 @@ func set_message(from_user: String, content: String, timestamp: float, is_self:
# 设置内容 # 设置内容
if content_label: if content_label:
content_label.clear() # 清除默认文本和所有内容 content_label.clear() # 清除默认文本和所有内容
content_label.bbcode_text = content # 使用 bbcode_text 因为 bbcode_enabled = true content_label.append_text(content) # 作为纯文本追加,避免 BBCode 解析导致内容不显示
else: else:
push_error("ChatMessage: content_label is null!") push_error("ChatMessage: content_label is null!")
return return
@@ -124,10 +132,6 @@ func _apply_style() -> void:
if not username_label or not timestamp_label or not user_info_container: if not username_label or not timestamp_label or not user_info_container:
return return
# 设置大小约束 - 宽度固定,高度适应内容
if get_parent():
custom_minimum_size.x = min(max_width, get_parent().size.x)
# 重要:设置垂直 size flags 让 Panel 适应内容高度 # 重要:设置垂直 size flags 让 Panel 适应内容高度
size_flags_vertical = Control.SIZE_SHRINK_BEGIN size_flags_vertical = Control.SIZE_SHRINK_BEGIN
@@ -142,6 +146,8 @@ func _apply_style() -> void:
# 设置文字颜色 - ID使用金色 #FFD700 # 设置文字颜色 - ID使用金色 #FFD700
username_label.add_theme_color_override("font_color", Color(1.0, 0.8431373, 0.0)) 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)) 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: else:
# 他人的消息:左侧对齐,灰色背景 # 他人的消息:左侧对齐,灰色背景
size_flags_horizontal = Control.SIZE_SHRINK_BEGIN size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
@@ -153,11 +159,13 @@ func _apply_style() -> void:
# 设置文字颜色 - ID使用蓝色 #69c0ff # 设置文字颜色 - ID使用蓝色 #69c0ff
username_label.add_theme_color_override("font_color", Color(0.4117647, 0.7529412, 1.0)) 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)) 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: func _get_self_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new() 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_left = 10
style.corner_radius_top_right = 10 style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10 style.corner_radius_bottom_left = 10
@@ -171,7 +179,7 @@ func _get_self_style() -> StyleBoxFlat:
# 获取他人消息的样式 # 获取他人消息的样式
func _get_other_style() -> StyleBoxFlat: func _get_other_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new() 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_left = 10
style.corner_radius_top_right = 10 style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 2 style.corner_radius_bottom_left = 2

View File

@@ -2,19 +2,15 @@
[ext_resource type="Script" path="res://scenes/prefabs/ui/ChatMessage.gd" id="1"] [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_right = 400.0
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 6 size_flags_vertical = 6
script = ExtResource("1") script = ExtResource("1")
[node name="VBoxContainer" type="VBoxContainer" parent="."] [node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1 layout_mode = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 4 theme_override_constants/separation = 4
[node name="UserInfoContainer" type="HBoxContainer" parent="VBoxContainer"] [node name="UserInfoContainer" type="HBoxContainer" parent="VBoxContainer"]

View File

@@ -272,8 +272,12 @@ func _on_chat_message_received(data: Dictionary) -> void:
var content: String = data.get("content", "") var content: String = data.get("content", "")
var timestamp: float = data.get("timestamp", 0.0) 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: 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: if not _is_chat_visible:
show_chat() 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() 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_node.set_message(from_user, content, timestamp, is_self)
# 添加到列表
message_list.add_child(message_node)
# 自动滚动到底部 # 自动滚动到底部
call_deferred("_scroll_to_bottom") call_deferred("_scroll_to_bottom")