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
+605 -57
View File
@@ -29,6 +29,7 @@ const TextMetrics = preload("res://text_metrics.gd")
const ScreenHpBar = preload("res://screen_hp_bar.gd")
const PhysicsPush = preload("res://physics_push.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd")
signal entity_added(node: Node3D, vid: int)
signal entity_removed(vid: int)
@@ -38,8 +39,10 @@ signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, appe
signal fly_exploded(world_pos: Vector3, cause: String) # §3.6 飞行物命中 / 超程 / 触地
signal fishing_event(vid: int, subheader: int, dir: int)
signal motion_event(vid: int, victim_vid: int, motion: int)
signal actor_motion_event(vid: int, event: Dictionary)
signal vitals(vid: int, hp: int, max_hp: int, dead: bool)
signal damage_number(vid: int, amount: int, crit: bool, dodge: bool)
signal point_effect(vid: int, point_type: int)
# CInstanceBase::FUNC_*
const FUNC_WAIT := 0
@@ -49,6 +52,50 @@ const FUNC_COMBO := 3
const FUNC_MOB_SKILL := 4
const FUNC_EMOTION := 5
const FUNC_SKILL := 0x80
# CRaceMotionData::NAME_NORMAL_ATTACK; combo segments start at 14.
const MOTION_NORMAL_ATTACK := 13
# `playersettingmodule.py` registers these exact indexed resources before the
# game phase starts; GC_CREATE_FLY carries the index, not a replacement speed.
const INDEXED_FLY_FILES := {
FlyObject.FLY_EXP: "d:/ymir work/effect/etc/gathering/ga_piece_yellow_small2.msf",
FlyObject.FLY_HP_MEDIUM: "d:/ymir work/effect/etc/gathering/ga_piece_red_small.msf",
FlyObject.FLY_HP_BIG: "d:/ymir work/effect/etc/gathering/ga_piece_red_big.msf",
FlyObject.FLY_SP_SMALL: "d:/ymir work/effect/etc/gathering/ga_piece_blue_warrior_small.msf",
FlyObject.FLY_SP_MEDIUM: "d:/ymir work/effect/etc/gathering/ga_piece_blue_small.msf",
FlyObject.FLY_SP_BIG: "d:/ymir work/effect/etc/gathering/ga_piece_blue_big.msf",
FlyObject.FLY_FIREWORK1: "d:/ymir work/effect/etc/firecracker/firecracker_1.msf",
FlyObject.FLY_FIREWORK2: "d:/ymir work/effect/etc/firecracker/firecracker_2.msf",
FlyObject.FLY_FIREWORK3: "d:/ymir work/effect/etc/firecracker/firecracker_3.msf",
FlyObject.FLY_FIREWORK4: "d:/ymir work/effect/etc/firecracker/firecracker_4.msf",
FlyObject.FLY_FIREWORK5: "d:/ymir work/effect/etc/firecracker/firecracker_5.msf",
FlyObject.FLY_FIREWORK6: "d:/ymir work/effect/etc/firecracker/firecracker_6.msf",
FlyObject.FLY_FIREWORK_XMAS: "d:/ymir work/effect/etc/firecracker/firecracker_xmas.msf",
FlyObject.FLY_CHAIN_LIGHTNING: "d:/ymir work/pc/shaman/effect/pokroe.msf",
FlyObject.FLY_HP_SMALL: "d:/ymir work/effect/etc/gathering/ga_piece_red_smallest.msf",
FlyObject.FLY_SKILL_MUYEONG: "d:/ymir work/pc/sura/effect/muyeong_fly.msf",
}
# CFlyingManager::TIndexFlyData::byType,必须和 playersettingmodule.py 的
# RegisterIndexedFlyData 第二个参数保持一一对应。资源路径相同不代表创建语义相同:
# FIRE_CRACKER 使用随机位置目标,AUTO_FIRE 只改变起点高度。
const INDEXED_FLY_TYPES := {
FlyObject.FLY_EXP: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_HP_MEDIUM: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_HP_BIG: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_SP_SMALL: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_SP_MEDIUM: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_SP_BIG: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_FIREWORK1: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER,
FlyObject.FLY_FIREWORK2: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER,
FlyObject.FLY_FIREWORK3: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER,
FlyObject.FLY_FIREWORK4: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER,
FlyObject.FLY_FIREWORK5: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER,
FlyObject.FLY_FIREWORK6: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER,
FlyObject.FLY_FIREWORK_XMAS: FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER,
FlyObject.FLY_CHAIN_LIGHTNING: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_HP_SMALL: FlyObject.INDEX_FLY_TYPE_NORMAL,
FlyObject.FLY_SKILL_MUYEONG: FlyObject.INDEX_FLY_TYPE_AUTO_FIRE,
}
# packet.h EWalkModes(GC_WALK_MODE / GC_CHARACTER_ADD_INFO.bWalkMode)
const WALKMODE_RUN := 0
const WALKMODE_WALK := 1
@@ -103,6 +150,10 @@ var snap_dist := 6.0
var _by_vid := {} # vid:int -> Node3D
# §3.7 受击击退:vid -> {obj: PhysicsPush(CPhysicsObject), off: Vector2 累计位移 cm, base: 推开时的 e.pos}
var _push := {}
# GC_SYNC_POSITION / CActorInstance::TEMP_Push. The authoritative entity
# already contains the server target; this map keeps only the presentation
# offset that eases the old actor position into that target for one second.
var _sync_push := {}
var skill_table: RefCounted
var fx: RefCounted
var _main_vid := 0
@@ -112,8 +163,6 @@ var _model_factory: Callable # func(Dictionary) -> Node3D
var _upgrade_queue: Array[int] = [] # Throttled queue for replacing placeholder capsules with real models
const MAX_UPGRADES_PER_FRAME := 4
const MAX_UPGRADE_TIME_MSEC := 4
const VISIBILITY_CHECK_INTERVAL := 0.1
var _vis_accum := 0.0
var name_resolver: Callable = Callable(): # func(entity_dict) -> String(怪名走 mob_proto)
set(v):
name_resolver = v
@@ -125,7 +174,7 @@ var _chat_tails := {} # §8.8 vid:int -> {text,color,name_fla
var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode}
var _duel_opponents := {} # server-provided duel opponent VID set
var _duel_cannot_attack := false
var _fading := {} # vid:int -> true while a culled node tweens out (not re-created until done)
var _fading := {} # vid:int -> true while an old node tweens out; a new same-VID spawn may replace it
# §8.4 / §8.6 头顶名字「是否显示」(name_show.gd)。game.py::__IsShowName() 的两个来源:
# _always_show_name —— uigameoption「总显示名字」radio(CPythonSystem::IsAlwaysShowName)
# _alt_held —— LALT 按住(game.py ShowName/HideName 的 ShowNameFlag)
@@ -142,6 +191,8 @@ var _show_damage := DamageEffect.SHOW_DAMAGE_DEFAULT
var _damage_queue: Array = [] # m_DamageQueue:{vid, amount, flag, self, target}
var _dmg_self_toggle := false # m_bDamageEffectType(SELFDAMAGE / SELFDAMAGE2 交替)
var _fly_mgr: Node # FlyManager(fly_object.gd)—— GC_CREATE_FLY 的真实弹道 + 命中判定。§3.6
var _assets_root := ""
var _fly_data_cache := {}
var _hp_layer: CanvasLayer # 屏幕空间尾标层(低于 HUD / UiManager)
var _hp_overlay: Control
var _hp_bars := {} # vid:int -> ScreenHpBar
@@ -160,9 +211,10 @@ func can_fishing_position(gx_m: float, gz_m: float, heading_deg: float) -> bool:
return true
return bool(world.call("can_fishing_position", gx_m, gz_m, heading_deg))
func setup(m2client: Node, mount: Node3D) -> void:
func setup(m2client: Node, mount: Node3D, assets_root_path: String = "") -> void:
client = m2client
parent = mount
_assets_root = assets_root_path
client.entity_spawned.connect(_on_spawn)
client.entity_despawned.connect(_on_despawn)
client.entity_main_set.connect(_on_main_set)
@@ -178,6 +230,8 @@ func setup(m2client: Node, mount: Node3D) -> void:
client.entity_moved.connect(_on_moved)
client.vitals_changed.connect(_on_vitals)
client.entity_dead.connect(_on_dead)
if client.has_signal("point_changed"):
client.point_changed.connect(_on_point_changed)
client.damage.connect(_on_damage)
if client.has_signal("motion"):
client.motion.connect(_on_motion)
@@ -205,7 +259,10 @@ func setup(m2client: Node, mount: Node3D) -> void:
_fly_mgr = FlyObject.new()
_fly_mgr.name = "FlyManager"
add_child(_fly_mgr)
_fly_mgr.setup(parent, world)
_fly_mgr.setup(parent, world, _assets_root)
_fly_mgr.set_actor_provider(Callable(self, "_fly_collision_actors"))
if fx != null:
_fly_mgr.set_effect_registry(fx)
_fly_mgr.exploded.connect(func(wp: Vector3, cause: String): fly_exploded.emit(wp, cause))
_ensure_hp_overlay()
@@ -386,15 +443,29 @@ func _set_name_visibility(root: Node3D) -> void:
# called from M2Client.world_reset before the replacement connection sends its
# first GC_MAIN_CHARACTER/GC_CHARACTER_ADD burst.
func clear_for_map_change() -> void:
if _fly_mgr and _fly_mgr.has_method("clear_for_map_change"):
_fly_mgr.clear_for_map_change()
for vid in _by_vid.keys():
var node: Node3D = _by_vid[vid]
if is_instance_valid(node):
_set_network_state_gate(node, false, true, false, true)
_set_entity_knockdown(int(vid), false)
node.queue_free()
entity_removed.emit(int(vid))
_by_vid.clear()
_clear_hp_bars()
# 40250 destroys the target effect and all transient text/model work when
# entering Loading. These are not owned by _by_vid, so clearing actors
# alone would let old presentation state leak into the next map.
_detach_target_effect()
_upgrade_queue.clear()
_chat_tails.clear()
_target_vid = 0
_hover_vid = 0
_fading.clear()
_damage_queue.clear()
_push.clear()
_sync_push.clear()
_main_vid = 0
_pvp_relations.clear()
_duel_opponents.clear()
@@ -410,20 +481,33 @@ func main_node() -> Node3D:
func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void:
var vid := int(d.get("vid", 0))
if vid == 0 or vid == _local_vid or vid == _main_vid or bool(d.get("is_main", false)):
# A main VID is only suppressed when GameScene has explicitly assigned a
# separate local player node. Headless/adaptor hosts may use NetWorld's
# normal node for the main entity, and the reference manager still exposes
# that instance through GetMainInstancePtr().
if vid == 0 or (vid == _local_vid and _local_vid != 0):
return
# §2.1: a node that is blending out is still "present" until the tween frees
# it — don't rebuild it mid-fade (mirrors GetInstancePtr/UpdateDeleting).
# §2.1: RemoveActor moves the old instance to the reference dead list, but
# AppendActor immediately calls GetInstancePtr/creates a new live instance
# for the same VID. The old blend-out may finish independently; it must not
# suppress a replacement actor or its authoritative spawn snapshot.
if _fading.has(vid):
return
_fading.erase(vid)
# §2.3 one-overwrite rule: a fresh Spawn for a VID that already has a node
# destroys and recreates the node (the data row was whole-replaced).
var keep_pos: Variant = null
var keep_rot: Variant = null
var existing: Node3D = _by_vid.get(vid, null)
if is_instance_valid(existing):
keep_pos = existing.global_position
keep_rot = existing.rotation.y
# NetworkActorManager::__AppendCharacterManagerActor only preserves the
# old pixel position when the mount state changes. A normal same-VID
# re-add (including a respawn/VID reuse) is created at the authoritative
# SCreateData position and rotation from the new packet.
var old_mounted := int(existing.get_meta("mount_vnum", 0)) != 0
var new_mounted := int(d.get("mount_vnum", 0)) != 0
if old_mounted != new_mounted:
keep_pos = existing.global_position if existing.is_inside_tree() else existing.position
if _fly_mgr and _fly_mgr.has_method("notify_target_despawn"):
_fly_mgr.notify_target_despawn(existing)
_by_vid.erase(vid)
existing.queue_free()
elif not _is_visible_entity(d):
@@ -448,12 +532,28 @@ func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void:
parent.add_child(node)
if keep_pos is Vector3:
node.global_position = keep_pos
if keep_rot is float:
node.rotation.y = keep_rot
else:
node.position = _grounded(d.get("pos", Vector3.ZERO))
# SCreateData::m_fRot is always the incoming packet rotation; only the
# position has the mount-transition preservation exception above.
node.rotation.y = MapCoord.heading_to_yaw(float(d.get("angle_deg", 0.0)))
node.set_meta("vid", vid)
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
if node.has_signal("motion_bound"):
node.motion_bound.connect(_on_remote_motion_bound.bind(vid))
_connect_actor_motion_events(node, vid)
var initial_func := int(d.get("func", FUNC_WAIT))
var initial_arg := int(d.get("func_arg", 0))
var initial_moving := bool(d.get("moving", false))
var initial_walk_mode := int(d.get("walk_mode", WALKMODE_RUN))
var initial_dead := bool(d.get("dead", false))
node.set_meta("func", initial_func)
node.set_meta("func_arg", initial_arg)
node.set_meta("moving", initial_moving)
node.set_meta("walk_mode", initial_walk_mode)
node.set_meta("dead", initial_dead)
# A correction that arrived before the visible node was created belongs to
# the data layer only; consume the current serial as the spawn baseline.
node.set_meta("sync_push_serial", int(d.get("sync_push_serial", 0)))
node.set_meta("mount_vnum", int(d.get("mount_vnum", 0)))
var ch_type := int(d.get("ch_type", 0))
if kind_resolver.is_valid():
@@ -465,6 +565,7 @@ func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void:
shape = "DOOR"
elif ch_type in [EntityRules.KIND_NPC, EntityRules.KIND_WARP]:
shape = "TALK"
node.set_meta("kind", ch_type)
node.set_meta("cursor_shape", shape)
_by_vid[vid] = node
if vid == _target_vid:
@@ -475,12 +576,160 @@ func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void:
_refresh_pvp_tag(vid)
_apply_field_updates(node, d)
_push_motion_speeds(vid, node)
# NetworkActorManager creates the instance, applies its immutable/mutable
# fields, then lets the current state select the initial motion. Without
# this explicit edge, a spawn whose first packet is FUNC_SKILL/EMOTION could
# look like WAIT forever because _process sees matching metadata and skips
# the transition.
_apply_anim(node, initial_func, initial_moving, initial_walk_mode, initial_dead, initial_arg)
_apply_name_color(vid)
_apply_text_tail(vid)
entity_added.emit(node, vid)
if vid == _main_vid:
main_entity_ready.emit(node, vid)
# CActorInstance::MotionEventProcess runs on every visible actor, not only the
# local player. Connect the real view's native animator once; placeholders do
# not have an animator and simply begin receiving events after _on_spawn
# replaces them with a model view.
func _connect_actor_motion_events(node: Node3D, vid: int) -> void:
if node == null or not is_instance_valid(node) or bool(node.get_meta("motion_events_connected", false)):
return
if not ("anim" in node):
return
var animator: Variant = node.get("anim")
if animator == null or not is_instance_valid(animator) or not animator.has_signal("motion_event_detailed"):
return
animator.motion_event_detailed.connect(_on_actor_motion_event.bind(vid))
node.set_meta("motion_events_connected", true)
func _motion_event_world_position(actor: Node3D, event: Dictionary) -> Vector3:
var source_variant: Variant = event.get("fly_pos", Vector3.ZERO)
var source: Vector3 = source_variant if source_variant is Vector3 else Vector3.ZERO
var model: Node3D = actor.get("model") as Node3D if "model" in actor else null
var anim: Node = actor.get("anim") as Node if "anim" in actor else null
if is_instance_valid(model):
var bone := Transform3D.IDENTITY
if bool(event.get("fly_attaching", false)) and is_instance_valid(anim) \
and anim.has_method("get_effect_bone_pose"):
var bone_name := String(event.get("fly_bone", ""))
if bone_name != "":
var pose: Dictionary = anim.get_effect_bone_pose(bone_name)
if pose.has("transform"):
bone = pose["transform"]
var source_to_metres := Transform3D(
Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)),
Vector3.ZERO)
var offset := Transform3D(Basis.IDENTITY, source)
return (model.global_transform * offset * bone * source_to_metres.affine_inverse()).origin
return actor.global_transform * Vector3(source.x * 0.01, source.z * 0.01, -source.y * 0.01)
func _resolve_actor_motion_fly_target(actor: Node3D) -> Dictionary:
var queued: Variant = actor.get_meta("fly_target_queue", [])
var record: Dictionary = {}
var from_queue := false
if queued is Array and not queued.is_empty() and queued[0] is Dictionary:
record = queued[0]
from_queue = true
else:
record = {
"vid": int(actor.get_meta("fly_target_vid", 0)),
"cm": actor.get_meta("fly_target_cm", Vector2.ZERO),
}
var target_vid := int(record.get("vid", 0))
if target_vid != 0:
var target_node: Node3D = _local_node if target_vid == _local_vid and is_instance_valid(_local_node) \
else _by_vid.get(target_vid, null)
if is_instance_valid(target_node):
return {"target": target_node, "from_queue": from_queue}
var target_cm: Variant = record.get("cm", null)
if target_cm is Vector2:
return {"target": _server_cm_to_world(target_cm), "from_queue": from_queue}
return {}
func _consume_actor_motion_fly_target(actor: Node3D) -> void:
var queued: Variant = actor.get_meta("fly_target_queue", [])
if queued is Array and not queued.is_empty():
queued.pop_front()
actor.set_meta("fly_target_queue", queued)
func _on_actor_motion_event(event: Dictionary, vid: int) -> void:
var actor: Node3D = _by_vid.get(vid, null)
if is_instance_valid(actor) and int(event.get("type", -1)) == 6:
# CActorInstance::ProcessMotionEventFly consumes the current FlyTarget
# and creates a resource-driven instance for every visible remote actor.
# GC_CREATE_FLY is a separate server-indexed path; it must not replace this
# action-event projectile.
var target_info := _resolve_actor_motion_fly_target(actor)
if not target_info.is_empty():
var skill_index := int(event.get("skill_index", actor.get_meta("func_arg", 0)))
if spawn_motion_fly(actor, event, target_info["target"], skill_index, true, vid):
if bool(target_info.get("from_queue", false)):
_consume_actor_motion_fly_target(actor)
actor_motion_event.emit(vid, event)
func _fly_collision_radius(node: Node3D) -> float:
var configured := float(node.get_meta("fly_collision_radius", 0.0))
if configured > 0.0:
return configured
if node.has_method("get_fly_collision_radius"):
var reported: Variant = node.call("get_fly_collision_radius")
if reported is float or reported is int:
return maxf(0.0, float(reported))
# Placeholder actors do not carry the native collision spheres. Keep the
# same dynamic-sphere shape contract and use a conservative 50cm fallback;
# real views can publish their calibrated radius through the metadata above.
return 0.5
func _fly_collision_actors() -> Array:
var actors: Array = []
var seen := {}
var append_actor := func(vid: int, node: Node3D) -> void:
if vid == 0 or not is_instance_valid(node) or seen.has(node.get_instance_id()):
return
if bool(node.get_meta("dead", false)):
return
seen[node.get_instance_id()] = true
actors.append({
"vid": vid,
"node": node,
"center": FlyTargetAnchor.fly_target_position(node),
"radius": _fly_collision_radius(node),
})
for vid_variant in _by_vid.keys():
append_actor.call(int(vid_variant), _by_vid[vid_variant])
if _local_vid != 0 and is_instance_valid(_local_node):
append_actor.call(_local_vid, _local_node)
return actors
# CActorInstance::ProcessMotionEventFly:本地主角的 `.msa` FLY 事件创建
# `FlyFileName` 对应的可攻击飞行实例;OnShoot/CG_SHOOT 仍由 GameScene 的
# pending-shot 队列发送。它与服务器 GC_CREATE_FLY 的索引飞行物是两条
# 独立路径,不能把其中任一条当作另一条的替代或重复创建。
func spawn_motion_fly(actor: Node3D, event: Dictionary, target,
skill_index: int = 0, can_attack: bool = true, owner_vid: int = 0) -> bool:
var target_valid := (target is Node3D and is_instance_valid(target)) \
or (target is Vector3 and (target as Vector3).is_finite())
if _fly_mgr == null or not is_instance_valid(actor) or not target_valid:
return false
var fly_file := String(event.get("fly_file", ""))
if fly_file == "":
return false # CFlyingManager returns NULL when the resource is absent.
var data = FlyObject.FlyData.load_msf(fly_file, _assets_root)
if data == null:
return false
var start := actor.global_position
if actor.has_method("motion_event_world_position"):
start = actor.motion_event_world_position(event)
else:
start = _motion_event_world_position(actor, event)
_fly_mgr.parent = parent
_fly_mgr.world = world
if fx != null and _fly_mgr.has_method("set_effect_registry"):
_fly_mgr.set_effect_registry(fx)
_fly_mgr.spawn(start, target, can_attack, data, skill_index, actor, owner_vid)
return true
# CActorInstance::Move -> SetLoopMotion(WALK/RUN, .., m_fMovSpd):远端位移按动作根运动
# 累计(.msa Accumulation / MotionDuration × movSpd/100)推进。模型建好后把走 / 跑
# 速度交给 EntityStore;占位胶囊 / 骑马(马的动作不在 general 目录)不推,EntityStore
@@ -523,6 +772,12 @@ func _on_info(vid: int, d: Dictionary) -> void:
# part_hiding.gd(见 equip_model.gd)。远端 PC 由 RemotePlayerView 消费
# awPart[CHR_EQUIPPART_ARMOR..HAIR];本地玩家仍由 EquipModel 读取 get_equipment()。
func _apply_field_updates(n: Node3D, d: Dictionary) -> void:
# The pointer path consumes the same CanPickInstance result as the script
# target path, so an affect/death update cannot leave an invisible actor
# selectable until the next scene rebuild.
var pick_probe := d.duplicate()
pick_probe["kind"] = int(n.get_meta("kind", int(d.get("ch_type", 0))))
n.set_meta("can_pick", EntityRules.can_pick_instance(pick_probe))
var parts: Variant = d.get("parts", [])
if parts is Array and parts.size() >= 4:
_set_wear_slot(n, "set_armor", 0, int(parts[0]))
@@ -541,6 +796,11 @@ func _apply_field_updates(n: Node3D, d: Dictionary) -> void:
n.call("set_server_speed", int(d.get("moving_speed", 0)))
if n.has_method("set_attack_speed"):
n.call("set_attack_speed", int(d.get("attack_speed", 0)))
# CInstanceBase::SetAttackSpeed is consumed later by RunNormalAttack /
# RunComboAttack when the remote state reaches the presentation layer. Keep
# the wire value on the actor node as well; unlike the local NetPlay path,
# remote FUNC_* packets arrive independently of the view setter.
n.set_meta("attack_speed", int(d.get("attack_speed", 0)))
if n.has_method("set_alignment"):
n.call("set_alignment", int(d.get("alignment", 0)))
if n.has_method("set_pk_mode"):
@@ -624,17 +884,21 @@ func _detach_text_tail(n: Node3D) -> void:
if node != null:
node.visible = false
# §2.1 cull / 40250 DeleteBlendOut: keep the data row, blend the node out. It stays discoverable via
# _fading (so it is not rebuilt mid-blend) until the tween frees it.
# §2.1 cull / 40250 DeleteBlendOut: keep the data row, blend the old node out.
# _fading tracks the old dead-list equivalent until its tween frees it; a new
# same-VID AppendActor is allowed to build a live replacement immediately.
func _fade_and_free(vid: int) -> void:
if vid == _target_vid:
_detach_target_effect()
_sync_push.erase(vid)
var n: Node3D = _by_vid.get(vid, null)
_by_vid.erase(vid)
if not is_instance_valid(n):
_fading.erase(vid)
return
_fading[vid] = true
_set_network_state_gate(n, false, true, false, true)
_set_entity_knockdown(vid, false)
entity_removed.emit(vid)
_remove_hp_bar(vid)
_detach_text_tail(n)
@@ -933,15 +1197,60 @@ func _on_despawn(vid: int) -> void:
_duel_opponents.erase(vid)
_chat_tails.erase(vid)
_push.erase(vid)
_sync_push.erase(vid)
_remove_hp_bar(vid)
if _target_vid == vid:
_target_vid = 0
if _hover_vid == vid:
_hover_vid = 0
var despawned: Node3D = _by_vid.get(vid, null)
if _fly_mgr and is_instance_valid(despawned) and _fly_mgr.has_method("notify_target_despawn"):
_fly_mgr.notify_target_despawn(despawned)
if _by_vid.has(vid):
_fade_and_free(vid)
# CNetworkActorManager::__RemoveCharacterManagerActor deletes the
# selected/main actor immediately, while remote actors use
# DeleteInstanceByFade and remain in the dead list during blend-out.
# Keeping this branch explicit also prevents a stale main-character node
# from receiving one more frame of movement after a server delete.
if vid == _main_vid:
_delete_now(vid)
else:
_fade_and_free(vid)
func _delete_now(vid: int) -> void:
if vid == _target_vid:
_detach_target_effect()
_sync_push.erase(vid)
_fading.erase(vid)
var n: Node3D = _by_vid.get(vid, null)
_by_vid.erase(vid)
if not is_instance_valid(n):
return
_set_network_state_gate(n, false, false, false, true)
_set_entity_knockdown(vid, false)
entity_removed.emit(vid)
_remove_hp_bar(vid)
_detach_text_tail(n)
n.queue_free()
func _on_main_set(vid: int) -> void:
# CNetworkActorManager::SetMainActorVID resets the main position and clears
# every live actor entry before the new loading burst is appended. A
# different main VID is a lifecycle boundary even when the outer scene has
# not emitted world_reset yet; retaining the old actor nodes would leave
# target/hover/HP/text-tail state attached to the previous character.
if _main_vid != 0 and _main_vid != vid:
_detach_target_effect()
_target_vid = 0
_hover_vid = 0
for old_vid in _by_vid.keys().duplicate():
_delete_now(int(old_vid))
_upgrade_queue.clear()
_sync_push.clear()
_push.clear()
_damage_queue.clear()
_chat_tails.clear()
_fading.clear()
_main_vid = vid
var n: Node3D = _by_vid.get(vid, null)
if n:
@@ -949,8 +1258,47 @@ func _on_main_set(vid: int) -> void:
# 主角变化会改写所有 PC 的相对名字色(同帝国 / PVP / 决斗 / 队伍判定都相对主角)。
_retint_all_names()
# CPythonCharacterManager::ShowPointEffect. The source client registers these
# exact MSE assets for CInstanceBase::LevelUp / SkillUp; keep the VID target
# resolution identical for remote actors and the local player.
func _on_point_changed(vid: int, point_type: int, _value: int, _amount: int) -> void:
var effect_path := ""
if point_type == 1:
effect_path = "d:/ymir work/effect/etc/levelup_1/level_up.mse"
elif point_type == 25:
effect_path = "d:/ymir work/effect/etc/skillup/skillup_1.mse"
else:
return
point_effect.emit(vid, point_type)
var n: Node3D = _by_vid.get(vid, null)
if n == null and vid == _local_vid:
n = _local_node
if n == null or not is_instance_valid(n):
return
n.set_meta("last_point_effect", point_type)
if point_type == 1:
# RecvPointChange's non-main branch calls SetLevel and
# UpdateTextTailLevel immediately after the effect dispatch.
_apply_text_tail(vid)
if fx != null and fx.has_method("spawn"):
fx.spawn(effect_path, n, true)
func _on_moved(_vid: int) -> void:
pass # 插值在 _process 里统一做
# Normal movement is consumed by _process. GC_SYNC_POSITION has an explicit
# serial because TEMP_Push must be distinct from every ordinary Move tick.
var vid := int(_vid)
var n: Node3D = _by_vid.get(vid, null)
if n == null or not is_instance_valid(n) or client == null \
or not client.has_method("get_entity"):
return
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var serial := int(e.get("sync_push_serial", 0))
if serial == int(n.get_meta("sync_push_serial", 0)):
return
n.set_meta("sync_push_serial", serial)
_apply_sync_push(n, e)
# GC_MOTION and ServerCommand emotion commands share CRaceMotionData IDs in
# the 40250 client. Keep the raw event visible to gameplay and ask real PC
@@ -994,6 +1342,88 @@ func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
else:
n.set_meta("motion_state", state)
# A one-shot remote skill/emotion releases the network-state queue from the
# animation tail, not from a timer. This is the Godot equivalent of
# CActorInstance::OnMotionEvent -> CInstanceBase::StateProcess becoming
# eligible again after __SetMotion finishes.
func _on_remote_motion_bound(state: String, vid: int) -> void:
if state != "wait":
return
var n: Node3D = _by_vid.get(vid, null)
if n != null and bool(n.get_meta("sync_push_reaction_active", false)):
n.set_meta("sync_push_reaction_active", false)
_set_entity_knockdown(vid, false)
_set_network_state_gate(n, false, true, false, true)
func _set_entity_knockdown(vid: int, on: bool) -> void:
if client == null or not client.has_method("set_entity_knockdown"):
return
client.call("set_entity_knockdown", vid, on)
# 40250 CActorInstance::TEMP_Push. The native entity row records the source
# position and the >150cm / IsUsingSkill decision. The map owns the only
# engine-dependent part: IPhysicsWorld's 100-point probe and the blending
# offset consumed by PhysicsProcess/GetBlendingPosition.
func _apply_sync_push(n: Node3D, e: Dictionary) -> void:
var vid := int(n.get_meta("vid", 0))
var source_raw: Variant = e.get("sync_push_from_cm", Vector3.ZERO)
var target_raw: Variant = e.get("pos_cm", Vector3.ZERO)
if not (source_raw is Vector3) or not (target_raw is Vector3):
return
var source := Vector2(float(source_raw.x), float(source_raw.y))
var target := Vector2(float(target_raw.x), float(target_raw.y))
if _sync_push_collision(source, target):
# TEMP_Push calls ResetBlendingPosition and returns on the first blocked
# sample. The authoritative NetworkActorData target still wins below.
_sync_push.erase(vid)
return
var delta := source - target
if delta.length_squared() > 0.0001:
var obj = PhysicsPush.new()
obj.set_last_position(delta, 1.0) # SetBlendingPosition default time
_sync_push[vid] = {"obj": obj, "off": delta,
"base": e.get("pos", Vector3.ZERO)}
else:
_sync_push.erase(vid)
var large := bool(e.get("sync_push_large", false))
var skill_active := bool(e.get("sync_push_skill_active", false))
if large and not skill_active:
n.set_meta("sync_push_reaction", "knockdown")
var started := false
if n.has_method("sync_push_motion"):
started = bool(n.call("sync_push_motion"))
elif n.has_method("set_anim_state"):
n.call("set_anim_state", "knockdown")
started = true
if started:
n.set_meta("sync_push_reaction_active", true)
_set_entity_knockdown(vid, true)
func _sync_push_collision(source: Vector2, target: Vector2) -> bool:
# In headless tests there is no IPhysicsWorld; the reference also returns
# before blending when its physics world is absent, but this client has no
# visual world in that mode, so fail open and keep the authoritative correction.
if world == null or not world.has_method("is_blocked"):
return false
for i in 100:
var p := source.lerp(target, float(i) / 100.0)
var w := MapCoord.to_world(Vector3(p.x * 0.01, 0.0, -p.y * 0.01))
if bool(world.call("is_blocked", w.x, w.z)):
return true
return false
func _set_network_state_gate(n: Node3D, skill_active: bool, skill_can_cancel: bool,
acting_emotion: bool, tcp_state_enabled: bool) -> void:
if client == null or not client.has_method("set_entity_network_state_gate") or n == null:
return
var vid := int(n.get_meta("vid", 0))
if vid == 0:
return
client.call("set_entity_network_state_gate", vid, skill_active, skill_can_cancel,
acting_emotion, tcp_state_enabled)
# GC_PVP: 显示当前与任一角色的挑战 / 战斗 / 复仇关系。原客户端同时会影响
# TargetBoard 和名字颜色;这里先提供始终可见的世界标签,避免关系状态无表现。
func _on_pvp_changed(src_vid: int, dst_vid: int, mode: int) -> void:
@@ -1177,6 +1607,7 @@ func _on_dead(vid: int) -> void:
_remove_hp_bar(vid)
_detach_text_tail(n)
n.set_meta("dead", true)
n.set_meta("can_pick", false)
if _target_vid == vid:
_target_vid = 0
@@ -1204,6 +1635,38 @@ func _on_dead(vid: int) -> void:
# shooter 的目标队列,避免远端技能没有终点被静默丢弃。§3.6:交给 FlyManager 跑真实弹道
# (CFlyingManager::CreateIndexedFly 的 INDEX_FLY_TYPE_NORMAL 分支:起点取射手像素位、
# 终点取目标 actor、canAttack=false —— 命中 / 伤害是服务端权威,客户端只做可视 + 命中时机)。
func _indexed_fly_data(index: int):
if _fly_data_cache.has(index):
return _fly_data_cache[index]
var source: String = String(INDEXED_FLY_FILES.get(index, ""))
if source == "":
return null
var data = FlyObject.FlyData.load_msf(source, _assets_root)
if data == null:
# 40250 RegisterIndexedFlyData/RegisterFlyingData 失败时不会创建实例;
# 不回退到脚本常量,避免资源缺失被伪装成另一种弹道。
return null
_fly_data_cache[index] = data
return data
func _indexed_fly_type(index: int) -> int:
return int(INDEXED_FLY_TYPES.get(index, FlyObject.INDEX_FLY_TYPE_NORMAL))
func _firecracker_launch(start: Vector3, shooter: Node3D) -> Dictionary:
# 40250 FlyingObjectManager.cpp uses the actor's D3D heading - 90 degrees,
# then maps the horizontal D3D Y axis to the Godot world -Z axis. In the
# Godot frame heading 0 therefore points +Z and heading 90 points +X.
var rotation_deg := fposmod(rad_to_deg(shooter.rotation.y) - 90.0 + 360.0, 360.0)
rotation_deg += randf_range(-30.0, 30.0)
var rotation_rad := deg_to_rad(rotation_deg)
var distance := randf_range(20.0, 50.0) # 2000..5000cm
var horizontal := Vector3(cos(rotation_rad), 0.0, -sin(rotation_rad)) * distance
var height := randf_range(10.0, 25.0) # 1000..2500cm
return {
"start": start + Vector3.UP * 2.0, # +200cm
"target": start + horizontal + Vector3.UP * height,
}
func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
var a: Vector3 = _entity_pos(start_vid)
var b: Vector3 = _entity_pos(end_vid)
@@ -1211,7 +1674,15 @@ func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
# 必须指向 _local_node 才会追踪胸口并触发 at_target/吸收效果。
var target_node: Node3D = _local_node if end_vid == _local_vid and is_instance_valid(_local_node) \
else _by_vid.get(end_vid, null)
var shooter: Node3D = _by_vid.get(start_vid, null)
var shooter: Node3D = _local_node if start_vid == _local_vid and is_instance_valid(_local_node) \
else _by_vid.get(start_vid, null)
# PythonNetworkStreamPhaseGame::RecvCreateFlyPacket calls GetInstancePtr for
# both VIDs and silently returns when either CInstanceBase is absent. Do not
# replace that actor-instance gate with an EntityStore coordinate fallback.
if not is_instance_valid(shooter):
return
if end_vid != 0 and not is_instance_valid(target_node):
return
if b == Vector3.INF and shooter:
var target: Dictionary = {}
var queued: Variant = shooter.get_meta("fly_target_queue", [])
@@ -1233,34 +1704,25 @@ func _on_fly(_type: int, start_vid: int, end_vid: int) -> void:
return
_fly_mgr.parent = parent
_fly_mgr.world = world
var start_w := a + Vector3(0, 1.0, 0)
var goal_w := b + Vector3(0, 1.0, 0)
var tgt: Variant = target_node if is_instance_valid(target_node) else goal_w
if _type == FlyObject.FLY_EXP:
# 40250 官方经验聚能光球: ga_piece_yellow_small2.msf
# 自导向追踪玩家胸膛 (is_homing=true, homing_start_time=0.05, homing_max_angle=140.0)
for k in range(3):
var offset = Vector3(randf_range(-0.25, 0.25), randf_range(0.0, 0.3), randf_range(-0.25, 0.25))
var orb_d = FlyObject.FlyData.new()
orb_d.init_vel = 11.0 + k * 2.0
orb_d.flat_range = maxf(start_w.distance_to(goal_w) * 2.5, 40.0)
orb_d.bomb_range = 0.8
orb_d.is_homing = true
orb_d.homing_start_time = 0.05 + k * 0.04
orb_d.homing_max_angle = 140.0
orb_d.maintain_parallel = true
orb_d.pierce_count = 0
_fly_mgr.spawn(start_w + offset, tgt, false, orb_d, FlyObject.FLY_EXP)
if fx != null and _fly_mgr.has_method("set_effect_registry"):
_fly_mgr.set_effect_registry(fx)
var data = _indexed_fly_data(_type)
if data == null:
return
# POC 无 .fly 加载器:合成一组弓箭弹道参数(换算成米),其余留 CFlyingData 默认。
var d = FlyObject.FlyData.new()
d.init_vel = 45.0 # ~4500 cm/s 的箭速
d.flat_range = maxf(start_w.distance_to(goal_w) * 1.5, 30.0)
d.bomb_range = 0.6 # 60 cm 命中球
d.pierce_count = 0
_fly_mgr.spawn(start_w, tgt, false, d, _type)
# CFlyingManager::CreateIndexedFly 的三种分支必须保持创建参数一致:
# NORMAL (a, pEndActor)
# FIRE_CRACKER (a + 200cm, 随机位置)
# AUTO_FIRE (a + 100cm, pEndActor)
var launch_start := a
var tgt: Variant = target_node if is_instance_valid(target_node) else b
match _indexed_fly_type(_type):
FlyObject.INDEX_FLY_TYPE_FIRE_CRACKER:
var firework := _firecracker_launch(a, shooter)
launch_start = firework["start"]
tgt = firework["target"]
FlyObject.INDEX_FLY_TYPE_AUTO_FIRE:
launch_start += Vector3.UP # +100cm
_fly_mgr.spawn(launch_start, tgt, false, data, _type, shooter, start_vid)
# 40250 供单机/离线击杀或事件直接调用发射经验光球
func spawn_exp_fly(from_pos: Vector3, to_node: Node3D = null, count: int = 3) -> void:
@@ -1268,6 +1730,8 @@ func spawn_exp_fly(from_pos: Vector3, to_node: Node3D = null, count: int = 3) ->
return
_fly_mgr.parent = parent
_fly_mgr.world = world
if fx != null and _fly_mgr.has_method("set_effect_registry"):
_fly_mgr.set_effect_registry(fx)
var start_w := from_pos + Vector3(0, 0.8, 0)
var tgt_node: Node3D = to_node
@@ -1322,6 +1786,11 @@ func _server_cm_to_world(cm: Vector2) -> Vector3:
# 实体世界坐标(Godot 米);找不到返回 Vector3.INF
func _entity_pos(vid: int) -> Vector3:
# GameScene owns the main actor outside _by_vid. During the phase handoff the
# EntityStore row can briefly be absent while the native client still resolves
# GetMainInstancePtr() for fly/death presentation.
if vid == _local_vid and is_instance_valid(_local_node):
return _local_node.global_position if _local_node.is_inside_tree() else _local_node.position
var e: Dictionary = client.get_entity(vid) if client else {}
if not e.is_empty() and e.get("pos", null) is Vector3:
return MapCoord.to_world(e["pos"])
@@ -1441,6 +1910,12 @@ func _process_damage_queue() -> void:
return
var d: Dictionary = _damage_queue.pop_front()
var n: Node3D = _by_vid.get(int(d["vid"]), null)
# 40250 RecvDamageInfoPacket 不区分普通 actor 与 MainInstance:
# MainInstance 也会进入 CInstanceBase::AddDamageEffect。当前 GameScene
# 为避免重复挂载把本地主角从 _by_vid 中分离,因此必须使用同一条
# _local_node 回退路径,否则本地主角自己的伤害数字会静默丢失。
if n == null and int(d["vid"]) == _local_vid:
n = _local_node
if n == null or not is_instance_valid(n) or parent == null:
return
var info := DamageEffect.classify(int(d["flag"]), bool(d["self"]), bool(d["target"]))
@@ -1564,11 +2039,16 @@ func _process_upgrade_queue() -> void:
func _process(dt: float) -> void:
if client == null or parent == null:
return
# GameScene injects EffectRegistry after NetWorld.setup; make the
# resource-driven FlyManager observe that handoff before the next cue.
if fx != null and _fly_mgr and _fly_mgr.has_method("set_effect_registry"):
_fly_mgr.set_effect_registry(fx)
_process_upgrade_queue()
_vis_accum += dt
if _vis_accum >= VISIBILITY_CHECK_INTERVAL:
_vis_accum = 0.0
_update_visibility()
# 40250 CPythonCharacterManager::Update checks distance and schedules
# blend-out on every frame. A timer-based throttle makes visibility depend on
# frame timing: a fast two-frame warp can leave a node stale or rebuild it
# after the actor has already crossed the view boundary.
_update_visibility()
_process_damage_queue() # §8.8:m_DamageQueue 每帧 pop 一条(CInstanceBase::ProcessDamage)
# §8.8:名字色随队伍 / 帝国名色模式 / PVP·决斗边沿变化——用 ~2Hz 节流整表刷新
# 兜底(单发路径已覆盖 spawn/info/pvp/duel/main_set;参考端每帧都算 GetNameColor)。
@@ -1600,6 +2080,8 @@ func _process(dt: float) -> void:
var want := _grounded(e.get("pos", n.position))
if _push.has(vid):
want = _pushed_position(vid, e, dt, want)
if _sync_push.has(vid):
want = _sync_pushed_position(vid, e, dt, want)
n.position = want
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
# CActorInstance::RotationProcess / SetAdvancingRotation:
@@ -1612,19 +2094,23 @@ func _process(dt: float) -> void:
n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt)
# 动画状态
var f := int(e.get("func", FUNC_WAIT))
var func_arg := int(e.get("func_arg", 0))
var moving := bool(e.get("moving", false))
var walk_mode := int(e.get("walk_mode", WALKMODE_RUN))
var dead := bool(e.get("dead", false))
if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \
if f != int(n.get_meta("func", -1)) or func_arg != int(n.get_meta("func_arg", -1)) \
or moving != bool(n.get_meta("moving", false)) \
or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)):
n.set_meta("func", f)
n.set_meta("func_arg", func_arg)
n.set_meta("moving", moving)
n.set_meta("walk_mode", walk_mode)
n.set_meta("dead", dead)
_apply_anim(n, f, moving, walk_mode, dead)
_apply_anim(n, f, moving, walk_mode, dead, func_arg)
_update_hp_bars()
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, dead := false) -> void:
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN,
dead := false, func_arg := 0) -> void:
var state := "wait"
if dead:
# CActorInstance::__SetMotion:IsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。
@@ -1632,17 +2118,56 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, dea
elif f == FUNC_MOVE or (f == FUNC_WAIT and moving):
state = "walk" if walk_mode == WALKMODE_WALK else "run"
elif f == FUNC_ATTACK or f == FUNC_COMBO:
state = "attack"
# 40250 InstanceBase::StateProcess calls RunNormalAttack for
# FUNC_ATTACK and RunComboAttack(..., uArg) for FUNC_COMBO. The
# previous remote path collapsed both into set_anim_state("attack"),
# which discarded the combo segment and bypassed weapon-mode motion
# lookup (onehand/twohand/bow/etc.).
var motion_index := MOTION_NORMAL_ATTACK if f == FUNC_ATTACK else func_arg
if n.has_method("play_attack_motion"):
var mode := 1
if n.has_method("get_motion_mode"):
mode = int(n.call("get_motion_mode"))
var speed := float(n.get_meta("attack_speed", 0)) / 100.0
if speed <= 0.0 or speed > 11.0:
speed = 1.0
if bool(n.call("play_attack_motion", mode, motion_index, speed)):
return
state = "combo" if f == FUNC_COMBO else "attack"
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
if (f & FUNC_SKILL) != 0:
var mot_idx := f & 0x7f
n.set_meta("motion_skill_index", mot_idx)
if mot_idx > 0 and skill_table and n.has_method("play_skill_motion"):
var mname: String = skill_table.motion_name_by_idx(mot_idx)
if mname != "":
n.call("play_skill_motion", mname)
return
var loop_count := func_arg & 0x0f
var moving_skill := (func_arg & (1 << 4)) != 0
# 40250 FUNC_SKILL carries the grade-specific motion index when
# USE_SKILL_EFFECT_UPGRADE_ENABLE=1; recover that grade before
# selecting the matching <motion>_2/_3/_4 MSA.
var skill_grade := 0
if skill_table.has_method("motion_grade_by_idx"):
skill_grade = int(skill_table.call("motion_grade_by_idx", mot_idx))
var played := bool(n.call("play_skill_motion", mname, skill_grade,
loop_count, moving_skill))
if played:
# Remote FUNC_SKILL has no cancel bit in TPacketGCMove;
# the authoritative motion data owns CanCancelSkill. Until
# that data says otherwise, hold the TCP queue and release it
# from motion_bound("wait").
_set_network_state_gate(n, true, false, false, true)
return
state = "skill"
elif f == FUNC_EMOTION:
# InstanceBase::__ProcessFunctionEmotion consumes uArg as the
# CRaceMotionData action id. TPacketGCMove has no target VID, so the
# one-sided action is selected with target_race=-1; GC_MOTION still
# supplies the paired-emotion target path through _on_motion().
if func_arg > 0 and n.has_method("set_motion_id"):
if bool(n.call("set_motion_id", func_arg, -1)):
_set_network_state_gate(n, false, true, true, false)
return
state = "emotion"
if n.has_method("set_anim_state"):
n.call("set_anim_state", state)
@@ -1722,6 +2247,29 @@ func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vect
var off: Vector2 = pe.off
return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0))
# TEMP_Push's SetBlendingPosition stores (target - current) in the physics
# object. Since the network row now points at target, keep the inverse offset
# (current - target) so the first rendered frame remains at the old position
# and the one-second ease-out converges to the authoritative target.
func _sync_pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3:
var pe: Dictionary = _sync_push.get(vid, {})
if pe.is_empty():
return want
var obj = pe.get("obj", null)
var pos: Variant = e.get("pos")
if obj == null or not (pos is Vector3):
_sync_push.erase(vid)
return want
if obj.is_blending():
obj.update(dt)
# SetBlendingPosition starts with the full (current - target) offset;
# PhysicsObject's movement is the progress from that offset back to zero.
pe["off"] = pe.get("off", Vector2.ZERO) - obj.get_movement()
var off: Vector2 = pe["off"]
return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0))
_sync_push.erase(vid)
return want
func _make_placeholder(d: Dictionary) -> Node3D:
var root := Node3D.new()
var mesh := MeshInstance3D.new()