Files
2026-07-21 23:07:31 +08:00

1084 lines
36 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Control
# ============================================================================
# ChatUI.gd - 聊天界面控制器T 唤起 / Enter 发送)
# ============================================================================
# 聊天系统的用户界面控制器
#
# 核心职责:
# - 显示聊天消息历史
# - 处理用户输入
# - 显示连接状态
# - T 唤起聊天框Enter 发送消息
# - 点击聊天框外部隐藏聊天框
# - 失焦后 5 秒自动隐藏
# - 显示/隐藏带 0.5s 过渡动画
# - Call Down: 通过 EventSystem 订阅聊天事件
#
# 使用方式:
# var chat_ui := preload("res://scenes/ui/ChatUI.tscn").instantiate()
# add_child(chat_ui)
#
# 注意事项:
# - 遵循 "Signal Up, Call Down" 架构
# - 使用 @onready 缓存节点引用
# - 所有 UI 操作通过 ChatManager
# ============================================================================
# ============================================================================
# 节点引用
# ============================================================================
# 聊天面板
@onready var chat_panel: Control = %ChatPanel
# 聊天历史容器
@onready var chat_history: ScrollContainer = %ChatHistory
# 消息列表
@onready var message_list: VBoxContainer = %MessageList
# 聊天输入框
@onready var chat_input: LineEdit = %ChatInput
# 发送按钮
@onready var send_button: Button = %SendButton
# 气泡发送按钮(世界频道专用)
@onready var bubble_send_button: Button = %BubbleSendButton
# 频道标签
@onready var popular_tab_button: Button = %PopularTabButton
@onready var recent_tab_button: Button = %RecentTabButton
@onready var friends_tab_button: Button = %FriendsTabButton
@onready var input_shell: Control = %InputShell
@onready var friends_list: HBoxContainer = %FriendsList
@onready var friends_list_surface: Control = %FriendsListSurface
@onready var add_friend_row: HBoxContainer = %AddFriendRow
@onready var add_friend_button: Button = %AddFriendButton
# ============================================================================
# 预加载资源
# ============================================================================
# 聊天消息场景
@onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn")
const CHAT_TRANSITION_DURATION: float = 0.5
const TAB_ACTIVE_COLOR: Color = Color(0.258824, 0.627451, 0.913725, 1.0)
const TAB_INACTIVE_COLOR: Color = Color(0.560784, 0.639216, 0.733333, 1.0)
const TAB_UNDERLINE_HIDDEN: Color = Color(1.0, 1.0, 1.0, 0.0)
const TAB_ACTIVE_BACKGROUND: Color = Color(0.918, 0.961, 0.992, 0.98)
const TAB_IDLE_BACKGROUND: Color = Color(1.0, 1.0, 1.0, 0.54)
const TAB_BORDER_COLOR: Color = Color(0.788, 0.851, 0.910, 0.38)
const CHAT_INPUT_PLACEHOLDER: String = "输入消息..."
const SYSTEM_SENDER_NAME: String = "系统"
const FRIENDS_UNSUPPORTED_TEXT: String = "当前后端暂不支持好友功能"
const FRIEND_CHAT_EMPTY_TEXT: String = "从右下角好友列表选择一位好友开始聊天"
const TAB_WORLD: String = "world"
const TAB_WHISPER: String = "whisper"
const TAB_FRIENDS: String = "friends"
const MAX_DISPLAYED_MESSAGES: int = 100
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
# ============================================================================
# 成员变量
# ============================================================================
# 是否显示聊天框
var _is_chat_visible: bool = false
# 隐藏计时器
var _hide_timer: Timer = null
# 是否在输入中(输入时不隐藏)
var _is_typing: bool = false
# 显示/隐藏过渡动画
var _transition_tween: Tween = null
# 当前用户名
var _current_username: String = ""
# 当前频道标签
var _current_tab: String = TAB_WORLD
# 悄悄话目标(靠近玩家按 E 后设置)
var _whisper_target_user_id: String = ""
var _whisper_target_username: String = ""
# 好友私聊目标(好友列表接入后复用同一私聊协议)
var _friend_target_user_id: String = ""
var _friend_target_username: String = ""
var _friends: Array[Dictionary] = []
var _friends_supported: bool = true
var _friends_status_message: String = ""
# 当前会话消息缓存,用于切换标签时重新筛选渲染
var _messages: Array[Dictionary] = []
var _send_failure_handled_by_ui: bool = false
# ============================================================================
# 生命周期方法
# ============================================================================
# 准备就绪
func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_configure_mouse_focus()
# 初始隐藏聊天框
hide_chat(true)
# 创建隐藏计时器
_create_hide_timer()
# 订阅事件Call Down via EventSystem
_subscribe_to_events()
# 连接 UI 信号
_connect_ui_signals()
# 尽可能保持回车提交后输入框继续编辑状态Godot 4.6+ 支持该属性)
_enable_keep_editing_on_submit()
_update_tab_visuals()
_update_bubble_send_button_visibility()
# 清理
func _exit_tree() -> void:
# 取消事件订阅
var eventSystem: Node = _get_event_system()
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
eventSystem.call("disconnect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
eventSystem.call("disconnect_event", EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, self)
eventSystem.call("disconnect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
# 清理计时器
if _hide_timer:
_hide_timer.queue_free()
if is_instance_valid(_transition_tween):
_transition_tween.kill()
_transition_tween = null
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")
func _get_chat_manager() -> Node:
return get_node_or_null("/root/ChatManager")
func _get_settings_manager() -> Node:
return get_node_or_null("/root/SettingsManager")
# ============================================================================
# 输入处理
# ============================================================================
# 处理全局输入
func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if event is InputEventMouseButton:
_handle_global_mouse_button_input(event as InputEventMouseButton)
return
if not (event is InputEventKey):
return
var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo:
return
# T 键用于唤起聊天(输入框聚焦时不拦截)
if key_event.keycode == KEY_T and not chat_input.has_focus():
_handle_t_pressed()
return
# Enter 键用于发送聊天
if key_event.keycode == KEY_ENTER or key_event.keycode == KEY_KP_ENTER:
_handle_enter_pressed()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
_handle_unhandled_mouse_button_input(event as InputEventMouseButton)
func _handle_unhandled_mouse_button_input(mouse_event: InputEventMouseButton) -> void:
_handle_global_mouse_button_input(mouse_event)
func _handle_global_mouse_button_input(mouse_event: InputEventMouseButton) -> void:
if not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT:
return
if not _is_chat_visible:
return
if is_instance_valid(chat_panel) and chat_panel.get_global_rect().has_point(mouse_event.position):
return
hide_chat()
# 处理 T 键按下
func _handle_t_pressed() -> void:
if not _is_chat_visible:
show_chat()
get_viewport().set_input_as_handled()
# 处理 Enter 键按下
func _handle_enter_pressed() -> void:
# 聊天框未显示时不处理 Enter
if not _is_chat_visible:
return
# 输入框有焦点时,发送逻辑交给 LineEdit 的 text_submitted 统一处理
if chat_input.has_focus():
return
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
chat_input.grab_focus()
# 延迟获取输入框焦点(避免事件冲突)
func _grab_input_focus() -> void:
if chat_input:
chat_input.grab_focus()
func _configure_mouse_focus() -> void:
if is_instance_valid(chat_input):
chat_input.mouse_filter = Control.MOUSE_FILTER_STOP
_update_input_placeholder()
if is_instance_valid(input_shell):
input_shell.mouse_filter = Control.MOUSE_FILTER_STOP
if is_instance_valid(friends_list):
friends_list.mouse_filter = Control.MOUSE_FILTER_STOP
func _update_input_placeholder() -> void:
if not is_instance_valid(chat_input):
return
if chat_input.has_focus() or not chat_input.text.strip_edges().is_empty():
chat_input.placeholder_text = ""
return
if _current_tab == TAB_WHISPER and not _whisper_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _whisper_target_username
return
if _current_tab == TAB_FRIENDS and not _friend_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _friend_target_username
return
chat_input.placeholder_text = CHAT_INPUT_PLACEHOLDER
# 处理 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 _is_chat_visible:
return
# 检查点击是否在聊天面板外部
if not chat_panel.get_global_rect().has_point(get_global_mouse_position()):
hide_chat()
# 延迟释放输入框焦点(由 call_deferred 调用)
func _release_input_focus() -> void:
_release_chat_focus()
func _release_chat_focus() -> void:
var releasedTextFocus := false
if is_instance_valid(chat_input) and chat_input.has_focus():
releasedTextFocus = true
chat_input.release_focus()
var viewport := get_viewport()
if viewport != null and viewport.gui_get_focus_owner() == chat_input:
releasedTextFocus = true
viewport.gui_release_focus()
if releasedTextFocus:
_reset_movement_input_state()
func _reset_movement_input_state() -> void:
Input.flush_buffered_events()
for action in MOVEMENT_ACTIONS:
Input.action_release(action)
# ============================================================================
# 显示/隐藏逻辑
# ============================================================================
# 显示聊天框
func show_chat(immediate: bool = false) -> void:
_is_chat_visible = true
_update_input_placeholder()
# 停止隐藏计时器
_stop_hide_timer()
if not is_instance_valid(chat_panel):
return
_stop_transition_tween()
chat_panel.show()
if immediate:
chat_panel.modulate.a = 1.0
return
chat_panel.modulate.a = 0.0
_transition_tween = create_tween()
_transition_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
_transition_tween.tween_property(chat_panel, "modulate:a", 1.0, CHAT_TRANSITION_DURATION)
_transition_tween.finished.connect(_on_show_transition_finished)
# 隐藏聊天框
func hide_chat(immediate: bool = false) -> void:
_is_chat_visible = false
_is_typing = false
_release_chat_focus()
_update_input_placeholder()
# 停止隐藏计时器
_stop_hide_timer()
if not is_instance_valid(chat_panel):
return
_stop_transition_tween()
if immediate:
chat_panel.hide()
chat_panel.modulate.a = 1.0
return
if not chat_panel.visible:
chat_panel.modulate.a = 1.0
return
_transition_tween = create_tween()
_transition_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
_transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION)
_transition_tween.finished.connect(_on_hide_transition_finished)
func is_escape_dismissible() -> bool:
return _is_chat_visible
func get_escape_priority() -> int:
return 480
func request_escape_close() -> void:
hide_chat()
# 创建隐藏计时器
func _create_hide_timer() -> void:
_hide_timer = Timer.new()
_hide_timer.wait_time = 5.0 # 5 秒
_hide_timer.one_shot = true
_hide_timer.timeout.connect(_on_hide_timeout)
add_child(_hide_timer)
# 开始隐藏倒计时
func _start_hide_timer() -> void:
if _is_typing:
return # 输入时不隐藏
if not is_instance_valid(_hide_timer):
return
if not _hide_timer.is_inside_tree():
return
_stop_hide_timer() # 先停止之前的计时器
_hide_timer.start()
# 停止隐藏倒计时
func _stop_hide_timer() -> void:
if is_instance_valid(_hide_timer) and _hide_timer.is_inside_tree():
_hide_timer.stop()
# 隐藏计时器超时
func _on_hide_timeout() -> void:
hide_chat()
func _on_show_transition_finished() -> void:
_transition_tween = null
func _on_hide_transition_finished() -> void:
_transition_tween = null
if not _is_chat_visible and is_instance_valid(chat_panel):
chat_panel.hide()
chat_panel.modulate.a = 1.0
func _stop_transition_tween() -> void:
if is_instance_valid(_transition_tween):
_transition_tween.kill()
_transition_tween = null
# ============================================================================
# UI 事件处理
# ============================================================================
# 连接 UI 信号
func _connect_ui_signals() -> void:
# 输入框回车
chat_input.text_submitted.connect(_on_chat_input_submitted)
chat_input.gui_input.connect(_on_chat_input_gui_input)
# 输入框焦点变化
chat_input.focus_entered.connect(_on_input_focus_entered)
chat_input.focus_exited.connect(_on_input_focus_exited)
if is_instance_valid(send_button):
send_button.pressed.connect(_on_send_button_pressed)
if is_instance_valid(bubble_send_button):
bubble_send_button.pressed.connect(_on_bubble_send_button_pressed)
if is_instance_valid(add_friend_button):
add_friend_button.pressed.connect(_on_add_friend_button_pressed)
if is_instance_valid(popular_tab_button):
popular_tab_button.pressed.connect(func() -> void: select_tab(TAB_WORLD))
if is_instance_valid(recent_tab_button):
recent_tab_button.pressed.connect(func() -> void: select_tab(TAB_WHISPER))
if is_instance_valid(friends_tab_button):
friends_tab_button.pressed.connect(func() -> void: select_tab(TAB_FRIENDS))
if is_instance_valid(input_shell):
input_shell.gui_input.connect(_on_input_shell_gui_input)
# 输入框获得焦点
func _on_input_focus_entered() -> void:
_is_typing = true
_update_input_placeholder()
_stop_hide_timer() # 停止隐藏计时器
func _on_chat_input_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mouse_event := event as InputEventMouseButton
if mouse_event.pressed and mouse_event.button_index == MOUSE_BUTTON_LEFT:
call_deferred("_grab_input_focus")
func _on_input_shell_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mouse_event := event as InputEventMouseButton
if mouse_event.pressed and mouse_event.button_index == MOUSE_BUTTON_LEFT:
call_deferred("_grab_input_focus")
input_shell.accept_event()
# 输入框失去焦点
func _on_input_focus_exited() -> void:
_is_typing = false
_update_input_placeholder()
if not is_inside_tree():
return
# 开始 5 秒倒计时
if not _is_chat_visible:
return
_start_hide_timer()
# 发送按钮点击处理
func _on_send_button_pressed() -> void:
_send_input_message(false)
func _on_bubble_send_button_pressed() -> void:
if _current_tab != TAB_WORLD:
return
if not _settings_bool("show_chat_bubbles", true):
_add_system_message("聊天气泡已在设置中关闭")
return
_send_input_message(true)
func _send_input_message(show_bubble: bool) -> void:
var content: String = chat_input.text.strip_edges()
if content.is_empty():
_add_system_message("请先输入消息")
call_deferred("_focus_input_after_send")
return
# 发送消息
var chatManager: Node = _get_chat_manager()
if chatManager == null:
push_warning("ChatUI: ChatManager autoload is not available.")
_add_system_message("聊天系统未加载")
call_deferred("_focus_input_after_send")
return
_send_failure_handled_by_ui = false
var sent: bool = _send_current_tab_message(chatManager, content, show_bubble)
if not sent:
if not _send_failure_handled_by_ui:
_add_system_message("消息未发送,请确认已经登录")
call_deferred("_focus_input_after_send")
return
# 清空输入框
chat_input.clear()
# 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
call_deferred("_focus_input_after_send")
func _on_add_friend_button_pressed() -> void:
add_whisper_target_as_friend()
func _focus_input_after_send() -> void:
if not _is_chat_visible:
return
if not is_instance_valid(chat_input):
return
chat_input.grab_focus()
# 聊天输入提交(回车键)处理
func _on_chat_input_submitted(_text: String) -> void:
_send_input_message(false)
# 即便提交的是空串,也保持输入焦点,便于连续输入
call_deferred("_focus_input_after_send")
func _enable_keep_editing_on_submit() -> void:
if not is_instance_valid(chat_input):
return
for property in chat_input.get_property_list():
if property.get("name", "") == "keep_editing_on_text_submit":
chat_input.set("keep_editing_on_text_submit", true)
return
# ============================================================================
# 事件订阅Call Down
# ============================================================================
# 订阅事件
func _subscribe_to_events() -> void:
var eventSystem: Node = _get_event_system()
if eventSystem == null:
push_warning("ChatUI: EventSystem autoload is not available.")
return
# 订阅聊天消息接收事件
eventSystem.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
# 订阅聊天错误事件
eventSystem.call("connect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
# 订阅连接状态变化事件
eventSystem.call("connect_event", EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
# 订阅登录成功事件
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
# 订阅登录失败事件
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, self)
# 订阅近身悄悄话目标选择事件
eventSystem.call("connect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
# 订阅右下角好友列表的好友选择事件
eventSystem.call("connect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
# 订阅好友列表更新事件
eventSystem.call("connect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("connect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
# ============================================================================
# 事件处理器
# ============================================================================
# 处理接收到的聊天消息
func _on_chat_message_received(data: Dictionary) -> void:
if not _should_show_incoming_message(data):
return
var from_user: String = data.get("from_user", "")
var content: String = data.get("content", "")
var timestamp: float = data.get("timestamp", 0.0)
var is_self: bool = bool(data.get("is_self", false))
if not data.has("is_self") and not _current_username.is_empty() and from_user == _current_username:
is_self = true
# 添加到消息历史
var message := data.duplicate(true)
message["from_user"] = from_user
message["content"] = content
message["timestamp"] = timestamp
message["is_self"] = is_self
_add_message_data(message)
# 处理聊天错误
func _on_chat_error(data: Dictionary) -> void:
var error_code: String = data.get("error_code", "")
var message: String = data.get("message", "")
# 聊天发送失败通常是业务校验(频率/内容)导致,不应按引擎级错误处理。
push_warning("ChatUI: [%s] %s" % [error_code, message])
if _is_friend_status_error(error_code):
_friends_status_message = message
if _current_tab == TAB_FRIENDS:
_render_friend_conversation_header()
return
if not message.strip_edges().is_empty():
_add_system_message(message)
# 处理连接状态变化
func _on_connection_state_changed(_data: Dictionary) -> void:
# 连接状态变化处理当前不更新UI
pass
# 处理登录成功
func _on_login_success(data: Dictionary) -> void:
_current_username = data.get("username", "")
func _on_login_failed(data: Dictionary) -> void:
var message: String = str(data.get("message", "聊天登录失败,请重新登录"))
_add_system_message(message)
# 选择聊天频道标签。
func select_tab(tab_name: String) -> void:
var normalized_tab := _normalize_tab_name(tab_name)
if normalized_tab.is_empty():
return
_current_tab = normalized_tab
_update_tab_visuals()
_update_friends_list_visibility()
_update_add_friend_row_visibility()
_update_bubble_send_button_visibility()
_update_input_placeholder()
_rerender_messages()
func get_current_tab() -> String:
return _current_tab
func _update_tab_visuals() -> void:
_apply_tab_visual(popular_tab_button, TAB_WORLD)
_apply_tab_visual(recent_tab_button, TAB_WHISPER)
_apply_tab_visual(friends_tab_button, TAB_FRIENDS)
func _apply_tab_visual(button: Button, tab_name: String) -> void:
if not is_instance_valid(button):
return
var is_active := tab_name == _current_tab
var color := TAB_ACTIVE_COLOR if is_active else TAB_INACTIVE_COLOR
button.add_theme_color_override("font_color", color)
button.add_theme_color_override("font_hover_color", TAB_ACTIVE_COLOR)
button.add_theme_color_override("font_pressed_color", TAB_ACTIVE_COLOR)
button.add_theme_stylebox_override("normal", _create_tab_style(is_active, false))
button.add_theme_stylebox_override("hover", _create_tab_style(is_active, true))
button.add_theme_stylebox_override("pressed", _create_tab_style(is_active, true, true))
var underline := button.get_parent().get_node_or_null("Underline") as ColorRect
if underline != null:
underline.color = TAB_ACTIVE_COLOR if is_active else TAB_UNDERLINE_HIDDEN
func _create_tab_style(is_active: bool, is_hovered: bool, is_pressed: bool = false) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = TAB_ACTIVE_BACKGROUND if is_active else TAB_IDLE_BACKGROUND
if is_hovered:
style.bg_color = style.bg_color.lightened(0.12)
if is_pressed:
style.bg_color = style.bg_color.darkened(0.12)
style.border_width_left = 1
style.border_width_top = 1
style.border_width_right = 1
style.border_width_bottom = 1
style.border_color = Color(0.56, 0.78, 0.94, 0.76) if is_active else TAB_BORDER_COLOR
style.corner_radius_top_left = 14
style.corner_radius_top_right = 14
style.corner_radius_bottom_left = 14
style.corner_radius_bottom_right = 14
return style
func _normalize_tab_name(tab_name: String) -> String:
match tab_name:
TAB_WORLD, "popular":
return TAB_WORLD
TAB_WHISPER, "recent":
return TAB_WHISPER
TAB_FRIENDS:
return TAB_FRIENDS
_:
return ""
func _send_current_tab_message(chatManager: Node, content: String, show_bubble: bool = false) -> bool:
match _current_tab:
TAB_WORLD:
return bool(chatManager.call("send_chat_message", content, "global", show_bubble))
TAB_WHISPER:
if _whisper_target_user_id.is_empty():
_add_system_message("请靠近玩家按 E 发起悄悄话")
_send_failure_handled_by_ui = true
return false
return bool(chatManager.call("send_private_message", content, _whisper_target_user_id, _whisper_target_username, TAB_WHISPER))
TAB_FRIENDS:
if _friend_target_user_id.is_empty():
_add_system_message("请从右下角好友列表选择一位好友")
_send_failure_handled_by_ui = true
return false
return bool(chatManager.call("send_private_message", content, _friend_target_user_id, _friend_target_username, TAB_FRIENDS))
_:
return false
func _on_private_target_selected(data: Dictionary) -> void:
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
if user_id.is_empty():
return
var username := str(data.get("username", "")).strip_edges()
start_whisper(user_id, username)
func _on_friend_selected(data: Dictionary) -> void:
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
if user_id.is_empty():
return
var username := str(data.get("username", "")).strip_edges()
select_friend_private_target(user_id, username)
func start_whisper(user_id: String, username: String = "") -> void:
var normalized_user_id := user_id.strip_edges()
if normalized_user_id.is_empty():
return
_whisper_target_user_id = normalized_user_id
_whisper_target_username = username.strip_edges()
if _whisper_target_username.is_empty():
_whisper_target_username = "玩家"
select_tab(TAB_WHISPER)
_update_add_friend_row_visibility()
show_chat(true)
call_deferred("_focus_input_after_send")
func add_whisper_target_as_friend() -> bool:
if _whisper_target_user_id.is_empty():
_add_system_message("请先靠近玩家按 F")
return false
var chatManager: Node = _get_chat_manager()
if chatManager == null or not chatManager.has_method("request_friend"):
_add_system_message("好友系统未加载")
return false
return bool(chatManager.call("request_friend", _whisper_target_user_id, _whisper_target_username))
func request_friend_with_target(user_id: String, username: String = "") -> bool:
var normalized_user_id := user_id.strip_edges()
if normalized_user_id.is_empty():
_add_system_message("请选择玩家后再发送好友请求")
return false
_whisper_target_user_id = normalized_user_id
_whisper_target_username = username.strip_edges()
if _whisper_target_username.is_empty():
_whisper_target_username = "玩家"
var chatManager: Node = _get_chat_manager()
if chatManager == null or not chatManager.has_method("request_friend"):
_add_system_message("好友系统未加载")
return false
show_chat(true)
return bool(chatManager.call("request_friend", _whisper_target_user_id, _whisper_target_username))
func select_friend_private_target(user_id: String, username: String = "") -> void:
var normalized_user_id := user_id.strip_edges()
if normalized_user_id.is_empty():
return
_friend_target_user_id = normalized_user_id
_friend_target_username = username.strip_edges()
if _friend_target_username.is_empty():
_friend_target_username = "好友"
select_tab(TAB_FRIENDS)
_render_friend_conversation_header()
show_chat(true)
call_deferred("_focus_input_after_send")
func _on_friends_updated(data: Dictionary) -> void:
_friends.clear()
_friends_supported = bool(data.get("supported", true))
_friends_status_message = str(data.get("status", "")).strip_edges()
var friends_variant: Variant = data.get("friends", [])
if friends_variant is Array:
for friend_variant in friends_variant:
if friend_variant is Dictionary:
_friends.append(friend_variant)
_render_friend_conversation_header()
func _on_settings_changed(_data: Dictionary) -> void:
_update_bubble_send_button_visibility()
func _update_friends_list_visibility() -> void:
if not is_instance_valid(friends_list):
return
friends_list.visible = _current_tab == TAB_FRIENDS
if is_instance_valid(friends_list_surface):
friends_list_surface.visible = _current_tab == TAB_FRIENDS
if friends_list.visible:
_render_friend_conversation_header()
func _update_add_friend_row_visibility() -> void:
if not is_instance_valid(add_friend_row):
return
add_friend_row.visible = _current_tab == TAB_WHISPER and not _whisper_target_user_id.is_empty()
func _update_bubble_send_button_visibility() -> void:
if not is_instance_valid(bubble_send_button):
return
bubble_send_button.visible = _current_tab == TAB_WORLD and _settings_bool("show_chat_bubbles", true)
func _render_friend_conversation_header() -> void:
if not is_instance_valid(friends_list):
return
for child in friends_list.get_children():
friends_list.remove_child(child)
child.queue_free()
if not _friends_supported:
_render_friends_status_label(_friends_status_message if not _friends_status_message.is_empty() else FRIENDS_UNSUPPORTED_TEXT)
return
if _friend_target_user_id.is_empty():
_render_friends_status_label(FRIEND_CHAT_EMPTY_TEXT)
return
var avatar := PanelContainer.new()
avatar.name = "FriendConversationAvatarPanel"
avatar.custom_minimum_size = Vector2(34, 34)
avatar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
avatar.add_theme_stylebox_override("panel", _create_friend_avatar_style())
friends_list.add_child(avatar)
var avatar_label := Label.new()
avatar_label.text = _friend_target_username.substr(0, 1).to_upper()
avatar_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
avatar_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
avatar_label.add_theme_color_override("font_color", Color.WHITE)
avatar_label.add_theme_font_size_override("font_size", 14)
avatar.add_child(avatar_label)
var title_box := VBoxContainer.new()
title_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title_box.add_theme_constant_override("separation", 0)
friends_list.add_child(title_box)
var name_label := Label.new()
name_label.text = _friend_target_username
name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
name_label.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
name_label.add_theme_font_size_override("font_size", 15)
title_box.add_child(name_label)
var hint_label := Label.new()
hint_label.text = "好友私聊"
hint_label.add_theme_color_override("font_color", TAB_INACTIVE_COLOR)
hint_label.add_theme_font_size_override("font_size", 12)
title_box.add_child(hint_label)
func _render_friends_status_label(message: String) -> void:
var label := Label.new()
label.text = message
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.add_theme_color_override("font_color", TAB_INACTIVE_COLOR)
label.add_theme_font_size_override("font_size", 14)
friends_list.add_child(label)
func _is_friend_status_error(error_code: String) -> bool:
match error_code:
"FRIEND_REQUEST_SENT", "FRIEND_REQUEST_REJECTED":
return true
_:
return false
func _create_friend_avatar_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.361, 0.690, 0.914)
style.border_width_left = 1
style.border_width_top = 1
style.border_width_right = 1
style.border_width_bottom = 1
style.border_color = Color(1, 1, 1, 0.78)
style.corner_radius_top_left = 8
style.corner_radius_top_right = 8
style.corner_radius_bottom_left = 8
style.corner_radius_bottom_right = 8
return style
# ============================================================================
# 公共 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:
_add_message_data({
"from_user": from_user,
"content": content,
"timestamp": timestamp,
"is_self": is_self,
"scope": "system" if from_user == SYSTEM_SENDER_NAME else "global",
"tab": _current_tab
})
func _add_message_data(message: Dictionary) -> void:
_messages.append(message.duplicate(true))
if _messages.size() > MAX_DISPLAYED_MESSAGES:
_messages.pop_front()
if not _message_matches_current_tab(message):
return
_render_message(message)
func _render_message(message: Dictionary) -> void:
# 如果聊天框隐藏,自动显示
if not _is_chat_visible:
show_chat()
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.alignment = BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN
# 创建消息节点
var message_node: Control = chat_message_scene.instantiate() as Control
if message_node == null:
return
# 先加入场景树,再设置内容(避免 ChatMessage._ready 尚未执行导致节点引用为空)
message_list.add_child(row)
row.add_child(message_node)
# 设置消息内容
if message_node.has_method("set_message"):
message_node.call(
"set_message",
_get_display_sender_name(message),
str(message.get("content", "")),
float(message.get("timestamp", 0.0)),
bool(message.get("is_self", false))
)
# 自动滚动到底部
call_deferred("_scroll_to_bottom")
func _rerender_messages() -> void:
if not is_instance_valid(message_list):
return
for child in message_list.get_children():
message_list.remove_child(child)
child.queue_free()
for message in _messages:
if _message_matches_current_tab(message):
_render_message(message)
func _message_matches_current_tab(message: Dictionary) -> bool:
var scope := str(message.get("scope", "local")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or scope == "private"
if scope == "system":
return str(message.get("tab", _current_tab)) == _current_tab
if _current_tab == TAB_WORLD:
return not is_private
if _current_tab == TAB_WHISPER or _current_tab == TAB_FRIENDS:
return _private_message_matches_current_tab(message)
return true
func _private_message_matches_current_tab(message: Dictionary) -> bool:
var scope := str(message.get("scope", "local")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or scope == "private"
if not is_private:
return false
var private_context := str(message.get("private_context", message.get("privateContext", ""))).strip_edges().to_lower()
if private_context == TAB_WHISPER:
return _current_tab == TAB_WHISPER and _private_message_matches_target(message, _whisper_target_user_id)
if private_context == TAB_FRIENDS:
return _current_tab == TAB_FRIENDS and _private_message_matches_target(message, _friend_target_user_id)
if _current_tab == TAB_WHISPER:
return _private_message_matches_target(message, _whisper_target_user_id)
if _current_tab == TAB_FRIENDS:
return _private_message_matches_target(message, _friend_target_user_id)
return false
func _private_message_matches_target(message: Dictionary, target_user_id: String) -> bool:
var normalized_target := target_user_id.strip_edges()
if normalized_target.is_empty():
return false
var from_user_id := str(message.get("from_user_id", message.get("fromUserId", ""))).strip_edges()
var to_user_id := str(message.get("to_user_id", message.get("toUserId", ""))).strip_edges()
return from_user_id == normalized_target or to_user_id == normalized_target
func _get_display_sender_name(message: Dictionary) -> String:
var from_user := str(message.get("from_user", ""))
var scope := str(message.get("scope", "local")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or scope == "private"
if not is_private:
return from_user
if bool(message.get("is_self", false)):
return ""
if from_user.strip_edges().is_empty():
from_user = "玩家"
return from_user
func _add_system_message(message: String) -> void:
var normalized_message := message.strip_edges()
if normalized_message.is_empty():
return
if not _is_chat_visible:
show_chat(true)
add_message_to_history(SYSTEM_SENDER_NAME, normalized_message, Time.get_unix_time_from_system(), false)
func _should_show_incoming_message(data: Dictionary) -> bool:
if bool(data.get("is_self", false)):
return true
var scope := str(data.get("scope", "")).strip_edges().to_lower()
var is_private := bool(data.get("is_private", false)) or scope == "private"
if is_private:
return _settings_bool("private_notifications", true)
return _settings_bool("world_notifications", true)
func _settings_bool(key: String, defaultValue: bool) -> bool:
var settingsManager := _get_settings_manager()
if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key))
return defaultValue
# ============================================================================
# 内部方法 - UI 更新
# ============================================================================
# 滚动到底部
func _scroll_to_bottom() -> void:
# 等待一帧,确保 UI 更新完成
await get_tree().process_frame
# 滚动到底部
if is_instance_valid(chat_history):
chat_history.scroll_vertical = chat_history.get_v_scroll_bar().max_value