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
+187 -10
View File
@@ -66,6 +66,14 @@ const WorldTime = preload("res://world/world_time.gd")
const Weather = preload("res://fx/weather.gd")
const QuestCurtain = preload("res://ui/quest_curtain.gd")
# game.py::__IsXMasMap — these maps suppress the server xmas_snow command.
const XMAS_SNOW_EXCLUDED_MAPS := {
"metin2_map_n_flame_01": true,
"metin2_map_n_desert_01": true,
"metin2_map_spiderdungeon": true,
"metin2_map_deviltower1": true,
}
# 龙魂是协议兼容层的一部分,但龙魂 40250 服务器没有这个功能。
# 保留 DragonSoulUI / ds_refine 代码供后续兼容,当前客户端不创建入口、不消费背包右键。
const ENABLE_DRAGON_SOUL := false
@@ -119,6 +127,7 @@ var atlas_ui: Node # AtlasUI
var dungeon_state: Node # GC_DUNGEON destination / compass state
var world_time: Node # WorldTime
var weather: Node3D # Weather
var _xmas_snow_enabled := false
var _assets := ""
var map_path := ""
var _model_built := false
@@ -208,7 +217,7 @@ func setup(m2client: Node, assets_root: String,
net_world = NetWorld.new()
add_child(net_world)
net_world.world = world
net_world.setup(client, _mount)
net_world.setup(client, _mount, _assets)
# §8.8 屏幕空间实体血条:明确绑定轨道相机,避免依赖 viewport 当前相机,
# 也让地图切换 / 多视口时投影来源稳定。
if net_world.has_method("set_camera"):
@@ -216,6 +225,8 @@ func setup(m2client: Node, assets_root: String,
net_world.set_local_node(player)
net_world.entity_added.connect(_on_pickable_added)
net_world.entity_removed.connect(_on_pickable_removed)
if net_world.has_signal("actor_motion_event"):
net_world.actor_motion_event.connect(_on_remote_motion_event)
if client.has_signal("entity_dead"):
client.entity_dead.connect(func(vid: int) -> void: _drop_stale_pickables(vid))
@@ -271,6 +282,14 @@ func setup(m2client: Node, assets_root: String,
add_child(_audio)
if assets_root != "":
_audio.setup(assets_root)
# CPythonNetworkStream plays the item-specific use/drop sound immediately
# before the corresponding CG item packet. Keep the native event as the
# single source so classic and m2dev callers cannot diverge in the UI.
if client and client.has_signal("item_action_sound"):
client.item_action_sound.connect(func(sound: String):
if _audio and _audio.has_method("play_ui"):
_audio.play_ui(sound)
)
await _yield()
# §9.1 地图 BGM:M2Client.bgm_changed -> Audio。放在独立节点里,W4 补全曲目解析。
@@ -411,6 +430,8 @@ func setup(m2client: Node, assets_root: String,
client.emoticon_requested.connect(_play_emoticon)
if client.has_signal("gift_available"):
client.gift_available.connect(_on_gift_available)
if client.has_signal("weather_changed"):
client.weather_changed.connect(_on_weather_changed)
# 任务 / NPC(P7)—— 对话窗自动弹,J 键任务日志
quest_dialog = QuestDialog.new()
@@ -472,6 +493,11 @@ func setup(m2client: Node, assets_root: String,
ip = assets_root.path_join("locale/locale/en/item_proto")
if FileAccess.file_exists(ip):
proto.call("load_item_proto", ip)
# CPythonPlayer::IsEquipItemInSlot and SetItemData both consult the
# same ItemManager table. Share this authority with native senders so
# equipment action gates do not infer type from the window alone.
if client and client.has_method("set_item_proto"):
client.call("set_item_proto", proto)
if quickbar and "proto" in quickbar:
quickbar.proto = proto
await _yield()
@@ -630,11 +656,15 @@ func setup(m2client: Node, assets_root: String,
quest_dialog.map_signal_added.connect(func(x: float, y: float):
if minimap and minimap.has_method("add_signal_point"):
minimap.add_signal_point(x, y)
if atlas_ui and atlas_ui.has_method("add_signal_point"):
atlas_ui.add_signal_point(x, y)
if atlas_ui and atlas_ui.has_method("open"):
atlas_ui.open())
quest_dialog.map_signals_cleared.connect(func():
if minimap and minimap.has_method("clear_signal_points"):
minimap.clear_signal_points())
minimap.clear_signal_points()
if atlas_ui and atlas_ui.has_method("clear_signal_points"):
atlas_ui.clear_signal_points())
quest_dialog.atlas_center_requested.connect(func(x: int, y: int):
if atlas_ui and atlas_ui.has_method("set_center_position_adjust"):
atlas_ui.set_center_position_adjust(x, y))
@@ -647,6 +677,7 @@ func setup(m2client: Node, assets_root: String,
weather = Weather.new()
add_child(weather)
weather.setup(cam)
_apply_server_weather()
# 系统设置 / 游戏设置 / ESC 系统菜单(uisystem.SystemDialog + uiSystemOption + uiGameOption)
system_option_ui = SystemOptionUI.new()
add_child(system_option_ui)
@@ -955,8 +986,34 @@ func _on_local_motion_event(type: int, effect: String, sound: String, pos: Vecto
# FIFO pop = AutoClear + 连射多箭在途各自一发。
if client and client.has_method("shoot") and not _pending_shots.is_empty():
client.shoot(_pending_shots.pop_front())
9: # WARP —— 传送起点,不是投射物
pass
9: # WARP —— CActorInstance::ProcessMotionEventWarp
# 40250 在事件帧把角色放到目标前 270cm,并通过 OnWarp
# 发送一次 FUNC_WAIT;这不是网络 GC_WARP 的换图流程。
_apply_motion_warp(anchor, _motion_effect_target.get_ref() if _motion_effect_target else null, true)
func _apply_motion_warp(actor: Node3D, target: Node3D, notify_server: bool = false) -> bool:
# ActorInstanceMotionEvent.cpp 使用 pixel 单位的 270cm;当前世界单位是米。
# 目标向量保留垂直分量用于落点,朝向则和 LookAt(x, y) 一样只取地面平面。
if not is_instance_valid(actor) or not is_instance_valid(target) or actor == target:
return false
var actor_pos := actor.global_position
var target_pos := target.global_position
var to_target := target_pos - actor_pos
if to_target.length_squared() <= 0.000001:
return false
var direction := to_target.normalized()
var destination := target_pos - direction * 2.70
var blocked := false
if world != null and world.has_method("is_blocked"):
blocked = bool(world.call("is_blocked", destination.x, destination.z))
if not blocked:
actor.global_position = destination
var ground_direction := Vector3(to_target.x, 0.0, to_target.z)
if ground_direction.length_squared() > 0.000001:
actor.rotation.y = atan2(ground_direction.x, ground_direction.z)
if notify_server and net_play != null and net_play.has_method("_send_state"):
net_play.call("_send_state", NetPlay.FUNC_WAIT, 0, actor.global_position)
return not blocked
var _motion_effect_target: WeakRef
@@ -982,13 +1039,22 @@ func _on_skill_cast_started(skill: int, target_vid: int) -> void:
var dur := 2.0
var is_charge := false
var cancel_enable := false
var motion_driven := false
var motion_loop_count := 0
var moving_skill := false
if skill_table:
if skill_table.has_method("is_charge_skill"):
is_charge = skill_table.is_charge_skill(skill)
if skill_table.has_method("is_moving_skill"):
moving_skill = bool(skill_table.call("is_moving_skill", skill))
if skill_table.has_method("motion_loop_count_of"):
motion_loop_count = int(skill_table.call("motion_loop_count_of", skill, _skill_level(skill)))
if is_instance_valid(pv_node) and pv_node.has_method("play_skill_motion"):
var mname: String = skill_table.motion_name_of(skill)
if mname != "":
var played: bool = pv_node.play_skill_motion(mname, _skill_master(skill))
var played: bool = pv_node.play_skill_motion(mname, _skill_master(skill),
motion_loop_count, moving_skill)
motion_driven = played
if played and "anim" in pv_node and pv_node.anim:
if pv_node.anim.has_method("get_duration"):
var d: float = float(pv_node.anim.get_duration())
@@ -1000,24 +1066,94 @@ func _on_skill_cast_started(skill: int, target_vid: int) -> void:
cancel_enable = true
if net_play:
if net_play.has_method("start_skill_cast"):
net_play.start_skill_cast(dur, is_charge, cancel_enable)
net_play.start_skill_cast(dur, is_charge, cancel_enable, motion_driven)
else:
net_play._using_skill = true
func _on_local_motion_event_detailed(event: Dictionary) -> void:
# Keep sound/projectile dispatch exactly once, independent of visual routing.
if int(event.type) == 1 and String(event.effect) != "":
var event_type := int(event.get("type", -1))
if event_type == 7 or event_type == 8:
# 40250 __ShowEvent / __HideEvent changes the actor render state at the
# exact motion frame; it is not an effect and has no payload of its own.
_on_local_motion_event(event_type, "", String(event.get("sound", "")),
event.get("pos", Vector3.ZERO))
if is_instance_valid(player) and player.has_method("set_motion_hidden"):
player.set_motion_hidden(event_type == 8)
elif event_type == 1 and String(event.get("effect", "")) != "":
_on_local_motion_event(int(event.type), "", String(event.sound), event.pos)
if fx and is_instance_valid(player):
fx.spawn_motion(event, player, _mount)
elif int(event.type) == 10:
elif event_type == 10:
_on_local_motion_event(int(event.type), "", String(event.sound), event.pos)
var target: Node3D = _motion_effect_target.get_ref() if _motion_effect_target else null
if fx and is_instance_valid(target):
fx.spawn_target(event, target, _mount)
elif event_type == 4:
# 40250 ProcessMotionEventSpecialAttacking only establishes the
# actor-local SplashArea here; AttackProcess performs collision and
# OnHit on subsequent frames. Keep sound dispatch separate and do not
# turn this event into a synthetic ordinary attack packet.
_on_local_motion_event(int(event.type), "", String(event.get("sound", "")),
event.get("pos", Vector3.ZERO))
if net_play and net_play.has_method("start_motion_splash"):
net_play.start_motion_splash(event)
elif event_type == 6:
# 40250 ProcessMotionEventFly creates the local .msf projectile at the
# motion frame, while its handler's OnShoot sends CG_SHOOT separately.
_on_local_motion_event(int(event.type), "", String(event.get("sound", "")),
event.get("pos", Vector3.ZERO))
var target: Node3D = _motion_effect_target.get_ref() if _motion_effect_target else null
if not is_instance_valid(target) and net_play and net_world:
var target_vid := int(net_play.get("_target_vid"))
var local_vid := -1
if client and client.has_method("get_main_vid"):
local_vid = int(client.get_main_vid())
if target_vid == local_vid:
target = player
elif target_vid != 0 and net_world.has_method("node_for"):
target = net_world.node_for(target_vid)
if net_world and net_world.has_method("spawn_motion_fly") and is_instance_valid(target):
var skill_index := int(_pending_shots[0]) if not _pending_shots.is_empty() else 0
net_world.spawn_motion_fly(player, event, target, skill_index)
elif event_type == 9:
_on_local_motion_event(int(event.type), "", String(event.get("sound", "")),
event.get("pos", Vector3.ZERO))
else:
_on_local_motion_event(int(event.type), String(event.effect), String(event.sound), event.pos)
func _on_remote_motion_event(vid: int, event: Dictionary) -> void:
# Remote actors use the same MotionEvent payload, but their visual side
# effects must be rooted at that actor. Never call client.shoot here: in
# 40250 remote projectile creation is server-authoritative GC_CREATE_FLY.
var actor: Node3D = net_world.node_for(vid) if net_world and net_world.has_method("node_for") else null
if not is_instance_valid(actor):
return
var event_type := int(event.get("type", -1))
if event_type == 4:
# ActorInstanceMotionEvent creates SplashArea for every visible actor;
# remote hits are presentation-only here and must not send CG_ATTACK.
if net_play and net_play.has_method("start_remote_motion_splash"):
net_play.start_remote_motion_splash(vid, event)
elif event_type == 1 and fx and String(event.get("effect", "")) != "":
fx.spawn_motion(event, actor, _mount)
elif event_type == 10 and fx:
var victim_vid := int(actor.get_meta("motion_victim_vid", 0))
var target: Node3D = net_world.node_for(victim_vid) if victim_vid != 0 else null
if is_instance_valid(target):
fx.spawn_target(event, target, _mount)
elif event_type == 7 or event_type == 8:
if actor.has_method("set_motion_hidden"):
actor.set_motion_hidden(event_type == 8)
elif event_type == 9:
var victim_vid := int(actor.get_meta("motion_victim_vid", 0))
var target: Node3D = net_world.node_for(victim_vid) if victim_vid != 0 else null
_apply_motion_warp(actor, target, false)
var sound := String(event.get("sound", ""))
if sound != "" and _audio and _audio.has_method("play_character_sound_3d"):
_audio.play_character_sound_3d(actor.global_position.x, actor.global_position.y,
actor.global_position.z, sound, true)
func _set_player_affect(type: int, visible: bool) -> void:
if player and player.has_method("set_affect"):
player.set_affect(type, visible, fx)
@@ -1122,6 +1258,13 @@ func _skill_master(sid: int) -> int:
return int(s.get("master", 0))
return 0
func _skill_level(sid: int) -> int:
if client and client.has_method("get_skills"):
for s in client.get_skills():
if int(s.get("id", -1)) == sid:
return int(s.get("level", 0))
return 0
# GC_WARP —— 40250 的参考端无论是否“同服”都会重连到 lAddr:wPort;
# world_reset 先清旧实体,随后这里重载地图并把玩家放到新坐标。
func _on_warp(pos: Vector3, same_server: bool) -> void:
@@ -1130,8 +1273,27 @@ func _on_warp(pos: Vector3, same_server: bool) -> void:
return
_place_player_at_net_pos(pos)
func _on_weather_changed(enabled: bool) -> void:
# 40250 game.py receives xmas_snow through ServerCommandParser and applies
# the map exclusion in __IsXMasMap before calling background.EnableSnow.
_xmas_snow_enabled = enabled
_apply_server_weather()
func _apply_server_weather() -> void:
if weather == null or not is_instance_valid(weather):
return
var map_name := map_path.get_file().to_lower()
var allowed := not XMAS_SNOW_EXCLUDED_MAPS.has(map_name)
weather.set_weather("snow" if _xmas_snow_enabled and allowed else "none")
func _on_world_reset() -> void:
_motion_effect_target = null
# CNormalBowAttack_FlyEventHandler_AutoClear 是旧 Actor 的生命周期对象。
# Loading/换图时它不会把旧挥击的 FLY 帧带到新地图;当前端的等价物是
# 清掉尚未消费的 CG_SHOOT 技能队列,避免新动作误发旧箭。
_pending_shots.clear()
if net_play and net_play.has_method("clear_for_map_change"):
net_play.clear_for_map_change()
_main_generation += 1
_main_view_key = ""
_main_attempts = 0
@@ -1170,10 +1332,16 @@ func _reload_map_for_warp(pos: Vector3) -> void:
var server_pos := Vector2(pos.x * 100.0, -pos.z * 100.0)
var next_map := _resolve_map_path(map_path, server_pos)
var old_world := world
map_path = next_map
_build_world(_assets, map_path)
# ClientVS22 unloads/clears the current CMapManager before LoadMap creates
# the replacement. queue_free alone leaves terrain/object roots and native
# map caches alive until a later frame, allowing old/new maps to overlap.
if old_world and is_instance_valid(old_world) and old_world.has_method("unload_map"):
old_world.call("unload_map")
if old_world and is_instance_valid(old_world) and old_world != world:
old_world.queue_free()
map_path = next_map
_build_world(_assets, map_path)
_apply_server_weather()
if cam:
cam.world = world
if pc:
@@ -1813,6 +1981,15 @@ func _sync_main_character() -> void:
func lifecycle_audio() -> Node:
return _audio
func _exit_tree() -> void:
# CPythonApplication::Destroy explicitly releases map-owned resources. The
# native world is a child Node, so make teardown deterministic when this
# scene is freed directly during reconnect or a headless regression.
if _audio and is_instance_valid(_audio) and _audio.has_method("shutdown"):
_audio.call("shutdown")
if world and is_instance_valid(world) and world.has_method("unload_map"):
world.call("unload_map")
func _make_placeholder_player() -> Node3D:
var root := Node3D.new()
root.name = "LocalPlayer"