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