From 558a0ff9bc106d9a1a14f4d65dfbb5e130eacf25 Mon Sep 17 00:00:00 2001 From: moyin <2443444649@qq.com> Date: Wed, 11 Mar 2026 18:11:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E8=B0=83=E6=95=B4=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=BA=A4=E4=BA=92=E5=B9=B6=E5=8A=A0=E5=9B=BA=20WS/?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Config/game_config.json | 1 + _Core/managers/ChatManager.gd | 33 ++++- _Core/managers/LocationManager.gd | 77 +++++++++- _Core/managers/SceneManager.gd | 4 +- scenes/characters/PlayerController.gd | 1 + scenes/prefabs/items/notice_board.tscn | 2 +- scenes/ui/ChatUI.gd | 148 +++++++++++++++---- scenes/ui/NoticeDialog.gd | 2 +- scenes/ui/WelcomeDialog.gd | 2 +- scenes/ui/notice_dialog.tscn | 2 +- scenes/ui/welcome_dialog.tscn | 2 +- tests/unit/test_chat_ui_input_regressions.gd | 85 +++++++++++ 12 files changed, 315 insertions(+), 44 deletions(-) create mode 100644 tests/unit/test_chat_ui_input_regressions.gd diff --git a/Config/game_config.json b/Config/game_config.json index a0a2485..bb19777 100644 --- a/Config/game_config.json +++ b/Config/game_config.json @@ -6,6 +6,7 @@ }, "network": { "api_base_url": "https://whaletownend.xinghangee.icu", + "location_ws_url": "wss://whaletownend.xinghangee.icu/game", "timeout": 30, "retry_count": 3 }, diff --git a/_Core/managers/ChatManager.gd b/_Core/managers/ChatManager.gd index 384953c..bfcc66a 100644 --- a/_Core/managers/ChatManager.gd +++ b/_Core/managers/ChatManager.gd @@ -126,6 +126,10 @@ var _game_token: String = "" const SELF_ECHO_DEDUPE_WINDOW: float = 10.0 var _pending_self_messages: Array[Dictionary] = [] +# 空消息类型告警限频(避免日志刷屏) +const EMPTY_MESSAGE_TYPE_WARNING_INTERVAL: float = 10.0 +var _last_empty_message_type_warning_at: float = -1000.0 + # ============================================================================ # 生命周期方法 # ============================================================================ @@ -489,12 +493,22 @@ func _on_data_received(message: String) -> void: push_error("ChatManager: JSON 解析失败") return - var data: Dictionary = json.data + var data_variant: Variant = json.data + if not (data_variant is Dictionary): + push_warning("ChatManager: 收到非对象消息,已忽略") + return - # 检查消息类型字段 - var message_type: String = data.get("t", "") + var data: Dictionary = data_variant + + # 兼容不同后端字段命名:t / type + var message_type: String = str(data.get("t", data.get("type", ""))).strip_edges() + if message_type.is_empty(): + _warn_empty_message_type_limited(data) + return match message_type: + "connected": + pass "login_success": _handle_login_success(data) "login_error": @@ -509,9 +523,22 @@ func _on_data_received(message: String) -> void: _handle_chat_render(data) "position_updated": _handle_position_updated(data) + "error": + _handle_error_response(data) _: push_warning("ChatManager: 未处理的消息类型 %s" % message_type) +func _warn_empty_message_type_limited(data: Dictionary) -> void: + var now: float = Time.get_unix_time_from_system() + if now - _last_empty_message_type_warning_at < EMPTY_MESSAGE_TYPE_WARNING_INTERVAL: + return + + _last_empty_message_type_warning_at = now + var payload_preview: String = JSON.stringify(data) + if payload_preview.length() > 180: + payload_preview = payload_preview.substr(0, 180) + "..." + push_warning("ChatManager: 收到未带消息类型的消息,已忽略 payload=%s" % payload_preview) + # 处理登录成功 func _handle_login_success(data: Dictionary) -> void: _is_logged_in = true diff --git a/_Core/managers/LocationManager.gd b/_Core/managers/LocationManager.gd index 545d455..7700024 100644 --- a/_Core/managers/LocationManager.gd +++ b/_Core/managers/LocationManager.gd @@ -6,7 +6,11 @@ extends Node # 负责与后端 WebSocket 服务进行位置同步和多人会话管理 # # 协议文档: new_docs/game_architecture_design.md -# 后端地址: wss://whaletownend.xinghangee.icu/location-broadcast +# 后端地址默认值: wss://whaletownend.xinghangee.icu/game +# 可通过以下方式覆盖: +# 1) 环境变量 WHALETOWN_LOCATION_WS_URL +# 2) Config/game_config.json 或 config/game_config.json 中的 +# network.location_ws_url / network.game_ws_url # ============================================================================ signal connected_to_server() @@ -17,23 +21,32 @@ signal user_joined(data: Dictionary) signal user_left(data: Dictionary) signal position_updated(data: Dictionary) -const WS_URL = "wss://whaletownend.xinghangee.icu/location-broadcast" +const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game" +const WS_URL_ENV_KEY: String = "WHALETOWN_LOCATION_WS_URL" const PING_INTERVAL = 25.0 # 秒 var _socket: WebSocketPeer var _connected: bool = false var _ping_timer: float = 0.0 var _auth_token: String = "" +var _connection_error_reported: bool = false +var _is_connecting: bool = false +var _ws_url: String = DEFAULT_WS_URL func _ready(): _socket = WebSocketPeer.new() process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络 + _ws_url = _resolve_ws_url() func _process(delta): _socket.poll() + var state = _socket.get_ready_state() if state == WebSocketPeer.STATE_OPEN: + _connection_error_reported = false + _is_connecting = false + if not _connected: _on_connected() @@ -51,19 +64,71 @@ func _process(delta): elif state == WebSocketPeer.STATE_CLOSED: if _connected: _on_disconnected() + elif _is_connecting and not _connection_error_reported: + var close_code := _socket.get_close_code() + var close_reason := _socket.get_close_reason() + push_warning( + "LocationManager: WebSocket 握手失败,close_code=%d, reason=%s" % [close_code, close_reason] + ) + connection_error.emit() + _connection_error_reported = true + _is_connecting = false func connect_to_server(): - if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN: + var state: WebSocketPeer.State = _socket.get_ready_state() + if state == WebSocketPeer.STATE_OPEN or state == WebSocketPeer.STATE_CONNECTING: return - - var err = _socket.connect_to_url(WS_URL) + + _connection_error_reported = false + _is_connecting = true + var err = _socket.connect_to_url(_ws_url) if err != OK: - push_error("LocationManager: WebSocket 连接请求失败,错误码: %d" % err) + push_error("LocationManager: WebSocket 连接请求失败,url=%s, 错误码: %d" % [_ws_url, err]) connection_error.emit() + _connection_error_reported = true + _is_connecting = false else: # Godot WebSocket connect is non-blocking, wait for state change in _process pass +func _resolve_ws_url() -> String: + var env_url: String = OS.get_environment(WS_URL_ENV_KEY).strip_edges() + if not env_url.is_empty(): + return env_url + + for config_path in ["res://Config/game_config.json", "res://config/game_config.json"]: + var config_url: String = _load_ws_url_from_config(config_path) + if not config_url.is_empty(): + return config_url + + return DEFAULT_WS_URL + +func _load_ws_url_from_config(config_path: String) -> String: + if not FileAccess.file_exists(config_path): + return "" + + var content: String = FileAccess.get_file_as_string(config_path) + if content.is_empty(): + return "" + + var json := JSON.new() + if json.parse(content) != OK: + push_warning("LocationManager: 读取配置失败 %s - %s" % [config_path, json.get_error_message()]) + return "" + + var data_variant: Variant = json.data + if not (data_variant is Dictionary): + return "" + + var root: Dictionary = data_variant + var network_variant: Variant = root.get("network", {}) + if not (network_variant is Dictionary): + return "" + + var network_config: Dictionary = network_variant + var ws_url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges() + return ws_url + func close_connection(): _socket.close() diff --git a/_Core/managers/SceneManager.gd b/_Core/managers/SceneManager.gd index bb46100..8767e4a 100644 --- a/_Core/managers/SceneManager.gd +++ b/_Core/managers/SceneManager.gd @@ -186,9 +186,9 @@ func set_next_spawn_name(spawn_name: String) -> void: # - 此方法会清除存储的名称,只能获取一次 # - 如果未设置名称,返回空字符串 func get_next_spawn_name() -> String: - var name = _next_spawn_name + var spawn_name: String = _next_spawn_name _next_spawn_name = "" - return name + return spawn_name # ============ 场景注册方法 ============ diff --git a/scenes/characters/PlayerController.gd b/scenes/characters/PlayerController.gd index 925037b..6247994 100644 --- a/scenes/characters/PlayerController.gd +++ b/scenes/characters/PlayerController.gd @@ -81,6 +81,7 @@ func _handle_movement(_delta: float) -> void: # 发送移动事件 (如果位置发生明显变化) if velocity.length() > 0: + player_moved.emit(global_position) EventSystem.emit_event(EventNames.PLAYER_MOVED, { "position": global_position }) diff --git a/scenes/prefabs/items/notice_board.tscn b/scenes/prefabs/items/notice_board.tscn index 790d85a..c621f9e 100644 --- a/scenes/prefabs/items/notice_board.tscn +++ b/scenes/prefabs/items/notice_board.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=6 format=3 uid="uid://rdmrm7j4iokr"] -[ext_resource type="Script" uid="uid://cia2i6wew103b" path="res://scenes/prefabs/items/NoticeBoard.gd" id="1_script"] +[ext_resource type="Script" uid="uid://pnlgf420wktn" path="res://scenes/prefabs/items/NoticeBoard.gd" id="1_script"] [ext_resource type="Texture2D" uid="uid://b4aildrnhbpl4" path="res://assets/materials/NoticeBoard.png" id="2_sprite"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_left_post"] diff --git a/scenes/ui/ChatUI.gd b/scenes/ui/ChatUI.gd index eb4f2ec..8010639 100644 --- a/scenes/ui/ChatUI.gd +++ b/scenes/ui/ChatUI.gd @@ -1,7 +1,7 @@ extends Control # ============================================================================ -# ChatUI.gd - 聊天界面控制器(Enter 显示/隐藏版本) +# ChatUI.gd - 聊天界面控制器(T 唤起 / Enter 发送) # ============================================================================ # 聊天系统的用户界面控制器 # @@ -9,8 +9,10 @@ extends Control # - 显示聊天消息历史 # - 处理用户输入 # - 显示连接状态 -# - Enter 显示/隐藏 + 点击外部取消输入状态 + 5秒自动隐藏 -# - 只有按 Enter 才会取消倒计时 +# - T 唤起聊天框,Enter 发送消息 +# - 点击聊天框外部隐藏聊天框 +# - 失焦后 5 秒自动隐藏 +# - 显示/隐藏带 0.5s 过渡动画 # - Call Down: 通过 EventSystem 订阅聊天事件 # # 使用方式: @@ -46,6 +48,8 @@ extends Control # 聊天消息场景 @onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn") +const CHAT_TRANSITION_DURATION: float = 0.5 + # ============================================================================ # 成员变量 # ============================================================================ @@ -59,6 +63,9 @@ var _hide_timer: Timer = null # 是否在输入中(输入时不隐藏) var _is_typing: bool = false +# 显示/隐藏过渡动画 +var _transition_tween: Tween = null + # 当前用户名 var _current_username: String = "" @@ -69,7 +76,7 @@ var _current_username: String = "" # 准备就绪 func _ready() -> void: # 初始隐藏聊天框 - hide_chat() + hide_chat(true) # 创建隐藏计时器 _create_hide_timer() @@ -80,6 +87,9 @@ func _ready() -> void: # 连接 UI 信号 _connect_ui_signals() + # 尽可能保持回车提交后输入框继续编辑状态(Godot 4.6+ 支持该属性) + _enable_keep_editing_on_submit() + # 清理 func _exit_tree() -> void: # 取消事件订阅 @@ -93,30 +103,48 @@ func _exit_tree() -> void: if _hide_timer: _hide_timer.queue_free() + if is_instance_valid(_transition_tween): + _transition_tween.kill() + _transition_tween = null + # ============================================================================ # 输入处理 # ============================================================================ # 处理全局输入 func _input(event: InputEvent) -> void: - # 检查是否按下 Enter 键 - if event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER): + 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: - show_chat() - # 使用 call_deferred 避免在同一个事件周期内触发 LineEdit 的 text_submitted 信号 - call_deferred("_grab_input_focus") return - # 如果聊天框已显示且输入框有焦点,检查输入框内容 + # 输入框有焦点时,发送逻辑交给 LineEdit 的 text_submitted 统一处理 if chat_input.has_focus(): - # 如果输入框有内容,发送消息 - if not chat_input.text.is_empty(): - _on_send_button_pressed() return # 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时) @@ -136,11 +164,12 @@ func _gui_input(event: InputEvent) -> void: # 处理点击聊天框外部区域 func _handle_click_outside() -> void: + if not _is_chat_visible: + return + # 检查点击是否在聊天面板外部 if not chat_panel.get_global_rect().has_point(get_global_mouse_position()): - # 延迟释放输入框焦点,避免事件冲突 - if chat_input.has_focus(): - call_deferred("_release_input_focus") + hide_chat() # 延迟释放输入框焦点(由 call_deferred 调用) func _release_input_focus() -> void: @@ -152,20 +181,32 @@ func _release_input_focus() -> void: # ============================================================================ # 显示聊天框 -func show_chat() -> void: +func show_chat(immediate: bool = false) -> void: _is_chat_visible = true - if is_instance_valid(chat_panel): - chat_panel.show() # 停止隐藏计时器 _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() -> void: +func hide_chat(immediate: bool = false) -> void: _is_chat_visible = false _is_typing = false - if is_instance_valid(chat_panel): - chat_panel.hide() if is_instance_valid(chat_input) and chat_input.has_focus(): chat_input.release_focus() @@ -173,6 +214,25 @@ func hide_chat() -> void: # 停止隐藏计时器 _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() @@ -202,6 +262,20 @@ func _stop_hide_timer() -> void: 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 事件处理 # ============================================================================ @@ -243,12 +317,30 @@ func _on_send_button_pressed() -> void: # 清空输入框 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: +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) @@ -290,10 +382,11 @@ func _on_chat_error(data: Dictionary) -> void: var error_code: String = data.get("error_code", "") var message: String = data.get("message", "") - push_error("ChatUI: [%s] %s" % [error_code, message]) + # 聊天发送失败通常是业务校验(频率/内容)导致,不应按引擎级错误处理。 + push_warning("ChatUI: [%s] %s" % [error_code, message]) # 处理连接状态变化 -func _on_connection_state_changed(data: Dictionary) -> void: +func _on_connection_state_changed(_data: Dictionary) -> void: # 连接状态变化处理(当前不更新UI) pass @@ -319,7 +412,6 @@ func add_message_to_history(from_user: String, content: String, timestamp: float # 每条消息用一行容器包起来,方便左右对齐且不挤在一起 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 diff --git a/scenes/ui/NoticeDialog.gd b/scenes/ui/NoticeDialog.gd index eea43f1..9bd5cd1 100644 --- a/scenes/ui/NoticeDialog.gd +++ b/scenes/ui/NoticeDialog.gd @@ -32,7 +32,7 @@ var current_page = 0 var tween: Tween var mock_pages = [] var _chat_ui: Control -var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP +var _chat_ui_prev_mouse_filter: Control.MouseFilter = Control.MOUSE_FILTER_STOP var _chat_ui_mouse_disabled: bool = false func _ready(): diff --git a/scenes/ui/WelcomeDialog.gd b/scenes/ui/WelcomeDialog.gd index bf17bb4..5f7bf04 100644 --- a/scenes/ui/WelcomeDialog.gd +++ b/scenes/ui/WelcomeDialog.gd @@ -1,7 +1,7 @@ extends CanvasLayer var _chat_ui: Control -var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP +var _chat_ui_prev_mouse_filter: Control.MouseFilter = Control.MOUSE_FILTER_STOP var _chat_ui_mouse_disabled: bool = false func _ready(): diff --git a/scenes/ui/notice_dialog.tscn b/scenes/ui/notice_dialog.tscn index 872441b..111d03b 100644 --- a/scenes/ui/notice_dialog.tscn +++ b/scenes/ui/notice_dialog.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=4 format=3 uid="uid://rdmro1jxs6ga"] -[ext_resource type="Script" uid="uid://c5iq3s1myvl83" path="res://scenes/ui/NoticeDialog.gd" id="1_script"] +[ext_resource type="Script" uid="uid://c227m0okmjt2t" path="res://scenes/ui/NoticeDialog.gd" id="1_script"] [ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"] diff --git a/scenes/ui/welcome_dialog.tscn b/scenes/ui/welcome_dialog.tscn index 4fb3e38..fbb69db 100644 --- a/scenes/ui/welcome_dialog.tscn +++ b/scenes/ui/welcome_dialog.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=6 format=3 uid="uid://d8mam0n1a3b5"] -[ext_resource type="Script" uid="uid://b1x23718wrl85" path="res://scenes/ui/WelcomeDialog.gd" id="1_vs5b1"] +[ext_resource type="Script" uid="uid://cohijfo0yeo34" path="res://scenes/ui/WelcomeDialog.gd" id="1_vs5b1"] [ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_dy5hw"] [ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3_theme"] diff --git a/tests/unit/test_chat_ui_input_regressions.gd b/tests/unit/test_chat_ui_input_regressions.gd new file mode 100644 index 0000000..f3576a6 --- /dev/null +++ b/tests/unit/test_chat_ui_input_regressions.gd @@ -0,0 +1,85 @@ +extends SceneTree + +# ChatUI 输入交互回归测试(无需 GUT) +# +# 运行方式(Godot 4.6): +# "D:\technology\biancheng\Godot_v4.6-stable_win64.exe\Godot_v4.6-stable_win64_console.exe" --headless --path . --script tests/unit/test_chat_ui_input_regressions.gd + +var _failures: Array[String] = [] + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + await _test_t_opens_enter_sends_outside_click_hides() + + if _failures.is_empty(): + print("PASS: test_chat_ui_input_regressions") + quit(0) + return + + for failure in _failures: + push_error(failure) + print("FAIL: test_chat_ui_input_regressions (%d)" % _failures.size()) + quit(1) + +func _test_t_opens_enter_sends_outside_click_hides() -> void: + var packed_scene: PackedScene = load("res://scenes/ui/ChatUI.tscn") + _assert(packed_scene != null, "应能加载 ChatUI.tscn") + if packed_scene == null: + return + + var chat_ui = packed_scene.instantiate() + root.add_child(chat_ui) + await process_frame + await process_frame + + var chat_panel = chat_ui.chat_panel + var chat_input = chat_ui.chat_input + + _assert(not chat_panel.visible, "初始化时聊天框应隐藏") + _assert(not chat_ui._is_chat_visible, "初始化时 _is_chat_visible 应为 false") + + var enter_event := InputEventKey.new() + enter_event.pressed = true + enter_event.keycode = KEY_ENTER + chat_ui._input(enter_event) + await process_frame + _assert(not chat_panel.visible, "聊天框隐藏时按 Enter 不应唤起聊天框") + + var t_event := InputEventKey.new() + t_event.pressed = true + t_event.keycode = KEY_T + chat_ui._input(t_event) + await create_timer(0.7).timeout + + _assert(chat_panel.visible, "按 T 后聊天框应显示") + _assert(chat_ui._is_chat_visible, "按 T 后 _is_chat_visible 应为 true") + _assert(chat_input.has_focus(), "按 T 后输入框应获得焦点") + + chat_input.text = "hello world" + chat_ui._on_chat_input_submitted(chat_input.text) + await process_frame + _assert(chat_input.text.is_empty(), "输入框有内容时按 Enter 应触发发送并清空输入框") + _assert(chat_input.has_focus(), "按 Enter 发送后输入框应自动保持焦点,便于连续输入") + + # 将鼠标移动到左上角,通常在聊天框外 + var viewport := root.get_viewport() + if viewport: + viewport.warp_mouse(Vector2.ZERO) + + var mouse_event := InputEventMouseButton.new() + mouse_event.button_index = MOUSE_BUTTON_LEFT + mouse_event.pressed = true + chat_ui._gui_input(mouse_event) + await create_timer(0.7).timeout + + _assert(not chat_ui._is_chat_visible, "点击聊天框外部后 _is_chat_visible 应为 false") + _assert(not chat_panel.visible, "点击聊天框外部后聊天框应隐藏") + + chat_ui.queue_free() + await process_frame + +func _assert(condition: bool, message: String) -> void: + if not condition: + _failures.append(message)