Files
whale-town-front/scenes/ui/ChatUI.gd

444 lines
13 KiB
GDScript
Raw 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 chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn")
const CHAT_TRANSITION_DURATION: float = 0.5
# ============================================================================
# 成员变量
# ============================================================================
# 是否显示聊天框
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 = ""
# ============================================================================
# 生命周期方法
# ============================================================================
# 准备就绪
func _ready() -> void:
# 初始隐藏聊天框
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()
# 清理
func _exit_tree() -> void:
# 取消事件订阅
if EventSystem:
EventSystem.disconnect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
EventSystem.disconnect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
EventSystem.disconnect_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
EventSystem.disconnect_event(EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
# 清理计时器
if _hide_timer:
_hide_timer.queue_free()
if is_instance_valid(_transition_tween):
_transition_tween.kill()
_transition_tween = null
# ============================================================================
# 输入处理
# ============================================================================
# 处理全局输入
func _input(event: InputEvent) -> void:
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()
# 处理 T 键按下
func _handle_t_pressed() -> void:
if not _is_chat_visible:
show_chat()
# 延迟聚焦,避免同一输入事件周期冲突
call_deferred("_grab_input_focus")
# 处理 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()
# 处理 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:
if chat_input and chat_input.has_focus():
chat_input.release_focus()
# ============================================================================
# 显示/隐藏逻辑
# ============================================================================
# 显示聊天框
func show_chat(immediate: bool = false) -> void:
_is_chat_visible = true
# 停止隐藏计时器
_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
if is_instance_valid(chat_input) and chat_input.has_focus():
chat_input.release_focus()
# 停止隐藏计时器
_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 _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.focus_entered.connect(_on_input_focus_entered)
chat_input.focus_exited.connect(_on_input_focus_exited)
# 输入框获得焦点
func _on_input_focus_entered() -> void:
_is_typing = true
_stop_hide_timer() # 停止隐藏计时器
# 输入框失去焦点
func _on_input_focus_exited() -> void:
_is_typing = false
if not is_inside_tree():
return
# 开始 5 秒倒计时
if not _is_chat_visible:
return
_start_hide_timer()
# 发送按钮点击处理
func _on_send_button_pressed() -> void:
var content: String = chat_input.text.strip_edges()
if content.is_empty():
return
# 发送消息
ChatManager.send_chat_message(content, "local")
# 清空输入框
chat_input.clear()
# 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
call_deferred("_focus_input_after_send")
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:
_on_send_button_pressed()
# 即便提交的是空串,也保持输入焦点,便于连续输入
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:
# 订阅聊天消息接收事件
EventSystem.connect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
# 订阅聊天错误事件
EventSystem.connect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
# 订阅连接状态变化事件
EventSystem.connect_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
# 订阅登录成功事件
EventSystem.connect_event(EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
# ============================================================================
# 事件处理器
# ============================================================================
# 处理接收到的聊天消息
func _on_chat_message_received(data: Dictionary) -> void:
var from_user: String = data.get("from_user", "")
var content: String = data.get("content", "")
var timestamp: float = data.get("timestamp", 0.0)
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, is_self)
# 处理聊天错误
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])
# 处理连接状态变化
func _on_connection_state_changed(_data: Dictionary) -> void:
# 连接状态变化处理当前不更新UI
pass
# 处理登录成功
func _on_login_success(data: Dictionary) -> void:
_current_username = data.get("username", "")
# ============================================================================
# 公共 API - 消息管理
# ============================================================================
# 添加消息到历史
#
# 参数:
# from_user: String - 发送者用户名
# content: String - 消息内容
# timestamp: float - 时间戳
# is_self: bool - 是否为自己发送的消息(默认 false
func add_message_to_history(from_user: String, content: String, timestamp: float, is_self: bool) -> void:
# 如果聊天框隐藏,自动显示
if not _is_chat_visible:
show_chat()
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
var 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 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)
# 自动滚动到底部
call_deferred("_scroll_to_bottom")
# ============================================================================
# 内部方法 - 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