fix: 装备属性面板避让逻辑 + 多项功能更新

- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
This commit is contained in:
shen
2026-09-21 16:38:59 -07:00
parent 1522a8a1a1
commit c93894313a
5498 changed files with 4558004 additions and 1442 deletions
+152 -15
View File
@@ -11,18 +11,28 @@ class FakeClient extends Node:
signal char_name_changed(pid: int, name: String)
signal entered_game()
signal login_failed(reason: String)
signal connect_failed(reason: String)
signal server_quit_requested()
signal char_created(slot: int)
signal char_create_failed(reason: int)
signal char_deleted(slot: int)
signal char_delete_failed()
var selects := []
var reconnects := 0
var connect_calls := 0
var retry_login_calls := 0
func select_character(index: int) -> bool:
selects.append(index)
return true
func reconnect() -> bool:
reconnects += 1
return true
func connect_to_server(_auth_host: String, _auth_port: int, _game_host: String,
_game_port: int, _id: String, _pw: String) -> void:
connect_calls += 1
func retry_login(_auth_host: String, _auth_port: int, _game_host: String,
_game_port: int, _id: String, _pw: String) -> void:
retry_login_calls += 1
var _fail := 0
@@ -46,33 +56,160 @@ func _init() -> void:
_ck(lifecycle_nodes.size() == 1, "AppFlow owns exactly one AppLifecycle")
_ck(flow.get_node_or_null("AppLifecycle") != null,
"AppLifecycle survives login screen construction")
_ck(flow._lifecycle.close_requested.is_connected(Callable(flow, "_on_close_requested")),
"AppFlow consumes the desktop close request")
var flow_file := FileAccess.open("res://app_flow.gd", FileAccess.READ)
var flow_source: String = flow_file.get_as_text() if flow_file else ""
var game_start: int = flow_source.find("func _goto_game()")
var game_end: int = flow_source.find("# --- 登录表单", game_start)
var game_body: String = flow_source.substr(game_start, game_end - game_start) if game_start >= 0 and game_end > game_start else ""
_ck(not game_body.contains("client.net_poll()"),
"AppFlow does not re-enter the M2Client frame pump while waiting for the model")
# A 40250 GC_WARP is a replacement transport, not a terminal login error.
# When the replacement connect fails synchronously, the native bridge must
# retain the reconnect owner before returning to LoginPhase; otherwise the
# next reconnect() call sees GameLogin and is incorrectly treated as a
# duplicate in-flight request. Keep this source-level guard beside the
# phase-only AppFlow behavior test so the native invariant cannot regress
# while the Godot fake client still exercises the UI owner.
var m2_path := ProjectSettings.globalize_path("res://../extension/src/net/m2_client.cpp")
var m2_file := FileAccess.open(m2_path, FileAccess.READ)
var m2_source: String = m2_file.get_as_text() if m2_file else ""
var first_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(")
var first_warp_body: String = m2_source.substr(first_warp_start, 700) \
if first_warp_start >= 0 else ""
var second_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(", first_warp_start + 1)
var second_warp_body: String = m2_source.substr(second_warp_start, 700) \
if second_warp_start >= 0 else ""
_ck(not first_warp_body.contains("classic_remote_disconnect_pending = true")
and not first_warp_body.contains("emit_signal(\"disconnected\""),
"fast DirectEnter connect failure stays on the ClosePhase owner")
_ck(not second_warp_body.contains("classic_remote_disconnect_pending = true")
and not second_warp_body.contains("emit_signal(\"disconnected\""),
"GC_WARP connect failure stays on the ClosePhase owner")
# 40250 keeps RecvPhasePacket(PHASE_CLOSE) separate from
# OnRemoteDisconnect: ClosePhase -> SetLoginPhase is a phase-owner change,
# not a transport failure. The native bridge must therefore not synthesize a
# disconnected signal or arm the remote-disconnect retry owner here.
var phase_close_start: int = m2_source.find("classic_sess->on_phase_close = [this]()")
var phase_close_end: int = m2_source.find("classic_sess->on_phase_leave", phase_close_start)
var phase_close_body: String = m2_source.substr(phase_close_start,
phase_close_end - phase_close_start) if phase_close_start >= 0 and phase_close_end > phase_close_start else ""
_ck(not phase_close_body.contains("classic_remote_disconnect_pending = true")
and not phase_close_body.contains("emit_signal(\"disconnected\""),
"explicit PHASE_CLOSE does not synthesize transport disconnected")
# An empty 40250 account is a valid SelectPhase result. The live probe must
# be able to verify that packet/UI owner without treating the absence of a
# selectable slot as a login failure or trying to enter a stale slot.
var e2e_path := ProjectSettings.globalize_path("res://../extension/tools/net_classic_e2e.cpp")
var e2e_file := FileAccess.open(e2e_path, FileAccess.READ)
var e2e_source: String = e2e_file.get_as_text() if e2e_file else ""
_ck(e2e_source.contains("MT_CLASSIC_EXPECT_EMPTY")
and e2e_source.contains("PASS EMPTY CHARACTER LIST"),
"live probe treats an all-empty character list as a valid SelectPhase result")
_ck(client.get_parent() == flow,
"AppFlow keeps the same client beside lifecycle coordinator")
var login_snapshot: Dictionary = flow.get_playable_snapshot()
_ck(login_snapshot.get("stage", "") == "LOGIN" and not login_snapshot.get("scene_ready", true),
"playable snapshot is a read-only LOGIN summary")
# A live game keeps its scene during a transient disconnect. ReconnectUI
# owns the retry while the next character list silently re-enters the last
# selected slot instead of flashing the character-select page.
# 40250 intrologin.OnLoginFailure closes only the connecting dialog. The
# LoginPhase window remains the owner of the retry, including its password
# field and status surface; rebuilding the whole layer changes the phase
# owner and can strand a pending retry callback on the old UI.
var login_ui_before: Node = flow._ui
var login_fields: Array[Node] = flow._ui.find_children("*", "LineEdit", true, false)
var password_before: Node = login_fields[1] if login_fields.size() > 1 else null
client.login_failed.emit("WRONGPWD")
await process_frame
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
"login failure keeps the existing LoginPhase UI for retry")
_ck(password_before != null, "login UI has a password field for retry focus")
var login_status := flow._ui.find_child("Status", true, false)
_ck(login_status != null and String(login_status.text).contains("WRONGPWD"),
"login failure is rendered in the existing status surface")
client.connect_failed.emit("ECONNREFUSED")
await process_frame
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
"game connect failure keeps the existing LoginPhase UI for retry")
_ck(login_status != null and String(login_status.text).contains("ECONNREFUSED"),
"game connect failure uses the transport status callback, not credential failure")
# 40250 LoginWindow.Connect reuses the existing MainStream owner and only
# replaces the AccountConnector transport after a credential failure. The
# AppFlow retry must therefore use the bridge's retry_login seam instead of
# treating a still-owned LoginPhase as a full disconnect/rebuild.
flow._do_connect()
_ck(client.retry_login_calls == 1 and client.connect_calls == 0,
"login form retry replaces only the authentication transport")
var retry_start: int = m2_source.find("void M2Client::retry_login")
var retry_end: int = m2_source.find("void M2Client::disconnect_from_server", retry_start)
var retry_body: String = m2_source.substr(retry_start, retry_end - retry_start) \
if retry_start >= 0 and retry_end > retry_start else ""
_ck(retry_body.contains("classic_sess->retry_login")
and not retry_body.contains("disconnect_from_server()"),
"native LoginPhase retry does not rebuild the whole connection owner")
# 40250 CPythonNetworkStream::OnRemoteDisconnect calls SetLoginPhase.
# SetLoginPhase replaces GameWindow with LoginWindow; it does not retain the
# GameScene behind a reconnect mask or auto-select a remembered slot.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
flow._selected_char_slot = 2
client.disconnected.emit("wifi lost")
_ck(flow.state() == AppFlow.GAME and flow._reconnecting_game,
"game disconnect enters reconnecting state without leaving GAME")
_ck(flow._reconnect != null and flow._reconnect.is_showing(),
"game disconnect shows reconnect overlay")
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
and flow._game == null,
"game peer close returns to LoginPhase and releases GameScene")
_ck(flow._reconnect == null or not flow._reconnect.is_showing(),
"game peer close does not retain a reconnect overlay")
client.char_list.emit([{"index": 2, "name": "Hero"}])
_ck(client.selects == [2], "reconnect re-enters remembered character slot")
client.entered_game.emit()
_ck(flow.state() == AppFlow.GAME and not flow._reconnecting_game
and not flow._reconnect.is_showing(),
"successful reconnect restores GAME and hides overlay")
var game_snapshot: Dictionary = flow.get_playable_snapshot()
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
"playable snapshot reports GAME after reconnect")
_ck(flow.state() == AppFlow.SELECT and flow._game == null and client.selects.is_empty(),
"post-disconnect character list enters SELECT without phantom re-entry")
# 40250 PHASE_CLOSE -> ClosePhase -> SetLoginPhase has the same LoginPhase
# owner even when the bridge reports only the phase observer event.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
flow._reconnecting_game = false
client.phase_changed.emit("login")
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
and flow._game == null
and (flow._reconnect == null or not flow._reconnect.is_showing()),
"game-side PHASE_CLOSE transition returns to LoginPhase owner")
# The explicit PHASE_CLOSE spelling must use the same owner path as the
# native phase-only login notification.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
client.phase_changed.emit("close")
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
and flow._game == null,
"explicit PHASE_CLOSE observer releases GameScene for LoginPhase")
# 40250 SetSelectCharacterPhase still owns the UI when LoginPhase receives an
# empty character list. The empty list must remain SELECT and never select a
# stale slot.
flow._state = AppFlow.LOGIN
flow._reconnecting_game = false
client.char_list.emit([])
_ck(flow.state() == AppFlow.SELECT and not flow._reconnecting_game
and flow._game == null and client.selects.is_empty(),
"empty character list returns to the SELECT owner")
# 40250 ClosePhase/SetLoginPhase also owns the UI when a close arrives
# before GAME. The phase-only bridge event must not leave the old SELECT
# owner visible after the loading mask is dismissed.
var select_ui_before_close: Node = flow._ui
client.phase_changed.emit("login")
_ck(flow.state() == AppFlow.LOGIN and flow._ui != select_ui_before_close,
"pre-game LoginPhase transition replaces the SELECT owner")
_ck(client.server_quit_requested.is_connected(Callable(flow, "_on_server_quit_requested")),
"server quit command is wired to the application lifecycle owner")
# Keep the following auto-login checks independent when the assertion above
# is intentionally run against a pre-fix implementation.
flow._reconnecting_game = false
# Auto-login with an explicit MT_CHAR_SLOT must never fall back to another
# character, and the chosen slot must be remembered for a later reconnect.