Compare commits
3 Commits
dc403179f7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b03d623a5 | |||
| f981ef18b1 | |||
| 558a0ff9bc |
@@ -6,6 +6,7 @@
|
|||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"api_base_url": "https://whaletownend.xinghangee.icu",
|
"api_base_url": "https://whaletownend.xinghangee.icu",
|
||||||
|
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"retry_count": 3
|
"retry_count": 3
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ var _game_token: String = ""
|
|||||||
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
|
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
|
||||||
var _pending_self_messages: Array[Dictionary] = []
|
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 解析失败")
|
push_error("ChatManager: JSON 解析失败")
|
||||||
return
|
return
|
||||||
|
|
||||||
var data: Dictionary = json.data
|
var data_variant: Variant = json.data
|
||||||
|
if not (data_variant is Dictionary):
|
||||||
|
push_warning("ChatManager: 收到非对象消息,已忽略")
|
||||||
|
return
|
||||||
|
|
||||||
# 检查消息类型字段
|
var data: Dictionary = data_variant
|
||||||
var message_type: String = data.get("t", "")
|
|
||||||
|
# 兼容不同后端字段命名: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:
|
match message_type:
|
||||||
|
"connected":
|
||||||
|
pass
|
||||||
"login_success":
|
"login_success":
|
||||||
_handle_login_success(data)
|
_handle_login_success(data)
|
||||||
"login_error":
|
"login_error":
|
||||||
@@ -509,9 +523,22 @@ func _on_data_received(message: String) -> void:
|
|||||||
_handle_chat_render(data)
|
_handle_chat_render(data)
|
||||||
"position_updated":
|
"position_updated":
|
||||||
_handle_position_updated(data)
|
_handle_position_updated(data)
|
||||||
|
"error":
|
||||||
|
_handle_error_response(data)
|
||||||
_:
|
_:
|
||||||
push_warning("ChatManager: 未处理的消息类型 %s" % message_type)
|
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:
|
func _handle_login_success(data: Dictionary) -> void:
|
||||||
_is_logged_in = true
|
_is_logged_in = true
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ extends Node
|
|||||||
# 负责与后端 WebSocket 服务进行位置同步和多人会话管理
|
# 负责与后端 WebSocket 服务进行位置同步和多人会话管理
|
||||||
#
|
#
|
||||||
# 协议文档: new_docs/game_architecture_design.md
|
# 协议文档: 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()
|
signal connected_to_server()
|
||||||
@@ -17,23 +21,32 @@ signal user_joined(data: Dictionary)
|
|||||||
signal user_left(data: Dictionary)
|
signal user_left(data: Dictionary)
|
||||||
signal position_updated(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 # 秒
|
const PING_INTERVAL = 25.0 # 秒
|
||||||
|
|
||||||
var _socket: WebSocketPeer
|
var _socket: WebSocketPeer
|
||||||
var _connected: bool = false
|
var _connected: bool = false
|
||||||
var _ping_timer: float = 0.0
|
var _ping_timer: float = 0.0
|
||||||
var _auth_token: String = ""
|
var _auth_token: String = ""
|
||||||
|
var _connection_error_reported: bool = false
|
||||||
|
var _is_connecting: bool = false
|
||||||
|
var _ws_url: String = DEFAULT_WS_URL
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
_socket = WebSocketPeer.new()
|
_socket = WebSocketPeer.new()
|
||||||
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
|
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
|
||||||
|
_ws_url = _resolve_ws_url()
|
||||||
|
|
||||||
func _process(delta):
|
func _process(delta):
|
||||||
_socket.poll()
|
_socket.poll()
|
||||||
|
|
||||||
var state = _socket.get_ready_state()
|
var state = _socket.get_ready_state()
|
||||||
|
|
||||||
if state == WebSocketPeer.STATE_OPEN:
|
if state == WebSocketPeer.STATE_OPEN:
|
||||||
|
_connection_error_reported = false
|
||||||
|
_is_connecting = false
|
||||||
|
|
||||||
if not _connected:
|
if not _connected:
|
||||||
_on_connected()
|
_on_connected()
|
||||||
|
|
||||||
@@ -51,19 +64,71 @@ func _process(delta):
|
|||||||
elif state == WebSocketPeer.STATE_CLOSED:
|
elif state == WebSocketPeer.STATE_CLOSED:
|
||||||
if _connected:
|
if _connected:
|
||||||
_on_disconnected()
|
_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():
|
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
|
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:
|
if err != OK:
|
||||||
push_error("LocationManager: WebSocket 连接请求失败,错误码: %d" % err)
|
push_error("LocationManager: WebSocket 连接请求失败,url=%s, 错误码: %d" % [_ws_url, err])
|
||||||
connection_error.emit()
|
connection_error.emit()
|
||||||
|
_connection_error_reported = true
|
||||||
|
_is_connecting = false
|
||||||
else:
|
else:
|
||||||
# Godot WebSocket connect is non-blocking, wait for state change in _process
|
# Godot WebSocket connect is non-blocking, wait for state change in _process
|
||||||
pass
|
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():
|
func close_connection():
|
||||||
_socket.close()
|
_socket.close()
|
||||||
|
|
||||||
|
|||||||
@@ -186,9 +186,9 @@ func set_next_spawn_name(spawn_name: String) -> void:
|
|||||||
# - 此方法会清除存储的名称,只能获取一次
|
# - 此方法会清除存储的名称,只能获取一次
|
||||||
# - 如果未设置名称,返回空字符串
|
# - 如果未设置名称,返回空字符串
|
||||||
func get_next_spawn_name() -> String:
|
func get_next_spawn_name() -> String:
|
||||||
var name = _next_spawn_name
|
var spawn_name: String = _next_spawn_name
|
||||||
_next_spawn_name = ""
|
_next_spawn_name = ""
|
||||||
return name
|
return spawn_name
|
||||||
|
|
||||||
# ============ 场景注册方法 ============
|
# ============ 场景注册方法 ============
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func _handle_movement(_delta: float) -> void:
|
|||||||
|
|
||||||
# 发送移动事件 (如果位置发生明显变化)
|
# 发送移动事件 (如果位置发生明显变化)
|
||||||
if velocity.length() > 0:
|
if velocity.length() > 0:
|
||||||
|
player_moved.emit(global_position)
|
||||||
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
|
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
|
||||||
"position": global_position
|
"position": global_position
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=6 format=3 uid="uid://rdmrm7j4iokr"]
|
[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"]
|
[ext_resource type="Texture2D" uid="uid://b4aildrnhbpl4" path="res://assets/materials/NoticeBoard.png" id="2_sprite"]
|
||||||
|
|
||||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_left_post"]
|
[sub_resource type="RectangleShape2D" id="RectangleShape2D_left_post"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ChatUI.gd - 聊天界面控制器(Enter 显示/隐藏版本)
|
# ChatUI.gd - 聊天界面控制器(T 唤起 / Enter 发送)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 聊天系统的用户界面控制器
|
# 聊天系统的用户界面控制器
|
||||||
#
|
#
|
||||||
@@ -9,8 +9,10 @@ extends Control
|
|||||||
# - 显示聊天消息历史
|
# - 显示聊天消息历史
|
||||||
# - 处理用户输入
|
# - 处理用户输入
|
||||||
# - 显示连接状态
|
# - 显示连接状态
|
||||||
# - Enter 显示/隐藏 + 点击外部取消输入状态 + 5秒自动隐藏
|
# - T 唤起聊天框,Enter 发送消息
|
||||||
# - 只有按 Enter 才会取消倒计时
|
# - 点击聊天框外部隐藏聊天框
|
||||||
|
# - 失焦后 5 秒自动隐藏
|
||||||
|
# - 显示/隐藏带 0.5s 过渡动画
|
||||||
# - Call Down: 通过 EventSystem 订阅聊天事件
|
# - Call Down: 通过 EventSystem 订阅聊天事件
|
||||||
#
|
#
|
||||||
# 使用方式:
|
# 使用方式:
|
||||||
@@ -46,6 +48,8 @@ extends Control
|
|||||||
# 聊天消息场景
|
# 聊天消息场景
|
||||||
@onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn")
|
@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 _is_typing: bool = false
|
||||||
|
|
||||||
|
# 显示/隐藏过渡动画
|
||||||
|
var _transition_tween: Tween = null
|
||||||
|
|
||||||
# 当前用户名
|
# 当前用户名
|
||||||
var _current_username: String = ""
|
var _current_username: String = ""
|
||||||
|
|
||||||
@@ -69,7 +76,7 @@ var _current_username: String = ""
|
|||||||
# 准备就绪
|
# 准备就绪
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# 初始隐藏聊天框
|
# 初始隐藏聊天框
|
||||||
hide_chat()
|
hide_chat(true)
|
||||||
|
|
||||||
# 创建隐藏计时器
|
# 创建隐藏计时器
|
||||||
_create_hide_timer()
|
_create_hide_timer()
|
||||||
@@ -80,6 +87,9 @@ func _ready() -> void:
|
|||||||
# 连接 UI 信号
|
# 连接 UI 信号
|
||||||
_connect_ui_signals()
|
_connect_ui_signals()
|
||||||
|
|
||||||
|
# 尽可能保持回车提交后输入框继续编辑状态(Godot 4.6+ 支持该属性)
|
||||||
|
_enable_keep_editing_on_submit()
|
||||||
|
|
||||||
# 清理
|
# 清理
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
# 取消事件订阅
|
# 取消事件订阅
|
||||||
@@ -93,30 +103,48 @@ func _exit_tree() -> void:
|
|||||||
if _hide_timer:
|
if _hide_timer:
|
||||||
_hide_timer.queue_free()
|
_hide_timer.queue_free()
|
||||||
|
|
||||||
|
if is_instance_valid(_transition_tween):
|
||||||
|
_transition_tween.kill()
|
||||||
|
_transition_tween = null
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 输入处理
|
# 输入处理
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# 处理全局输入
|
# 处理全局输入
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
# 检查是否按下 Enter 键
|
if not (event is InputEventKey):
|
||||||
if event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER):
|
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()
|
_handle_enter_pressed()
|
||||||
|
|
||||||
|
# 处理 T 键按下
|
||||||
|
func _handle_t_pressed() -> void:
|
||||||
|
if not _is_chat_visible:
|
||||||
|
show_chat()
|
||||||
|
|
||||||
|
# 延迟聚焦,避免同一输入事件周期冲突
|
||||||
|
call_deferred("_grab_input_focus")
|
||||||
|
|
||||||
# 处理 Enter 键按下
|
# 处理 Enter 键按下
|
||||||
func _handle_enter_pressed() -> void:
|
func _handle_enter_pressed() -> void:
|
||||||
# 如果聊天框未显示,显示它
|
# 聊天框未显示时不处理 Enter
|
||||||
if not _is_chat_visible:
|
if not _is_chat_visible:
|
||||||
show_chat()
|
|
||||||
# 使用 call_deferred 避免在同一个事件周期内触发 LineEdit 的 text_submitted 信号
|
|
||||||
call_deferred("_grab_input_focus")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 如果聊天框已显示且输入框有焦点,检查输入框内容
|
# 输入框有焦点时,发送逻辑交给 LineEdit 的 text_submitted 统一处理
|
||||||
if chat_input.has_focus():
|
if chat_input.has_focus():
|
||||||
# 如果输入框有内容,发送消息
|
|
||||||
if not chat_input.text.is_empty():
|
|
||||||
_on_send_button_pressed()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
|
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
|
||||||
@@ -136,11 +164,12 @@ func _gui_input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
# 处理点击聊天框外部区域
|
# 处理点击聊天框外部区域
|
||||||
func _handle_click_outside() -> 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 not chat_panel.get_global_rect().has_point(get_global_mouse_position()):
|
||||||
# 延迟释放输入框焦点,避免事件冲突
|
hide_chat()
|
||||||
if chat_input.has_focus():
|
|
||||||
call_deferred("_release_input_focus")
|
|
||||||
|
|
||||||
# 延迟释放输入框焦点(由 call_deferred 调用)
|
# 延迟释放输入框焦点(由 call_deferred 调用)
|
||||||
func _release_input_focus() -> void:
|
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
|
_is_chat_visible = true
|
||||||
if is_instance_valid(chat_panel):
|
|
||||||
chat_panel.show()
|
|
||||||
|
|
||||||
# 停止隐藏计时器
|
# 停止隐藏计时器
|
||||||
_stop_hide_timer()
|
_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_chat_visible = false
|
||||||
_is_typing = false
|
_is_typing = false
|
||||||
if is_instance_valid(chat_panel):
|
|
||||||
chat_panel.hide()
|
|
||||||
|
|
||||||
if is_instance_valid(chat_input) and chat_input.has_focus():
|
if is_instance_valid(chat_input) and chat_input.has_focus():
|
||||||
chat_input.release_focus()
|
chat_input.release_focus()
|
||||||
@@ -173,6 +214,25 @@ func hide_chat() -> void:
|
|||||||
# 停止隐藏计时器
|
# 停止隐藏计时器
|
||||||
_stop_hide_timer()
|
_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:
|
func _create_hide_timer() -> void:
|
||||||
_hide_timer = Timer.new()
|
_hide_timer = Timer.new()
|
||||||
@@ -202,6 +262,20 @@ func _stop_hide_timer() -> void:
|
|||||||
func _on_hide_timeout() -> void:
|
func _on_hide_timeout() -> void:
|
||||||
hide_chat()
|
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 事件处理
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -243,12 +317,30 @@ func _on_send_button_pressed() -> void:
|
|||||||
# 清空输入框
|
# 清空输入框
|
||||||
chat_input.clear()
|
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()
|
chat_input.grab_focus()
|
||||||
|
|
||||||
# 聊天输入提交(回车键)处理
|
# 聊天输入提交(回车键)处理
|
||||||
func _on_chat_input_submitted(text: String) -> void:
|
func _on_chat_input_submitted(_text: String) -> void:
|
||||||
_on_send_button_pressed()
|
_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)
|
# 事件订阅(Call Down)
|
||||||
@@ -290,10 +382,11 @@ func _on_chat_error(data: Dictionary) -> void:
|
|||||||
var error_code: String = data.get("error_code", "")
|
var error_code: String = data.get("error_code", "")
|
||||||
var message: String = data.get("message", "")
|
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)
|
# 连接状态变化处理(当前不更新UI)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -319,7 +412,6 @@ func add_message_to_history(from_user: String, content: String, timestamp: float
|
|||||||
|
|
||||||
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
|
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
|
||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
row.layout_mode = 2 # 让 VBoxContainer 接管布局,否则会重叠在同一位置
|
|
||||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
row.alignment = BoxContainer.ALIGNMENT_END if is_self else BoxContainer.ALIGNMENT_BEGIN
|
row.alignment = BoxContainer.ALIGNMENT_END if is_self else BoxContainer.ALIGNMENT_BEGIN
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ var current_page = 0
|
|||||||
var tween: Tween
|
var tween: Tween
|
||||||
var mock_pages = []
|
var mock_pages = []
|
||||||
var _chat_ui: Control
|
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
|
var _chat_ui_mouse_disabled: bool = false
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
|
|
||||||
var _chat_ui: Control
|
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
|
var _chat_ui_mouse_disabled: bool = false
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=4 format=3 uid="uid://rdmro1jxs6ga"]
|
[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"]
|
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=6 format=3 uid="uid://d8mam0n1a3b5"]
|
[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="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"]
|
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3_theme"]
|
||||||
|
|
||||||
|
|||||||
85
tests/unit/test_chat_ui_input_regressions.gd
Normal file
85
tests/unit/test_chat_ui_input_regressions.gd
Normal file
@@ -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)
|
||||||
@@ -112,7 +112,7 @@ body {
|
|||||||
|
|
||||||
<script src="index.js"></script>
|
<script src="index.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"index","experimentalVK":false,"fileSizes":{"index.pck":46883000,"index.wasm":37686550},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"index.service.worker.js"};
|
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"index","experimentalVK":false,"fileSizes":{"index.pck":47001952,"index.wasm":37686550},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"index.service.worker.js"};
|
||||||
const GODOT_THREADS_ENABLED = false;
|
const GODOT_THREADS_ENABLED = false;
|
||||||
const engine = new Engine(GODOT_CONFIG);
|
const engine = new Engine(GODOT_CONFIG);
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -4,7 +4,7 @@
|
|||||||
// Incrementing CACHE_VERSION will kick off the install event and force
|
// Incrementing CACHE_VERSION will kick off the install event and force
|
||||||
// previously cached resources to be updated from the network.
|
// previously cached resources to be updated from the network.
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
const CACHE_VERSION = '1773130384|168741112';
|
const CACHE_VERSION = '1773223762|43101372';
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
const CACHE_PREFIX = 'whaleTown-sw-cache-';
|
const CACHE_PREFIX = 'whaleTown-sw-cache-';
|
||||||
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
|
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
|
||||||
|
|||||||
Reference in New Issue
Block a user