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
+478 -21
View File
@@ -31,31 +31,48 @@
# OnExplodingAtAnotherTarget(skill,vid) / OnExplodingAtTarget(skill)
extends Node
const AssetRoot = preload("res://asset_root.gd")
const FlyTargetAnchor = preload("res://fx/target_effect_anchor.gd")
# CFlyingManager::EIndexFlyTypeREF/GameLib/FlyingObjectManager.h
const INDEX_FLY_TYPE_NORMAL := 0
const INDEX_FLY_TYPE_FIRE_CRACKER := 1
const INDEX_FLY_TYPE_AUTO_FIRE := 2
# 40250 playersettingmodule.py / char.h 索引飞行物类型 (effect.FLY_*)
const FLY_EXP := 0
const FLY_HP_SMALL := 1
# 40250 PythonEffectModule.cpp / playersettingmodule.py / char.h 索引飞行物类型
# FLY_NONE 占用 0;经验球从 1 开始,不能把 0 当成 FLY_EXP。
const FLY_NONE := 0
const FLY_EXP := 1
const FLY_HP_MEDIUM := 2
const FLY_HP_BIG := 3
const FLY_SP_SMALL := 4
const FLY_SP_MEDIUM := 5
const FLY_SP_BIG := 6
const FLY_FIREWORK1 := 7
const FLY_FIREWORK2 := 8
const FLY_FIREWORK3 := 9
const FLY_FIREWORK4 := 10
const FLY_FIREWORK5 := 11
const FLY_FIREWORK6 := 12
const FLY_FIREWORK_XMAS := 13
const FLY_CHAIN_LIGHTNING := 14
const FLY_HP_SMALL := 15
const FLY_SKILL_MUYEONG := 16
# CFlyingData —— .fly 脚本字段。POC 无 .fly 加载器,默认值逐项对齐 CFlyingData::__Initialize()
# (长度单位:原版是 pixel==cm,这里换算成米后传进来,故 init_vel / range / bomb_range 都按米)。
# CFlyingData —— .fly 脚本字段。原版文件使用 pixel==cm;加载器在边界处
# 统一换算为 Godot 米制,因此 init_vel / range / bomb_range / acceleration 等
# 长度量都按 0.01 转换,角度和时间保持原单位。
class FlyData extends RefCounted:
var init_vel := 2.0 # m_fInitVel=200cm/s)→ 2 m/s(占位;net_world 用弓箭值覆盖)
var resource_path := ""
var init_vel := 2.0 # m_fInitVel=200cm/s)→ 2 m/s
var cone_angle := 0.0 # m_fConeAngle
var roll_angle := 0.0 # m_fRollAngle
var gravity := 0.0 # m_fGravity(原版加到速度 .z(=up),本实现映射到 -Y = 向下)
var accel := Vector3.ZERO # m_v3Accel
var angular_velocity := Vector3.ZERO # m_v3AngVel,度/秒
var flat_range := 5.0 # m_fRange=500cm)→ 5 m
var bomb_range := 0.1 # m_fBombRange=10cm)→ 0.1 m
var collision_sphere_radius := 0.0 # m_fCollisionSphereRadius,米
var pierce_count := 0 # m_iPierceCount
var spreading := false # m_bSpreading
var maintain_parallel := false # m_bMaintainParallel(对象目标点 y 抬 0.5 m ≈ 原版 +50cm
@@ -63,6 +80,147 @@ class FlyData extends RefCounted:
var homing_start_time := 0.0 # m_fHomingStartTime
var homing_max_angle := 0.0 # m_fHomingMaxAngle(度 / 每次修正)
var hit_on_background := false # m_bHitOnBackground
var hit_on_another_monster := false # m_bHitOnAnotherMonster
var bomb_effect := "" # m_strBombEffectName
var attach_data: Array[Dictionary] = [] # CFlyingData::m_AttachDataVector
static func load_msf(path: String, assets_root: String = ""):
var resolved := _resolve_msf_path(path, assets_root)
if resolved == "":
return null
var file := FileAccess.open(resolved, FileAccess.READ)
if file == null:
return null
var data := FlyData.new()
data.resource_path = resolved
var saw_initial := false
var saw_range := false
var in_attach := false
var attach: Dictionary = {}
while not file.eof_reached():
var line := file.get_line().strip_edges()
if line == "" or line.begins_with("#") or line.begins_with("//"):
continue
if in_attach and line == "}":
data.attach_data.append(attach)
in_attach = false
attach = {}
continue
if line.to_lower().begins_with("group attachdata"):
in_attach = true
attach = {}
continue
var pair := _split_assignment(line)
if pair.is_empty():
continue
var key: String = String(pair[0]).to_lower()
var value: String = String(pair[1]).strip_edges()
if in_attach:
_match_attach_value(attach, key, value, resolved, assets_root)
continue
if line == "{" or line == "}":
continue
match key:
"spreadingflag": data.spreading = _as_int(value) != 0
"maintainparallelflag": data.maintain_parallel = _as_int(value) != 0
"initialvelocity":
data.init_vel = _as_float(value) * 0.01
saw_initial = true
"coneangle": data.cone_angle = _as_float(value)
"rollangle": data.roll_angle = _as_float(value)
"angularvelocity": data.angular_velocity = _as_vec3(value)
"gravity": data.gravity = _as_float(value) * 0.01
"hitonbackground": data.hit_on_background = _as_int(value) != 0
"hitonanothermonster": data.hit_on_another_monster = _as_int(value) != 0
"piercecount": data.pierce_count = _as_int(value)
"collisionsphereradius": data.collision_sphere_radius = _as_float(value) * 0.01
"bombrange": data.bomb_range = _as_float(value) * 0.01
"bombeffect": data.bomb_effect = _resolve_related_path(resolved, _unquote(value), assets_root)
"homingflag": data.is_homing = _as_int(value) != 0
"homingstarttime": data.homing_start_time = _as_float(value)
"homingmaxangle": data.homing_max_angle = _as_float(value)
"range":
data.flat_range = _as_float(value) * 0.01
saw_range = true
"acceleration": data.accel = _as_vec3(value) * 0.01
if not saw_initial or not saw_range:
return null
return data
static func _split_assignment(line: String) -> Array:
var normalized := line.replace("\t", " ").strip_edges()
var sep := normalized.find(" ")
if sep < 0:
return []
return [normalized.substr(0, sep), normalized.substr(sep + 1).strip_edges()]
static func _unquote(value: String) -> String:
var out := value.strip_edges()
if out.length() >= 2 and out.begins_with("\"") and out.ends_with("\""):
return out.substr(1, out.length() - 2)
return out
static func _as_float(value: String) -> float:
return value.strip_edges().to_float()
static func _as_int(value: String) -> int:
return value.strip_edges().to_int()
static func _as_vec3(value: String) -> Vector3:
var parts := value.replace("\t", " ").split(" ", false)
if parts.size() < 3:
return Vector3.ZERO
return Vector3(parts[0].to_float(), parts[1].to_float(), parts[2].to_float())
static func _match_attach_value(attach: Dictionary, key: String, value: String,
resolved: String, assets_root: String) -> void:
match key:
"type": attach["type"] = _as_int(value)
"flytype": attach["fly_type"] = _as_int(value)
"attachfile": attach["file"] = _resolve_related_path(resolved, _unquote(value), assets_root)
"tailflag": attach["has_tail"] = _as_int(value) != 0
"tailcolor": attach["tail_color"] = _unquote(value).trim_suffix("d").to_int()
# CFlyingData keeps TailLength as seconds; TailSize is a world
# length stored in centimetres and is the only one converted here.
"taillength": attach["tail_length"] = _as_float(value)
"tailsize": attach["tail_size"] = _as_float(value) * 0.01
"tailshaperect": attach["rect_shape"] = _as_int(value) != 0
"roll": attach["roll"] = _as_float(value)
"distance": attach["distance"] = _as_float(value) * 0.01
"period": attach["period"] = _as_float(value)
"amplitude": attach["amplitude"] = _as_float(value) * 0.01
static func _resolve_related_path(source_file: String, value: String, assets_root: String) -> String:
if value == "":
return ""
var sibling := source_file.get_base_dir().path_join(value)
if FileAccess.file_exists(sibling):
return sibling
var resolved := _resolve_msf_path(value, assets_root)
return resolved if resolved != "" else value
static func _resolve_msf_path(path: String, assets_root: String) -> String:
var root := assets_root
if root == "":
root = AssetRoot.path()
var source := path.replace("\\", "/")
if source.length() >= 2 and source[1] == ":":
source = source.substr(2).lstrip("/")
var candidates: Array[String] = [source]
var lower := source.to_lower()
if lower.begins_with("ymir work/"):
candidates.append("Effect/" + source)
candidates.append("PC/" + source)
var marker := lower.find("ymir work/")
if marker >= 0:
var tail := source.substr(marker)
candidates.append("Effect/" + tail)
candidates.append("PC/" + tail)
for rel in candidates:
var candidate := root.path_join(String(rel))
if FileAccess.file_exists(candidate):
return candidate
return ""
# 单颗飞行物 —— 对齐 CFlyingInstance。target 可为 Node3D(对象目标,跟随移动)、
# Vector3(位置目标)、或 { "vid": int, "pos": Vector3 }。
@@ -80,25 +238,45 @@ class FlyInstance extends RefCounted:
var skill_index := 0
var target_vid := 0
var target_hitted := false
var hitted_objects := {} # CFlyingInstance::m_HittedObjectSet
var _target_obj: Node3D
var _target_pos := Vector3.ZERO
var _is_object := false
var _elapsed := 0.0
var _world: Object # 可空:有 sample_height 时做触地
var _actor_provider := Callable() # func() -> Array[{vid,node,center,radius}]
var _owner_node: Node3D # FCheckAnotherMonsterDuringFlying 排除射手
var _owner_vid := 0
var attachments: Array = [] # TAttachEffectInstanceVector
var _attach_rotation := Quaternion.IDENTITY
# handler.call(event: String, world_pos: Vector3, vid: int) —— 对齐 IFlyEventHandler
var handler := Callable()
func _node_fly_target_position(node: Node3D) -> Vector3:
if not is_instance_valid(node):
return Vector3.ZERO
if node.is_inside_tree():
return FlyTargetAnchor.fly_target_position(node)
return node.position
func target_position() -> Vector3:
if _is_object and is_instance_valid(_target_obj):
if _target_obj.is_inside_tree():
_target_pos = _target_obj.global_position
else:
_target_pos = _target_obj.position
_target_pos = _node_fly_target_position(_target_obj)
var tp := _target_pos
if data and data.maintain_parallel:
tp.y += 0.5
return tp
func notify_target_despawn(node: Node3D) -> void:
if not _is_object or _target_obj != node:
return
# CFlyTarget::NotifyTargetClear changes TYPE_OBJECT to TYPE_POSITION
# while retaining the last cached target position. Read it once before
# dropping the node reference so delayed fade-out cannot move the target.
_target_pos = target_position()
_target_obj = null
_is_object = false
# CFlyingInstance::Create → __SetDataPointer + __SetTargetDirection。
func create(d: FlyData, start_world: Vector3, target, attack: bool) -> void:
data = d
@@ -108,12 +286,13 @@ class FlyInstance extends RefCounted:
alive = true
_elapsed = 0.0
target_hitted = false
hitted_objects.clear()
pierce = d.pierce_count
remain_range = d.flat_range
if target is Node3D:
_is_object = true
_target_obj = target
_target_pos = (target as Node3D).global_position if (target as Node3D).is_inside_tree() else (target as Node3D).position
_target_pos = _node_fly_target_position(target as Node3D)
elif target is Vector3:
_is_object = false
_target_pos = target
@@ -126,7 +305,7 @@ class FlyInstance extends RefCounted:
if tn is Node3D:
_is_object = true
_target_obj = tn
_target_pos = (tn as Node3D).global_position
_target_pos = _node_fly_target_position(tn as Node3D)
# __SetTargetDirection:方向 = normalize(target - pos),局部速度 (0,-init_vel,0)
# 旋到该方向即 dir*init_vel;加速度同样旋到该方向。
var dir := (target_position() - pos)
@@ -139,6 +318,142 @@ class FlyInstance extends RefCounted:
# 局部帧 -Y → dir 的最小弧旋转(对齐 __SetTargetNormalizedDirection)。
accel = Quaternion(Vector3(0, -1, 0), dir).normalized() * accel
func build_attachments(effect_registry, visual_parent: Node3D) -> void:
attachments.clear()
_attach_rotation = Quaternion.IDENTITY
if data == null:
return
for attach_variant in data.attach_data:
if not attach_variant is Dictionary or int(attach_variant.get("type", 0)) != 1:
continue # CFlyingData::FLY_ATTACH_OBJECT is also not implemented in 40250.
var attach: Dictionary = attach_variant
var effect: Node3D = null
var effect_file := String(attach.get("file", ""))
if effect_registry != null and effect_file != "" \
and effect_registry.has_method("spawn") and visual_parent != null:
effect = effect_registry.spawn(effect_file, visual_parent, false)
var tail: MeshInstance3D = null
if bool(attach.get("has_tail", false)) and visual_parent != null:
tail = _create_tail(visual_parent, attach)
attachments.append({"data": attach, "effect": effect, "tail": tail, "history": []})
_update_attachments(0.0)
func clear_attachments() -> void:
for item in attachments:
for key in ["effect", "tail"]:
var node: Variant = item.get(key, null)
if is_instance_valid(node):
node.free()
attachments.clear()
func _create_tail(visual_parent: Node3D, attach: Dictionary) -> MeshInstance3D:
var tail := MeshInstance3D.new()
tail.name = "FlyTrace"
var mesh := ImmediateMesh.new()
tail.mesh = mesh
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.vertex_color_use_as_albedo = true
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
tail.material_override = material
visual_parent.add_child(tail)
return tail
func _fly_orientation() -> Quaternion:
var direction := vel.normalized()
if direction.length_squared() < 0.000001:
direction = Vector3(0.0, 0.0, -1.0)
return Quaternion(Vector3(0.0, -1.0, 0.0), direction).normalized()
func _orientation(include_attach: bool = true) -> Quaternion:
var base := _fly_orientation()
return (_attach_rotation * base).normalized() if include_attach else base
func _attachment_position(attach: Dictionary) -> Vector3:
var fly_type := int(attach.get("fly_type", 1))
var roll := deg_to_rad(float(attach.get("roll", 0.0)))
var distance := float(attach.get("distance", 0.0))
var amplitude := float(attach.get("amplitude", 0.0))
var period := float(attach.get("period", 1.0))
var offset_amount := distance
match fly_type:
3: # FLY_ATTACH_TYPE_SINE
var angle := _elapsed * TAU / maxf(period, 0.0001)
offset_amount = amplitude * sin(angle)
4: # FLY_ATTACH_TYPE_EXP
var angle := _elapsed / maxf(period, 0.0001)
offset_amount = amplitude * exp(-angle) * angle
2: # FLY_ATTACH_TYPE_MULTI_LINE
pass
_: # FLY_ATTACH_TYPE_NONE / LINE
return pos
# EffectLib's source horizontal plane maps to Godot x/z; apply the
# current fly orientation after the local roll offset.
# 40250's source plane uses -cos(roll) on the local Z axis. Keeping
# this sign is important for MULTI_LINE/SINE/EXP trace placement.
var local := Vector3(-sin(roll) * offset_amount, 0.0, -cos(roll) * offset_amount)
return pos + (_orientation() * local)
func _tail_color(value: int) -> Color:
return Color8((value >> 16) & 0xff, (value >> 8) & 0xff,
value & 0xff, (value >> 24) & 0xff)
func _update_tail(tail: MeshInstance3D, history: Array, attach: Dictionary) -> void:
if tail == null or not is_instance_valid(tail) or history.size() < 2:
return
var mesh := tail.mesh as ImmediateMesh
if mesh == null:
return
mesh.clear_surfaces()
var material := tail.material_override as Material
mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLE_STRIP, material)
var length := maxf(float(attach.get("tail_length", 0.0)), 0.0001)
var width := maxf(float(attach.get("tail_size", 0.0)), 0.001)
var base_color := _tail_color(int(attach.get("tail_color", 0xffffffff)))
for i in range(history.size()):
var item: Dictionary = history[i]
var previous: Vector3 = history[maxi(i - 1, 0)].position
var next: Vector3 = history[mini(i + 1, history.size() - 1)].position
var tangent := (next - previous).normalized()
var side := tangent.cross(Vector3.UP)
if side.length_squared() < 0.000001:
side = tangent.cross(Vector3.RIGHT)
side = side.normalized() * width
var age := maxf(0.0, _elapsed - float(item.time))
var alpha := clampf(1.0 - age / length, 0.0, 1.0)
mesh.surface_set_color(Color(base_color, base_color.a * alpha))
mesh.surface_add_vertex(item.position - side)
mesh.surface_set_color(Color(base_color, base_color.a * alpha))
mesh.surface_add_vertex(item.position + side)
mesh.surface_end()
func _update_attachments(dt: float) -> void:
if data == null:
return
var delta_q := Quaternion(Vector3.UP, deg_to_rad(data.angular_velocity.y) * dt)
delta_q = delta_q * Quaternion(Vector3.RIGHT, deg_to_rad(data.angular_velocity.x) * dt)
delta_q = delta_q * Quaternion(Vector3.BACK, deg_to_rad(data.angular_velocity.z) * dt)
_attach_rotation = (_attach_rotation * delta_q).normalized()
for item_variant in attachments:
var item: Dictionary = item_variant
var attach: Dictionary = item.data
var at := _attachment_position(attach)
var effect: Node3D = item.get("effect", null)
if is_instance_valid(effect):
# UpdateAttachInstance uses m_qRot for LINE and
# m_qAttachRotation * m_qRot for the offset trace types.
var attach_orientation := _orientation(
int(attach.get("fly_type", 1)) != 1)
effect.global_transform = Transform3D(Basis(attach_orientation), at)
var history: Array = item.history
if bool(attach.get("has_tail", false)):
history.push_front({"time": _elapsed, "position": at})
var tail_length := maxf(float(attach.get("tail_length", 0.0)), 0.0)
while not history.is_empty() and _elapsed - float(history.back().time) > tail_length:
history.pop_back()
_update_tail(item.get("tail", null), history, attach)
# CFlyingInstance::AdjustDirectionForHoming —— 把速度朝目标方向转,单次夹在
# homing_max_angle 度以内。
func _adjust_homing() -> void:
@@ -169,6 +484,7 @@ class FlyInstance extends RefCounted:
var move_dist := movement.length()
remain_range -= move_dist
pos += movement
_update_attachments(dt)
if remain_range < 0.0:
_emit("out_of_range")
@@ -196,6 +512,30 @@ class FlyInstance extends RefCounted:
_explode()
return false
# FlyingInstance.cpp checks another Actor after the primary target branch
# and before background collision. The sphere is expanded by twice this
# frame's movement distance, or the resource-configured radius, whichever
# is larger. HittedObjectSet is pointer identity in 40250; the provider's
# VID is only the observable callback value, so a new node with a reused
# VID is not accidentally treated as the old object.
if data.hit_on_another_monster and _actor_provider.is_valid():
var collision_radius := maxf(move_dist * 2.0, data.collision_sphere_radius)
var another := _find_another_actor(collision_radius)
if not another.is_empty():
var actor_node: Node3D = another.get("node", null)
var object_key := actor_node.get_instance_id() if is_instance_valid(actor_node) else int(another.get("vid", 0))
if not hitted_objects.has(object_key):
hitted_objects[object_key] = true
var another_vid := int(another.get("vid", 0))
_emit("at_another", pos, another_vid)
if pierce > 0:
pierce -= 1
_emit("bomb")
else:
_explode()
return false
return true
if data.hit_on_background:
if _world and _world.has_method("sample_height"):
var gh := float(_world.call("sample_height", pos.x, pos.z))
@@ -224,9 +564,36 @@ class FlyInstance extends RefCounted:
if bomb:
_emit("bomb")
func _emit(ev: String) -> void:
func _find_another_actor(projectile_radius: float) -> Dictionary:
var candidates: Variant = _actor_provider.call()
if not candidates is Array:
return {}
for candidate_variant in candidates:
if not candidate_variant is Dictionary:
continue
var candidate: Dictionary = candidate_variant
var node_variant: Variant = candidate.get("node", null)
if not node_variant is Node3D or not is_instance_valid(node_variant):
continue
var node: Node3D = node_variant
if node == _owner_node or int(candidate.get("vid", 0)) == _owner_vid:
continue
if bool(node.get_meta("dead", false)):
continue
var center_variant: Variant = candidate.get("center", null)
var center := _node_fly_target_position(node)
if center_variant is Vector3:
center = center_variant
var actor_radius := maxf(0.0, float(candidate.get("radius", 0.0)))
if _seg_point_sq_dist(pos, last_pos, center) < (projectile_radius + actor_radius) * (projectile_radius + actor_radius):
return candidate
return {}
func _emit(ev: String, event_pos: Variant = null, event_vid: int = -1) -> void:
var world_pos := pos if event_pos == null else (event_pos as Vector3)
var vid := target_vid if event_vid < 0 else event_vid
if handler.is_valid():
handler.call(ev, pos, target_vid)
handler.call(ev, world_pos, vid)
# REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point
static func _seg_point_sq_dist(p1: Vector3, p2: Vector3, x: Vector3) -> float:
@@ -242,33 +609,65 @@ class FlyInstance extends RefCounted:
var parent: Node3D # 弹道 / 爆点可视节点挂这下面(可空 = 纯逻辑,仅回调)
var world: Object # Metin2World(可空,用于 hit_on_background 贴地)
var _assets_root := "" # 与 NetWorld 的资源根保持同一解析上下文
var manual_step := false # true 时 _process 不自动推进(headless 测试用 step() 手动驱动)
var actor_provider := Callable() # func() -> Array[{vid,node,center,radius}]
var effect_registry = null # EffectRegistry;可空时保留 headless fallback flash
var _instances: Array = [] # Array[FlyInstance]
var _visuals := {} # FlyInstance -> MeshInstance3D
var _flash_nodes: Array = [] # 短生命周期爆点特效,Loading 时必须一并回收
var _effect_nodes: Array = [] # EffectRegistry bomb/attach nodes owned by this manager
var _id_counter := 1
signal shoot_damage(target_vid: int)
signal exploded(world_pos: Vector3, cause: String)
signal exp_absorbed(world_pos: Vector3)
func setup(mount: Node3D, w: Object = null) -> void:
func setup(mount: Node3D, w: Object = null, assets_root_path: String = "") -> void:
parent = mount
world = w
_assets_root = assets_root_path
func set_actor_provider(provider: Callable) -> void:
actor_provider = provider
func set_effect_registry(registry) -> void:
effect_registry = registry
func notify_target_despawn(node: Node3D) -> void:
if not is_instance_valid(node):
return
for inst_variant in _instances:
if inst_variant is FlyInstance:
(inst_variant as FlyInstance).notify_target_despawn(node)
# CFlyingManager::CreateFlyingInstanceFlyTarget —— 建一颗飞行物并入表。
func spawn(start_world: Vector3, target, can_attack: bool, data: FlyData = null,
skill_index := 0) -> FlyInstance:
skill_index := 0, owner_node: Node3D = null, owner_vid: int = 0) -> FlyInstance:
var d := data if data != null else FlyData.new()
var inst := FlyInstance.new()
inst.id = _id_counter
_id_counter += 1
inst.skill_index = skill_index
inst._owner_node = owner_node
inst._owner_vid = owner_vid
inst._actor_provider = actor_provider
inst._world = world
inst.handler = func(ev: String, wp: Vector3, vid: int) -> void:
_on_instance_event(inst, ev, wp, vid)
inst.create(d, start_world, target, can_attack)
inst.build_attachments(effect_registry, parent)
_instances.append(inst)
if parent != null:
var has_resource_visual := false
for attachment_variant in inst.attachments:
var attached_effect: Variant = attachment_variant.get("effect", null)
if is_instance_valid(attached_effect):
has_resource_visual = true
break
# CFlyingInstance renders its AttachFile/EffectManager instance. The
# SphereMesh is only a headless/asset-missing fallback; do not draw both
# representations when the 40250 resource visual was created successfully.
if parent != null and not has_resource_visual:
var m := MeshInstance3D.new()
var sm := SphereMesh.new()
var mat := StandardMaterial3D.new()
@@ -312,8 +711,50 @@ func spawn(start_world: Vector3, target, can_attack: bool, data: FlyData = null,
func active_count() -> int:
return _instances.size()
func _release_instance(inst: FlyInstance) -> void:
if not is_instance_valid(inst):
return
inst.alive = false
# The event Callable closes over `inst` and is itself owned by the
# RefCounted instance. Break that cycle before dropping the manager's
# arrays, matching CFlyingInstance destruction at Loading or expiry.
inst.handler = Callable()
inst.clear_attachments()
inst._target_obj = null
inst._owner_node = null
inst._actor_provider = Callable()
inst._world = null
inst.data = null
# ClientVS22 的 CFlyingManager::DeleteAllInstances / CEffectManager::DeleteAllInstances。
# 地图切换发生在下一批 GC_MAIN_CHARACTER / GC_CHARACTER_ADD 到达之前,旧地图的
# 弹道和爆点不能继续挂在新地图上,否则会产生跨图命中、残留特效和旧目标回调。
func clear_for_map_change() -> void:
for inst in _instances:
if inst is FlyInstance:
_release_instance(inst)
var vis: MeshInstance3D = _visuals.get(inst, null)
if is_instance_valid(vis):
# ClientVS22's DeleteAllInstances is synchronous at the Loading
# boundary. Free the old visual now so its mesh/material RIDs cannot
# survive into the replacement world or a short-process Destroy.
vis.free()
_instances.clear()
_visuals.clear()
for flash in _flash_nodes:
if is_instance_valid(flash):
flash.free()
_flash_nodes.clear()
for effect in _effect_nodes:
if is_instance_valid(effect):
effect.free()
_effect_nodes.clear()
# CFlyingManager::Update() —— 遍历实例,Update() 返 false 即删除。
func step(dt: float) -> void:
for cleanup_index in range(_effect_nodes.size() - 1, -1, -1):
if not is_instance_valid(_effect_nodes[cleanup_index]):
_effect_nodes.remove_at(cleanup_index)
var i := 0
while i < _instances.size():
var inst: FlyInstance = _instances[i]
@@ -325,6 +766,7 @@ func step(dt: float) -> void:
else:
vis.position = inst.pos
if not keep:
_release_instance(inst)
if is_instance_valid(vis):
vis.queue_free()
_visuals.erase(inst)
@@ -344,11 +786,21 @@ func _on_instance_event(inst: FlyInstance, ev: String, wp: Vector3, vid: int) ->
exploded.emit(wp, ev)
if ev == "at_target" and inst.skill_index == FLY_EXP:
exp_absorbed.emit(wp)
if parent != null and ev != "out_of_range":
_spawn_flash(wp, inst.skill_index)
# 40250 emits the hit callback first, then __Explode() emits exactly
# one __Bomb() effect. Do not create a compatibility flash here: the
# following "bomb" event owns both the real BombEffect and its fallback.
"bomb":
if parent != null:
_spawn_flash(wp, inst.skill_index)
var bomb_spawned := false
if effect_registry != null and inst.data != null:
var bomb_file := String(inst.data.bomb_effect)
if bomb_file != "" and effect_registry.has_method("spawn_at"):
var bomb_fx = effect_registry.spawn_at(bomb_file, parent, wp, true)
bomb_spawned = bomb_fx != null
if bomb_spawned:
_effect_nodes.append(bomb_fx)
if not bomb_spawned:
_spawn_flash(wp, inst.skill_index)
func _spawn_flash(wp: Vector3, skill_index: int = -1) -> void:
var f := MeshInstance3D.new()
@@ -379,6 +831,7 @@ func _spawn_flash(wp: Vector3, skill_index: int = -1) -> void:
f.material_override = mat
parent.add_child(f)
_flash_nodes.append(f)
if f.is_inside_tree():
f.global_position = wp
else:
@@ -387,4 +840,8 @@ func _spawn_flash(wp: Vector3, skill_index: int = -1) -> void:
tw.set_parallel(true)
tw.tween_property(f, "scale", final_scale, 0.28)
tw.tween_property(mat, "albedo_color:a", 0.0, 0.28)
tw.chain().tween_callback(f.queue_free)
tw.chain().tween_callback(func() -> void:
_flash_nodes.erase(f)
if is_instance_valid(f):
f.queue_free()
)