Files
mtgodot-poc/project/fly_object.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

848 lines
33 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# fly_object.gd —— 1:1 复刻 GameLib 的飞行物实体系统(CFlyingManager / CFlyingInstance /
# CFlyingData),把原客户端「.msa FLY 帧 → OnShoot → CG_SHOOT」之后、由服务端 GC_CREATE_FLY
# 驱动的那颗真实弹道 + 命中判定搬到 POC。net_world.gd 收到 client.fly_cue 后调
# FlyManager.spawn(),每帧 step() 推进运动学并做「线段到点」的爆炸判定,命中 / 超程 / 触地
# 时回调 handler(对齐 IFlyEventHandler)。§3.6
#
# 参考:
# REF/GameLib/FlyingObjectManager.cpp CFlyingManager::CreateFlyingInstanceFlyTarget /
# Update()(遍历实例,Update() 返 false 即 Delete + erase/ CreateIndexedFlyNORMAL /
# FIRE_CRACKER / AUTO_FIRE 三种服务端索引飞行)
# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Create → __SetDataPointerm_v3Position=起点、
# m_bAlive、m_fStartTime、由 m_fRollAngle-90 yaw + m_fConeAngle roll 建四元数、可选
# m_bSpreading 随机扩散、m_v3Velocity=m_v3LocalVelocity=(0,-m_fInitVel,0)、m_v3Accel=
# pData->m_v3Accel、m_fRemainRange=m_fRange、m_iPierceCount+ __SetTargetDirection
# v3TargetPos=target.GetFlyTargetPosition()m_bMaintainParallel 时 z+=50;方向=
# normalize(target-pos),把局部速度 / 加速度旋到该方向)
# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Update()homing → AdjustDirectionForHoming
# v3LastPosition=m_v3Positionm_v3Velocity+=m_v3Accel*dtm_v3Velocity.z+=m_fGravity*dt
# v3Movement=m_v3Velocity*dt_fMoveDistance=|v3Movement|m_fRemainRange-=_fMoveDistance
# m_v3Position+=v3Movementm_fRemainRange<0 → OnExplodingOutOfRange + __Explode(false)
# 对象目标且未命中 && square_distance_between_linesegment_and_point(pos,last,targetPos) <
# m_fBombRange² → m_bTargetHitted=TRUEm_canAttack 时 pVictim->OnShootDamage()
# handler->OnExplodingAtTarget(skill)m_iPierceCount>0 → --、__Bomb();否则 __Explode()
# 位置目标同样线段判定 → __Explode()m_bHitOnBackground → GetTerrainHeight(x,-y)>z → 触地
# REF/GameLib/FlyingInstance.cpp __Explode(bBomb=true)!m_bAlive returnm_bAlive=false
# bBomb → __Bomb()(在 m_v3Position 生成 m_dwBombEffectID 特效)
# REF/EterLib/GrpMath.h:80 square_distance_between_linesegment_and_point(点到线段的平方距离,
# d<=0 取端点 p1、d>=l 取端点 p2、否则叉积模方 / l)
# REF/GameLib/FlyingData.cpp CFlyingData::__Initialize()(本文件 FlyData 默认值逐项对齐)
# REF/GameLib/FlyHandler.h IFlyEventHandlerOnExplodingOutOfRange / OnExplodingAtBackground /
# 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 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 脚本字段。原版文件使用 pixel==cm;加载器在边界处
# 统一换算为 Godot 米制,因此 init_vel / range / bomb_range / acceleration 等
# 长度量都按 0.01 转换,角度和时间保持原单位。
class FlyData extends RefCounted:
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
var is_homing := false # m_bIsHoming
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 }。
class FlyInstance extends RefCounted:
var id := 0
var data: FlyData
var pos := Vector3.ZERO
var last_pos := Vector3.ZERO
var vel := Vector3.ZERO
var accel := Vector3.ZERO
var remain_range := 0.0
var pierce := 0
var alive := false
var can_attack := false
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):
_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
can_attack = attack
pos = start_world
last_pos = start_world
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 = _node_fly_target_position(target as Node3D)
elif target is Vector3:
_is_object = false
_target_pos = target
elif target is Dictionary:
target_vid = int(target.get("vid", 0))
var tp: Variant = target.get("pos", null)
if tp is Vector3:
_target_pos = tp
var tn: Variant = target.get("node", null)
if tn is Node3D:
_is_object = true
_target_obj = tn
_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)
if dir.length() < 0.0001:
dir = Vector3(0, 0, -1)
dir = dir.normalized()
vel = dir * d.init_vel
accel = d.accel
if accel.length_squared() > 0.0:
# 局部帧 -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:
var tdir := (target_position() - pos)
if tdir.length() < 0.0001:
return
tdir = tdir.normalized()
var vdir := vel.normalized()
var ang := vdir.angle_to(tdir)
if ang < 0.0001:
return
var maxr := deg_to_rad(data.homing_max_angle) if data.homing_max_angle < 180.0 else ang
var t := clampf(maxr / ang, 0.0, 1.0)
var speed := vel.length()
vel = vdir.slerp(tdir, t) * speed
# CFlyingInstance::Update() —— 返 false 表示该销毁(超程 / 命中且不穿透 / 触地)。
func update(dt: float) -> bool:
if not alive:
return false
_elapsed += dt
if data.is_homing and _elapsed > data.homing_start_time and _is_object:
_adjust_homing()
last_pos = pos
vel += accel * dt
vel.y += data.gravity * dt
var movement := vel * dt
var move_dist := movement.length()
remain_range -= move_dist
pos += movement
_update_attachments(dt)
if remain_range < 0.0:
_emit("out_of_range")
_explode(false)
return false
var br2 := data.bomb_range * data.bomb_range
if _is_object:
if not target_hitted:
if _seg_point_sq_dist(pos, last_pos, target_position()) < br2:
target_hitted = true
if can_attack:
_emit("shoot_damage")
_emit("at_target")
if pierce > 0:
pierce -= 1
_emit("bomb")
else:
_explode()
return false
return true
else:
if _seg_point_sq_dist(pos, last_pos, target_position()) < br2:
_emit("at_position")
_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))
if gh > pos.y:
_emit("at_background")
_explode()
return false
# 40250 CFlyingInstance::Update FCheckBackgroundDuringFlying:
# 空间 3D 建筑与静态障碍物碰撞(碰撞层 2: StaticBody3D
if _world is Node3D and _world.is_inside_tree():
var w3d: World3D = _world.get_world_3d()
if w3d and w3d.direct_space_state:
var q := PhysicsRayQueryParameters3D.create(last_pos, pos, 2)
var hit := w3d.direct_space_state.intersect_ray(q)
if not hit.is_empty():
pos = hit.position
_emit("at_background")
_explode()
return false
return true
func _explode(bomb := true) -> void:
if not alive:
return
alive = false
if bomb:
_emit("bomb")
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, 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:
var v := p2 - p1
var l := v.length_squared()
var w := x - p1
var d := w.dot(v)
if d <= 0.0:
return w.length_squared()
if d >= l:
return (x - p2).length_squared()
return w.cross(v).length_squared() / l
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, 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, 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)
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()
mat.emission_enabled = true
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
if skill_index == FLY_EXP:
# 40250 官方经验聚能光球: ga_piece_yellow_small2.msf
sm.radius = 0.12
sm.height = 0.24
mat.albedo_color = Color(1.0, 0.9, 0.2, 0.95)
mat.emission = Color(1.0, 0.8, 0.1) * 3.0
elif skill_index in [FLY_HP_SMALL, FLY_HP_MEDIUM, FLY_HP_BIG]:
# 40250 官方生命吸取光球: ga_piece_red_small.msf
sm.radius = 0.12
sm.height = 0.24
mat.albedo_color = Color(1.0, 0.25, 0.25, 0.95)
mat.emission = Color(1.0, 0.1, 0.1) * 3.0
elif skill_index in [FLY_SP_SMALL, FLY_SP_MEDIUM, FLY_SP_BIG]:
# 40250 官方法力吸取光球: ga_piece_blue_small.msf
sm.radius = 0.12
sm.height = 0.24
mat.albedo_color = Color(0.25, 0.6, 1.0, 0.95)
mat.emission = Color(0.1, 0.5, 1.0) * 3.0
else:
sm.radius = 0.08
sm.height = 0.16
mat.albedo_color = Color(1.0, 0.9, 0.4)
mat.emission = Color(0.9, 0.7, 0.2)
m.mesh = sm
m.material_override = mat
parent.add_child(m)
if m.is_inside_tree():
m.global_position = inst.pos
else:
m.position = inst.pos
_visuals[inst] = m
return inst
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]
var keep := inst.update(dt)
var vis: MeshInstance3D = _visuals.get(inst, null)
if is_instance_valid(vis):
if vis.is_inside_tree():
vis.global_position = inst.pos
else:
vis.position = inst.pos
if not keep:
_release_instance(inst)
if is_instance_valid(vis):
vis.queue_free()
_visuals.erase(inst)
_instances.remove_at(i)
else:
i += 1
func _process(dt: float) -> void:
if not manual_step:
step(dt)
func _on_instance_event(inst: FlyInstance, ev: String, wp: Vector3, vid: int) -> void:
match ev:
"shoot_damage":
shoot_damage.emit(vid)
"out_of_range", "at_target", "at_position", "at_background", "at_another":
exploded.emit(wp, ev)
if ev == "at_target" and inst.skill_index == FLY_EXP:
exp_absorbed.emit(wp)
# 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:
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()
var sm := SphereMesh.new()
sm.radius = 0.06
sm.height = 0.12
f.mesh = sm
var mat := StandardMaterial3D.new()
mat.emission_enabled = true
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
var final_scale := Vector3.ONE * 6.0
if skill_index == FLY_EXP:
mat.albedo_color = Color(1.0, 0.9, 0.2, 0.95)
mat.emission = Color(1.0, 0.85, 0.2) * 2.5
final_scale = Vector3.ONE * 8.0
elif skill_index in [FLY_HP_SMALL, FLY_HP_MEDIUM, FLY_HP_BIG]:
mat.albedo_color = Color(1.0, 0.25, 0.25, 0.95)
mat.emission = Color(1.0, 0.1, 0.1) * 2.5
final_scale = Vector3.ONE * 8.0
elif skill_index in [FLY_SP_SMALL, FLY_SP_MEDIUM, FLY_SP_BIG]:
mat.albedo_color = Color(0.25, 0.6, 1.0, 0.95)
mat.emission = Color(0.1, 0.5, 1.0) * 2.5
final_scale = Vector3.ONE * 8.0
else:
mat.albedo_color = Color(1.0, 0.75, 0.3, 0.9)
mat.emission = Color(1.0, 0.6, 0.2)
f.material_override = mat
parent.add_child(f)
_flash_nodes.append(f)
if f.is_inside_tree():
f.global_position = wp
else:
f.position = wp
var tw := create_tween()
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(func() -> void:
_flash_nodes.erase(f)
if is_instance_valid(f):
f.queue_free()
)