fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
This commit is contained in:
+187
-10
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user