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
+112 -41
View File
@@ -6,8 +6,8 @@
# af.start(assets_root) # assets_root 传给 game_scene
#
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
# 断线:登录/选人阶段回到登录;游戏阶段保留 GameScene,显示重连遮罩,
# 重连成功后自动重新进入上次角色,避免短暂闪回选人界面。
# 断线:所有阶段都回到 LoginPhase,并释放旧 GameScene;LoginPhase 是唯一
# 的网络/界面属主。40250 的 OnRemoteDisconnect 不保留游戏窗口重连遮罩。
extends Node
const ServerInfoRes = preload("res://net/serverinfo.gd")
@@ -16,8 +16,8 @@ const LoadingScreen = preload("res://ui/loading_screen.gd")
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
const GameScene = preload("res://game_scene.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
const UiProfile = preload("res://ui/ui_profile.gd")
const MobView = preload("res://ui/mob_view.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
enum { LOGIN, SELECT, GAME }
@@ -39,6 +39,8 @@ var _chars: Array = []
var _selected_char_slot := -1
var _reconnecting_game := false
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
var _probe_in_flight := false
var _closing := false
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty()
var _auto_char_slot := -1 # MT_CHAR_SLOT:自动登录时优先选择的角色槽位。
@@ -48,6 +50,18 @@ func _init() -> void:
if raw_slot.is_valid_int():
_auto_char_slot = int(raw_slot)
func _exit_tree() -> void:
# CPythonApplication::Destroy releases process-wide model, motion, text/UI
# and NPC resource caches after the live scene has been torn down. Keeping
# these Ref-backed caches through ObjectDB shutdown causes the same native
# leak/SIGSEGV seen in a real map + animated crowd scenario.
if ClassDB.class_exists("Metin2Model"):
Metin2Model.clear_texture_cache()
if ClassDB.class_exists("Metin2AnimPlayer"):
Metin2AnimPlayer.clear_clip_cache()
MobView.clear_cache()
UiAssets.clear_cache()
func start(assets_root: String = "", injected_client: Node = null) -> void:
_assets = assets_root
serverinfo = ServerInfoRes.new()
@@ -78,11 +92,13 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
_lifecycle.name = "AppLifecycle"
add_child(_lifecycle)
_lifecycle.bind(client)
_reconnect = ReconnectUI.new()
_reconnect.name = "ReconnectUI"
add_child(_reconnect)
_reconnect.setup(client)
_reconnect.set_mobile_mode(UiProfile.resolve() == UiProfile.Mode.MOBILE)
# 40250 WM_CLOSE enters the application exit owner. Keep the Godot
# notification on the same AppFlow owner that created the client/session so
# disconnect, scene teardown and process exit happen once and in order.
_lifecycle.close_requested.connect(_on_close_requested)
# 40250 SetLoginPhase replaces the current phase window. Do not attach the
# optional ReconnectUI to `disconnected`: doing so would reopen a reconnect
# overlay after AppFlow has already become the LoginPhase owner.
_goto_login()
if auto_login:
call_deferred("_do_connect")
@@ -103,7 +119,7 @@ func select_test_server(server_index: int, channel: int) -> Dictionary:
# Read-only adapter for the packaged playable test. It deliberately returns
# summaries rather than the private UI/game nodes so a report cannot retain a
# stale scene reference across reconnect or map reload.
# stale scene reference across a phase or map transition.
func get_playable_snapshot() -> Dictionary:
var stage := "LOGIN"
match _state:
@@ -134,7 +150,7 @@ func get_playable_context() -> Dictionary:
# --- client 信号 -------------------------------------------------------------
func _wire_client() -> void:
for sig in ["char_list", "char_name_changed", "entered_game", "disconnected", "login_failed"]:
for sig in ["char_list", "char_name_changed", "entered_game", "disconnected", "login_failed", "connect_failed", "phase_changed", "server_quit_requested"]:
if client.has_signal(sig):
client.connect(sig, Callable(self, "_on_" + sig))
# 建 / 删号结果 —— 一次性接,转发给当前选人页
@@ -144,16 +160,6 @@ func _wire_client() -> void:
func _on_char_list(list: Array) -> void:
_chars = list
if _reconnecting_game:
# A full reconnect replays the character list before it can enter the
# previous character. Do not briefly replace the live game with SELECT;
# re-enter the remembered slot and keep the reconnect mask on screen.
var slot := _selected_char_slot
if slot < 0 and not _chars.is_empty():
slot = int(_chars[0].get("index", 0))
if slot >= 0:
_enter_character(slot)
return
# 已在选人页(建号 / 删号后 M2Client 会重发 char_list)——只刷数据,别整屏重建
if _state == SELECT and _ui:
var sc: Node = _ui.get_node_or_null("CharSelect")
@@ -172,8 +178,8 @@ func _on_char_list(list: Array) -> void:
idx = _auto_char_slot
break
if idx >= 0:
# Remember the slot so a later in-game reconnect re-enters the same character.
_enter_character(idx)
# Keep the selected slot for the ordinary character-select flow.
_enter_character(idx)
func _on_char_name_changed(pid: int, name: String) -> void:
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
@@ -182,11 +188,27 @@ func _on_char_name_changed(pid: int, name: String) -> void:
func _on_entered_game() -> void:
_reconnecting_game = false
if _reconnect and _reconnect.has_method("dismiss"):
_reconnect.dismiss()
_goto_game()
_start_guild_mark_download()
func _on_phase_changed(phase: String) -> void:
# The native classic bridge reports 40250 PHASE_CLOSE as a transition back
# to LoginPhase. Explicit PHASE_CLOSE is not a transport disconnect; keep
# this phase-only owner path separate from the peer-close `disconnected`
# signal so both sources have the same single LoginPhase owner.
var p := phase.to_lower()
if p not in ["login", "close"]:
return
if _state != GAME:
if _state == SELECT:
_goto_login()
return
if _game == null or not is_instance_valid(_game):
_goto_login()
return
var reason := "服务器返回登录阶段"
_on_disconnected(reason)
# 会徽下载:serverlist 第 8 列配了 mark_port 才连(0 = 跳过)。
# `guild_mark_updated` 时按服务器 1s 冷却重连一次。
func _start_guild_mark_download() -> void:
@@ -209,26 +231,60 @@ func _on_guild_mark_updated(_guild_id: int, _img_idx: int) -> void:
_start_guild_mark_download()
func _on_login_failed(reason: String) -> void:
if _reconnecting_game:
if _reconnect and _reconnect.has_method("set_error"):
_reconnect.set_error(reason)
return
if _state != GAME:
# 40250 intrologin.OnLoginFailure closes only the connecting dialog and
# leaves the LoginPhase window alive. Preserve the existing login owner so
# its callbacks, entered text and retry controls remain valid. Rebuild only
# when the failure arrived from SELECT/another phase that has no login UI.
if _state != LOGIN or _ui == null:
_goto_login()
_set_status("登录失败:%s" % reason)
_focus_login_password()
_set_status("登录失败:%s" % reason)
func _on_connect_failed(reason: String) -> void:
# 40250 LoginWindow.OnConnectFailure is separate from OnLoginFailure: keep
# the LoginPhase owner and only report transport failure to its status/focus
# surface. A game-socket refusal must not be presented as bad credentials.
if _state != LOGIN or _ui == null:
_goto_login()
_focus_login_password()
_set_status("连接失败:%s" % reason)
func _focus_login_password() -> void:
if _ui == null:
return
var password := _ui.find_child("Password", true, false)
if password is LineEdit:
password.grab_focus()
func _on_disconnected(reason: String) -> void:
if _state == GAME and _game != null and is_instance_valid(_game):
_reconnecting_game = true
if _reconnect and _reconnect.has_method("set_error"):
_reconnect.set_error(reason)
return
# 40250 OnRemoteDisconnect -> SetLoginPhase always replaces the current
# phase window. Retaining GameScene or a reconnect overlay here creates a
# second owner and diverges from peer-close and explicit PHASE_CLOSE behavior.
_reconnecting_game = false
_goto_login()
if _reconnect and _reconnect.has_method("dismiss"):
_reconnect.dismiss()
_set_status("连接断开:%s" % reason)
func _on_server_quit_requested() -> void:
# 40250 ServerCommand("quit") posts the application quit message. Route it
# through the same lifecycle owner as WM_CLOSE so the network is disconnected,
# the retained world is reset, and the process exits exactly once.
_on_close_requested()
func _on_close_requested() -> void:
if _closing:
return
_closing = true
# Mirror CPythonApplication::Destroy's network-before-window teardown. The
# world_reset signal is emitted before the retained GameScene is released.
if client and client.has_method("disconnect_from_server"):
client.disconnect_from_server()
if _lifecycle and is_instance_valid(_lifecycle):
_lifecycle.bind(null, null)
if _game and is_instance_valid(_game):
_game.queue_free()
_game = null
get_tree().quit()
# --- 切屏 ------------------------------------------------------------------
func _clear_ui() -> void:
@@ -278,8 +334,9 @@ func _goto_game() -> void:
if is_instance_valid(_game) and "_model_built" in _game and client != null:
var wait_msec := Time.get_ticks_msec()
while not _game._model_built and (Time.get_ticks_msec() - wait_msec < 3000):
if client.has_method("net_poll"):
client.net_poll()
# M2Client._process is the single frame-pump owner, matching
# CPythonApplication::Process. Yielding lets that owner deliver
# spawn/phase packets without a second same-frame pump here.
if _game.has_method("_sync_main_character"):
_game._sync_main_character()
if _game._model_built:
@@ -330,6 +387,7 @@ func _build_login_form() -> void:
id_edit.text_changed.connect(func(t): _id = t)
box.add_child(id_edit)
var pw_edit := LineEdit.new()
pw_edit.name = "Password"
pw_edit.text = _pw
pw_edit.secret = true
pw_edit.placeholder_text = "密码"
@@ -368,6 +426,13 @@ func _refresh_channels() -> void:
# 频道负载 —— 先问 CServerStateChecker(一个连接拿全部频道状态:正常/拥挤/爆满/关闭),
# 没答上的频道回退纯 TCP 连通性(● 通 / ○ 不通)。
func _probe_channels() -> void:
if _probe_in_flight:
return
_probe_in_flight = true
await _probe_channels_impl()
_probe_in_flight = false
func _probe_channels_impl() -> void:
var s: Dictionary = serverinfo.server(_sel_server)
if s.is_empty():
return
@@ -433,7 +498,13 @@ func _do_connect() -> void:
_set_status("没有服务器")
return
_set_status("连接 %s:%d …" % [addr["game_host"], addr["game_port"]])
if client.has_method("connect_to_server"):
if client.has_method("retry_login"):
# M2Client selects the 40250 LoginWindow.Connect retry owner when the
# current LoginPhase came from a credential/connection failure, and falls
# back to the initial full connect when no retry is pending.
client.retry_login(addr["auth_host"], addr["auth_port"],
addr["game_host"], addr["game_port"], _id, _pw)
elif client.has_method("connect_to_server"):
client.connect_to_server(addr["auth_host"], addr["auth_port"],
addr["game_host"], addr["game_port"], _id, _pw)