- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
848 lines
33 KiB
GDScript
848 lines
33 KiB
GDScript
# 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)/ CreateIndexedFly(NORMAL /
|
||
# FIRE_CRACKER / AUTO_FIRE 三种服务端索引飞行)
|
||
# REF/GameLib/FlyingInstance.cpp CFlyingInstance::Create → __SetDataPointer(m_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_v3Position;m_v3Velocity+=m_v3Accel*dt;m_v3Velocity.z+=m_fGravity*dt;
|
||
# v3Movement=m_v3Velocity*dt;_fMoveDistance=|v3Movement|;m_fRemainRange-=_fMoveDistance;
|
||
# m_v3Position+=v3Movement;m_fRemainRange<0 → OnExplodingOutOfRange + __Explode(false);
|
||
# 对象目标且未命中 && square_distance_between_linesegment_and_point(pos,last,targetPos) <
|
||
# m_fBombRange² → m_bTargetHitted=TRUE;m_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 return;m_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 IFlyEventHandler:OnExplodingOutOfRange / 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::EIndexFlyType(REF/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=200(cm/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=500(cm)→ 5 m
|
||
var bomb_range := 0.1 # m_fBombRange=10(cm)→ 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()
|
||
)
|