Implement playable Mac client and rendering validation
This commit is contained in:
+227
-121
@@ -13,6 +13,9 @@ extends Node3D
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const Mde = preload("res://fx/mde.gd")
|
||||
const EffectSpace = preload("res://fx/effect_space.gd")
|
||||
const ParticleColorMaterial = preload("res://fx/particle_color_material.gd")
|
||||
const ParticleFaces = preload("res://fx/particle_faces.gd")
|
||||
|
||||
var spec := {}
|
||||
var assets_root := ""
|
||||
@@ -24,6 +27,8 @@ var _mesh_states: Array[Dictionary] = []
|
||||
var _lights: Array[OmniLight3D] = []
|
||||
var _light_states: Array[Dictionary] = []
|
||||
var _bsphere_r := 0.0
|
||||
var _playing := false
|
||||
var _cleanup_remaining := -1.0
|
||||
|
||||
static var _glow_tex: Texture2D
|
||||
|
||||
@@ -48,41 +53,54 @@ func build(mse_spec: Dictionary, assets := "") -> void:
|
||||
_lights.append(light)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _playing:
|
||||
return
|
||||
for state in _particle_states:
|
||||
_advance_particle(state, delta)
|
||||
for state in _mesh_states:
|
||||
_advance_mesh(state, delta)
|
||||
for state in _light_states:
|
||||
_advance_light(state, delta)
|
||||
if _cleanup_remaining >= 0.0:
|
||||
_cleanup_remaining -= maxf(delta, 0.0)
|
||||
if _cleanup_remaining <= 0.0:
|
||||
queue_free()
|
||||
|
||||
func play(force_one_shot := false) -> void:
|
||||
# EffectPlayer can be reused by the registry. Reset the CPU-side clocks as
|
||||
# well as the GPU emitters so cycle gates do not inherit the previous play.
|
||||
# One playback clock owns starts and cleanup. No detached SceneTree timers
|
||||
# may start or delete an effect after stop/replay, or while it is paused.
|
||||
_playing = true
|
||||
_cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0
|
||||
for state in _particle_states:
|
||||
state["clock"] = 0.0
|
||||
state["emission_stopped"] = false
|
||||
for i in _emitters.size():
|
||||
var e := _emitters[i]
|
||||
var st := float(_emitters[i].get_meta("start_time", 0.0))
|
||||
if force_one_shot:
|
||||
e.one_shot = true
|
||||
var tree: SceneTree = get_tree() if is_inside_tree() else null
|
||||
if st <= 0.0 or tree == null:
|
||||
e.restart()
|
||||
e.emitting = true
|
||||
else:
|
||||
tree.create_timer(st).timeout.connect(func():
|
||||
if is_instance_valid(e):
|
||||
e.restart()
|
||||
e.emitting = true)
|
||||
var tr: SceneTree = get_tree() if is_inside_tree() else null
|
||||
if (force_one_shot or one_shot) and tr:
|
||||
var total := _longest_life() + 0.5
|
||||
tr.create_timer(maxf(total, 1.5)).timeout.connect(queue_free)
|
||||
state["emission_started"] = false
|
||||
var e: GPUParticles3D = state["node"]
|
||||
# Effect one_shot controls node cleanup, not a GPU emission cycle.
|
||||
# Individual lifetime may be much shorter than the MSE emission window.
|
||||
e.one_shot = false
|
||||
e.restart()
|
||||
e.emitting = false
|
||||
e.position = _position_at(state.get("rows", []), 0.0)
|
||||
_advance_particle(state, 0.0)
|
||||
for state in _mesh_states:
|
||||
state["clock"] = 0.0
|
||||
for child in state.get("children", []):
|
||||
child["frame"] = -1
|
||||
child["texture_frame"] = -1
|
||||
_advance_mesh(state, 0.0)
|
||||
for state in _light_states:
|
||||
state["clock"] = 0.0
|
||||
_advance_light(state, 0.0)
|
||||
|
||||
func stop() -> void:
|
||||
# Existing particles may finish their lifetime; mesh/light emission stops now.
|
||||
_playing = false
|
||||
_cleanup_remaining = -1.0
|
||||
for e in _emitters:
|
||||
e.emitting = false
|
||||
for mesh in _mesh_nodes:
|
||||
mesh.visible = false
|
||||
for light in _lights:
|
||||
light.visible = false
|
||||
|
||||
@@ -109,6 +127,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var emit: Dictionary = p.get("emitter", {})
|
||||
var prop: Dictionary = p.get("particle", {})
|
||||
var g := GPUParticles3D.new()
|
||||
g.emitting = false
|
||||
g.set_meta("start_time", float(p.get("start_time", 0.0)))
|
||||
g.set_meta("emitter_advanced_type", int(_n(emit.get("EmitterAdvancedType", 0))))
|
||||
g.set_meta("emitter_emit_from_edge", int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0)
|
||||
@@ -116,8 +135,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
|
||||
# EffectLib stores effect coordinates in centimetres; the Godot scene is
|
||||
# metres. The position curve is evaluated again as the effect advances.
|
||||
g.position = Vector3(float(positions[0][2]), float(positions[0][3]),
|
||||
float(positions[0][4])) * 0.01
|
||||
g.position = _position_at(positions, 0.0)
|
||||
g.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16))))
|
||||
g.explosiveness = 0.0
|
||||
|
||||
@@ -128,7 +146,8 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var cycle := _n(emit.get("CycleLength", 0.0)) if emit.has("CycleLength") else 0.05
|
||||
var loop := int(_n(emit.get("CycleLoopEnable", 0)))
|
||||
var loop_count := int(_n(emit.get("LoopCount", 0)))
|
||||
g.one_shot = (loop == 0)
|
||||
g.one_shot = false # emission ends at the MSE clock gate, not one particle lifetime
|
||||
g.local_coords = int(_n(prop.get("AttachEnable", 0))) != 0
|
||||
g.set_meta("cycle_length", maxf(cycle, 0.0))
|
||||
g.set_meta("cycle_loop", loop != 0)
|
||||
g.set_meta("loop_count", maxi(loop_count, 0))
|
||||
@@ -140,11 +159,11 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
var shape := int(_n(emit.get("EmitterShape", 0)))
|
||||
var emit_from_edge := int(_n(emit.get("EmitterEmitFromEdgeFlag", 0))) != 0
|
||||
var radius := maxf(0.0, _n(emit.get("EmittingRadius", 10.0))) * 0.01
|
||||
var emitting_size := _vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0])) * 0.01
|
||||
var emitting_size := EffectSpace.position(_vec3(emit.get("EmittingSize", [0.0, 0.0, 0.0])))
|
||||
match shape:
|
||||
1: # CEmitterProperty::EMITTER_SHAPE_ELLIPSE (flat box approximation)
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
pm.emission_box_extents = Vector3(maxf(radius, 0.01), maxf(radius, 0.01), 0.005)
|
||||
pm.emission_box_extents = Vector3(maxf(radius, 0.01), 0.005, maxf(radius, 0.01))
|
||||
2: # CEmitterProperty::EMITTER_SHAPE_SQUARE
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
pm.emission_box_extents = Vector3(maxf(absf(emitting_size.x) * 0.5, 0.005),
|
||||
@@ -160,6 +179,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
direction += Vector3(_last_val(emit.get("TimeEventEmittingDirectionX", []), 0.0),
|
||||
_last_val(emit.get("TimeEventEmittingDirectionY", []), 0.0),
|
||||
_last_val(emit.get("TimeEventEmittingDirectionZ", []), 0.0))
|
||||
direction = EffectSpace.direction(direction)
|
||||
pm.direction = direction.normalized() if direction.length_squared() > 0.000001 else Vector3(0, 1, 0)
|
||||
pm.spread = 25.0 if direction.length_squared() <= 0.000001 else 0.0
|
||||
# The reference computes OUTER/INNER velocity from each particle's radial
|
||||
@@ -175,44 +195,84 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
# 重力
|
||||
var grav := _last_val(p.get("particle", {}).get("TimeEventGravity", []), 0.0)
|
||||
pm.gravity = Vector3(0, -grav * 0.01, 0)
|
||||
# 大小
|
||||
var sx := _last_val(emit.get("TimeEventSizeX", []), 32.0) * 0.01
|
||||
pm.scale_min = maxf(0.02, sx * 0.7)
|
||||
pm.scale_max = maxf(0.03, sx)
|
||||
pm.scale_curve = _curve_tex(prop.get("TimeEventScaleX", [[0, 1], [1, 0]]))
|
||||
# 颜色渐变(RGB 曲线各取末值 + Alpha 曲线)
|
||||
pm.color = _rgb(prop)
|
||||
var ramp := _alpha_ramp(prop.get("TimeEventAlpha", []), pm.color)
|
||||
if ramp:
|
||||
pm.color_ramp = ramp
|
||||
# EffectLib stores independent half-width/height. Lifetime scale is XY,
|
||||
# not a uniform random multiplier. Birth-time size tracks remain approximated
|
||||
# by their last value until per-particle emitter-clock sampling is supported.
|
||||
var half_width := _last_val(emit.get("TimeEventSizeX", []), 32.0)
|
||||
var half_height := _last_val(emit.get("TimeEventSizeY", []), 32.0)
|
||||
pm.scale_min = 1.0
|
||||
pm.scale_max = 1.0
|
||||
var scale_texture := CurveXYZTexture.new()
|
||||
scale_texture.curve_x = _scale_curve(prop.get("TimeEventScaleX", []))
|
||||
scale_texture.curve_y = _scale_curve(prop.get("TimeEventScaleY", []))
|
||||
scale_texture.curve_z = _scale_curve([])
|
||||
pm.scale_curve = scale_texture
|
||||
# Godot multiplies color by color_ramp. Store RGBA only in the ramp so
|
||||
# channels are not squared, and sample all four tracks over particle age.
|
||||
pm.color = Color.WHITE
|
||||
pm.color_ramp = _color_ramp(prop)
|
||||
# 旋转:ParticleProperty 的 0=NONE、1=TIME_EVENT、2=CW、3=CCW、
|
||||
# 4=RANDOM_DIRECTION。Godot 没有完全相同的每粒子时间表,先保留方向和
|
||||
# 时间表末值,避免把 NONE 误当成恒速旋转。
|
||||
# 4=RANDOM_DIRECTION。TIME_EVENT 按粒子寿命采样转速再积分。
|
||||
var rotation_type := int(_n(prop.get("RotationType", 0)))
|
||||
var angle_begin := _n(prop.get("RotationRandomStartingBegin", 0.0))
|
||||
var angle_end := _n(prop.get("RotationRandomStartingEnd", 0.0))
|
||||
pm.angle_min = minf(angle_begin, angle_end)
|
||||
pm.angle_max = maxf(angle_begin, angle_end)
|
||||
var rspeed := _n(prop.get("RotationSpeed", 0.0))
|
||||
if rotation_type == 1:
|
||||
rspeed = _last_val(prop.get("TimeEventRotation", []), rspeed)
|
||||
var rotation_texture := CurveTexture.new()
|
||||
rotation_texture.curve = _scale_curve(prop.get("TimeEventRotation", []), 0.0)
|
||||
pm.angular_velocity_curve = rotation_texture
|
||||
# Godot 4.7's curve branch accumulates directly in radians (CUSTOM.x),
|
||||
# whereas the constant branch converts degrees internally. The curve
|
||||
# retains source degrees/sec; this multiplier supplies the conversion.
|
||||
pm.angular_velocity_min = deg_to_rad(1.0)
|
||||
pm.angular_velocity_max = deg_to_rad(1.0)
|
||||
if rotation_type == 3:
|
||||
rspeed = -rspeed
|
||||
if rotation_type != 0 and rspeed != 0.0:
|
||||
pm.angular_velocity_min = deg_to_rad(rspeed) * 0.3
|
||||
pm.angular_velocity_max = deg_to_rad(rspeed)
|
||||
if rotation_type in [2, 3] and rspeed != 0.0:
|
||||
# Both EffectLib and Godot's constant angular velocity use degrees/sec.
|
||||
pm.angular_velocity_min = rspeed
|
||||
pm.angular_velocity_max = rspeed
|
||||
if rotation_type == 4:
|
||||
# Choose a stable per-birth sign in the drawing shader, retaining full
|
||||
# magnitude in both directions and the initial angle in CUSTOM.x.
|
||||
pm.anim_speed_min = 0.0
|
||||
pm.anim_speed_max = 0.0
|
||||
pm.anim_offset_min = 0.0
|
||||
pm.anim_offset_max = 1.0
|
||||
g.process_material = pm
|
||||
|
||||
# 绘制网格 + 材质(billboard + 混合)
|
||||
var qm := QuadMesh.new()
|
||||
qm.size = Vector2(1, 1)
|
||||
g.draw_pass_1 = qm
|
||||
g.draw_pass_1 = ParticleFaces.build(Vector2(maxf(half_width, 0.0), maxf(half_height, 0.0)) * 0.02,
|
||||
int(_n(prop.get("BillboardType", 1))))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
# EffectInstance::OnRender disables culling and depth writes for FX, but
|
||||
# keeps depth testing so terrain/buildings can still occlude the effect.
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||
mat.billboard_mode = _billboard(int(_n(prop.get("BillboardType", 1))))
|
||||
# Only particle billboards consume INSTANCE_CUSTOM.x (rotation). Keep the
|
||||
# shared mesh-material mapping separate: meshes have no particle custom data.
|
||||
if int(_n(prop.get("BillboardType", 1))) == 1:
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
|
||||
mat.billboard_keep_scale = true
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.blend_mode = _blend(int(_n(prop.get("SrcBlendType", 5))), int(_n(prop.get("DestBlendType", 2))))
|
||||
var tex := _particle_texture(prop)
|
||||
mat.albedo_texture = tex if tex else _glow()
|
||||
var textures := _particle_textures(prop)
|
||||
mat.albedo_texture = textures[0] if not textures.is_empty() else _glow()
|
||||
var frames: Texture2DArray
|
||||
if textures.size() > 1:
|
||||
frames = _texture_array(textures)
|
||||
# CUSTOM.z is a stable per-birth seed; CUSTOM.y is this particle's age.
|
||||
# Do not use a shared AnimatedTexture wall clock for all particles.
|
||||
pm.anim_speed_min = 0.0
|
||||
pm.anim_speed_max = 0.0
|
||||
pm.anim_offset_min = 0.0
|
||||
pm.anim_offset_max = 1.0
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
g.material_override = mat
|
||||
g.material_override = ParticleColorMaterial.build(mat, int(_n(prop.get("ColorOperationType", 4))), frames, prop, g.lifetime)
|
||||
_particle_states.append({
|
||||
"node": g,
|
||||
"rows": positions,
|
||||
@@ -222,6 +282,7 @@ func _build_particle(p: Dictionary) -> GPUParticles3D:
|
||||
"cycle_loop": loop != 0,
|
||||
"loop_count": maxi(loop_count, 0),
|
||||
"emission_stopped": false,
|
||||
"emission_started": false,
|
||||
})
|
||||
return g
|
||||
|
||||
@@ -230,8 +291,7 @@ func _advance_particle(state: Dictionary, delta: float) -> void:
|
||||
if g == null or not is_instance_valid(g):
|
||||
return
|
||||
var rows: Array = state.get("rows", [])
|
||||
if rows.is_empty():
|
||||
return
|
||||
# Position tracks are optional; stationary emitters still obey lifecycle gates.
|
||||
var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0)
|
||||
state["clock"] = clock
|
||||
var start := float(state.get("start_time", 0.0))
|
||||
@@ -248,7 +308,11 @@ func _advance_particle(state: Dictionary, delta: float) -> void:
|
||||
g.emitting = false
|
||||
state["emission_stopped"] = true
|
||||
return
|
||||
if not bool(state.get("emission_stopped", false)):
|
||||
if _playing and not bool(state.get("emission_started", false)) and not bool(state.get("emission_stopped", false)):
|
||||
g.restart()
|
||||
g.emitting = true
|
||||
state["emission_started"] = true
|
||||
if not rows.is_empty() and not bool(state.get("emission_stopped", false)):
|
||||
g.position = _position_at(rows, local)
|
||||
|
||||
func _build_mesh(m: Dictionary) -> Node3D:
|
||||
@@ -262,7 +326,7 @@ func _build_mesh(m: Dictionary) -> Node3D:
|
||||
n.name = "mesh_" + String(m.get("mesh_file", "?")).get_basename()
|
||||
var positions: Array = m.get("position", [])
|
||||
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
|
||||
n.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01
|
||||
n.position = _position_at(positions, 0.0)
|
||||
|
||||
var geometries: Array = mesh_data.get("geometries", [])
|
||||
var elements: Array = m.get("elements", [])
|
||||
@@ -328,7 +392,7 @@ func _build_light(l: Dictionary) -> OmniLight3D:
|
||||
light.name = "simple_light"
|
||||
var positions: Array = l.get("position", [])
|
||||
if not positions.is_empty() and positions[0] is Array and positions[0].size() >= 5:
|
||||
light.position = Vector3(float(positions[0][2]), float(positions[0][3]), float(positions[0][4])) * 0.01
|
||||
light.position = _position_at(positions, 0.0)
|
||||
var diffuse: Array = l.get("diffuse", [0.0, 0.0, 0.0, 1.0])
|
||||
if diffuse.size() >= 4:
|
||||
light.light_color = Color(float(diffuse[0]), float(diffuse[1]), float(diffuse[2]), float(diffuse[3]))
|
||||
@@ -477,6 +541,8 @@ func _array_mesh(frame: Dictionary) -> ArrayMesh:
|
||||
func _mesh_material(element: Dictionary, textures: Array) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.billboard_mode = _billboard(int(_n(element.get("BillboardType", 0))))
|
||||
mat.blend_mode = _blend(int(_n(element.get("BlendingSrcType", 5))), int(_n(element.get("BlendingDestType", 2))))
|
||||
@@ -578,7 +644,7 @@ func _position_at(rows, time: float) -> Vector3:
|
||||
var p0 := _position_vector(previous)
|
||||
var p1 := _position_vector(current)
|
||||
if String(previous[1]) == "MOVING_TYPE_BEZIER_CURVE" and previous.size() >= 8:
|
||||
var control := Vector3(float(previous[5]), float(previous[6]), float(previous[7]))
|
||||
var control := EffectSpace.direction(Vector3(float(previous[5]), float(previous[6]), float(previous[7])))
|
||||
var middle := p0 + control
|
||||
var inv := 1.0 - t
|
||||
return (p0 * inv * inv + middle * 2.0 * inv * t + p1 * t * t) * 0.01
|
||||
@@ -588,7 +654,7 @@ func _position_at(rows, time: float) -> Vector3:
|
||||
|
||||
func _position_vector(row) -> Vector3:
|
||||
if row is Array and row.size() >= 5:
|
||||
return Vector3(float(row[2]), float(row[3]), float(row[4]))
|
||||
return EffectSpace.direction(Vector3(float(row[2]), float(row[3]), float(row[4])))
|
||||
return Vector3.ZERO
|
||||
|
||||
func _event_value(rows, time: float, fallback: float) -> float:
|
||||
@@ -624,10 +690,10 @@ func _vec3(v) -> Vector3:
|
||||
return Vector3(float(v[0]), float(v[1]), float(v[2]))
|
||||
return Vector3.ZERO
|
||||
|
||||
func _particle_texture(prop: Dictionary) -> Texture2D:
|
||||
func _particle_textures(prop: Dictionary) -> Array[Texture2D]:
|
||||
var rows: Variant = prop.get("TextureFiles", [])
|
||||
if not rows is Array or rows.is_empty():
|
||||
return null
|
||||
return []
|
||||
# ParticleSystemData resolves relative texture names against the .mse
|
||||
# directory. UiAssets keeps the same behavior and uses the native DDS path.
|
||||
var mse_dir := String(spec.get("dir", ""))
|
||||
@@ -641,23 +707,29 @@ func _particle_texture(prop: Dictionary) -> Texture2D:
|
||||
tex = UiAssets.load_tex(assets_root, file)
|
||||
if tex:
|
||||
textures.append(tex)
|
||||
if textures.is_empty():
|
||||
return null
|
||||
var animation_type := int(_n(prop.get("TexAniType", 0)))
|
||||
if textures.size() == 1 or animation_type == 0:
|
||||
return textures[0]
|
||||
# AnimatedTexture is shared by the draw pass, unlike the original per-particle
|
||||
# frame index. This preserves the real frame order and delay for the common
|
||||
# multi-file case; random-start variants remain deterministic until the GPU
|
||||
# particle custom-data path is added.
|
||||
if animation_type == 2: # TEXTURE_ANIMATION_TYPE_CCW
|
||||
textures.reverse()
|
||||
var animated := AnimatedTexture.new()
|
||||
animated.fps = 1.0 / maxf(_n(prop.get("TexAniDelay", 0.05)), 0.001)
|
||||
animated.frames = mini(textures.size(), 256)
|
||||
for i in animated.frames:
|
||||
animated.set_frame_texture(i, textures[i])
|
||||
return animated
|
||||
return textures
|
||||
|
||||
func _texture_array(textures: Array[Texture2D]) -> Texture2DArray:
|
||||
var images: Array[Image] = []
|
||||
var width := 1
|
||||
var height := 1
|
||||
for texture in textures:
|
||||
width = maxi(width, texture.get_width())
|
||||
height = maxi(height, texture.get_height())
|
||||
for texture in textures:
|
||||
var img := texture.get_image()
|
||||
if img == null or img.is_empty():
|
||||
return null
|
||||
if img.is_compressed():
|
||||
if img.decompress() != OK:
|
||||
return null
|
||||
img.convert(Image.FORMAT_RGBA8)
|
||||
img.clear_mipmaps()
|
||||
if img.get_width() != width or img.get_height() != height:
|
||||
img.resize(width, height, Image.INTERPOLATE_BILINEAR)
|
||||
images.append(img)
|
||||
var frames := Texture2DArray.new()
|
||||
return frames if frames.create_from_images(images) == OK else null
|
||||
|
||||
# List 表末行的值(列 idx,默认 col 1 = 时间后第一个数)
|
||||
func _last_val(rows, def: float, col := 1) -> float:
|
||||
@@ -667,68 +739,102 @@ func _last_val(rows, def: float, col := 1) -> float:
|
||||
return float(r[col])
|
||||
return def
|
||||
|
||||
func _rgb(prop: Dictionary) -> Color:
|
||||
return Color(
|
||||
_last_val(prop.get("TimeEventColorRed", []), 1.0),
|
||||
_last_val(prop.get("TimeEventColorGreen", []), 1.0),
|
||||
_last_val(prop.get("TimeEventColorBlue", []), 1.0),
|
||||
1.0)
|
||||
|
||||
# TimeEventScaleX rows [[t,v],...] -> CurveTexture
|
||||
func _curve_tex(rows) -> CurveTexture:
|
||||
# Independent lifetime channel, with linear tangents matching EffectLib.
|
||||
func _scale_curve(rows, fallback := 1.0) -> Curve:
|
||||
var values := {}
|
||||
if rows is Array:
|
||||
for row in rows:
|
||||
if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int):
|
||||
var time := float(row[0])
|
||||
var value := float(row[1])
|
||||
if is_finite(time) and is_finite(value):
|
||||
if not values.has(time):
|
||||
values[time] = []
|
||||
values[time].append(value)
|
||||
var times := values.keys()
|
||||
times.sort()
|
||||
var track: Array = []
|
||||
for time in times:
|
||||
for value in values[time]:
|
||||
track.append([time, value])
|
||||
var points := {0.0: _event_value(track, 0.0, fallback), 1.0: _event_value(track, 1.0, fallback)}
|
||||
for i in times.size():
|
||||
var time: float = times[i]
|
||||
if time > 0.0 and time < 1.0:
|
||||
points[time] = values[time][0]
|
||||
if time >= 0.0 and time < 1.0 and values[time].size() > 1:
|
||||
# EffectLib takes the first value exactly at a duplicate key, then
|
||||
# departs from the last. A texture cannot encode a zero-width jump;
|
||||
# preserve both sides using a narrow transition, not a long ramp.
|
||||
var next_time: float = times[i + 1] if i + 1 < times.size() else 1.0
|
||||
# Keep wider than Curve's approximate-equality epsilon.
|
||||
var after := time + minf(0.0001, (next_time - time) * 0.5)
|
||||
points[after] = values[time][-1]
|
||||
var c := Curve.new()
|
||||
c.min_value = 0.0
|
||||
c.max_value = 2.0
|
||||
if rows is Array and rows.size() > 0:
|
||||
for r in rows:
|
||||
if r is Array and r.size() >= 2:
|
||||
c.add_point(Vector2(clampf(float(r[0]), 0, 1), float(r[1])))
|
||||
else:
|
||||
c.add_point(Vector2(0, 1))
|
||||
c.add_point(Vector2(1, 0))
|
||||
var t := CurveTexture.new()
|
||||
t.curve = c
|
||||
return t
|
||||
c.min_value = minf(0.0, points.values().min())
|
||||
c.max_value = maxf(1.0, points.values().max())
|
||||
times = points.keys()
|
||||
times.sort()
|
||||
for time in times:
|
||||
c.add_point(Vector2(time, points[time]), 0.0, 0.0, Curve.TANGENT_LINEAR, Curve.TANGENT_LINEAR)
|
||||
return c
|
||||
|
||||
func _alpha_ramp(rows, base: Color) -> GradientTexture1D:
|
||||
if not (rows is Array) or rows.size() < 1:
|
||||
return null
|
||||
func _color_ramp(prop: Dictionary) -> GradientTexture1D:
|
||||
# Match EffectLib ParticleSystemData: union the RGBA key times, then
|
||||
# linearly evaluate each channel there. Preserve independent channel knots.
|
||||
var times := {0.0: true, 1.0: true}
|
||||
var tracks: Array = []
|
||||
for key in ["TimeEventColorRed", "TimeEventColorGreen", "TimeEventColorBlue", "TimeEventAlpha"]:
|
||||
var points := {}
|
||||
var rows = prop.get(key, [])
|
||||
if rows is Array:
|
||||
for row in rows:
|
||||
if row is Array and row.size() >= 2 and (row[0] is float or row[0] is int) and (row[1] is float or row[1] is int):
|
||||
var time := float(row[0])
|
||||
var value := float(row[1])
|
||||
if is_finite(time) and is_finite(value):
|
||||
points[time] = value
|
||||
times[clampf(time, 0.0, 1.0)] = true
|
||||
var ordered := points.keys()
|
||||
ordered.sort()
|
||||
var track: Array = []
|
||||
for time in ordered:
|
||||
track.append([time, points[time]])
|
||||
tracks.append(track)
|
||||
var ordered_times := times.keys()
|
||||
ordered_times.sort()
|
||||
var offsets := PackedFloat32Array()
|
||||
var colors := PackedColorArray()
|
||||
for time in ordered_times:
|
||||
offsets.append(time)
|
||||
colors.append(Color(
|
||||
clampf(_event_value(tracks[0], time, 1.0), 0.0, 1.0),
|
||||
clampf(_event_value(tracks[1], time, 1.0), 0.0, 1.0),
|
||||
clampf(_event_value(tracks[2], time, 1.0), 0.0, 1.0),
|
||||
clampf(_event_value(tracks[3], time, 1.0), 0.0, 1.0)))
|
||||
var grad := Gradient.new()
|
||||
var pts := []
|
||||
for r in rows:
|
||||
if r is Array and r.size() >= 2:
|
||||
pts.append([clampf(float(r[0]), 0, 1), float(r[1])])
|
||||
if pts.is_empty():
|
||||
return null
|
||||
pts.sort_custom(func(a, b): return a[0] < b[0])
|
||||
if pts[0][0] > 0.0:
|
||||
pts.push_front([0.0, pts[0][1]])
|
||||
if pts[-1][0] < 1.0:
|
||||
pts.append([1.0, pts[-1][1]])
|
||||
if pts.size() == 1:
|
||||
pts.append([minf(1.0, pts[0][0] + 0.001), pts[0][1]])
|
||||
grad.offsets = PackedFloat32Array([float(pts[0][0]), float(pts[-1][0])])
|
||||
grad.colors = PackedColorArray([
|
||||
Color(base.r, base.g, base.b, clampf(float(pts[0][1]), 0, 1)),
|
||||
Color(base.r, base.g, base.b, clampf(float(pts[-1][1]), 0, 1)),
|
||||
])
|
||||
for i in range(1, pts.size() - 1):
|
||||
var pt = pts[i]
|
||||
grad.add_point(float(pt[0]), Color(base.r, base.g, base.b, clampf(float(pt[1]), 0, 1)))
|
||||
grad.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_LINEAR
|
||||
grad.offsets = offsets
|
||||
grad.colors = colors
|
||||
var gt := GradientTexture1D.new()
|
||||
gt.gradient = grad
|
||||
return gt
|
||||
|
||||
func _billboard(bt: int) -> int:
|
||||
# 0 = none/local, 1 = 面向相机, 4 = Y 轴
|
||||
if bt == 4:
|
||||
# EffectLib Type.h: 0=NONE, 1=ALL, 2=Y. Particle 3=LIE,
|
||||
# 4=2FACE and 5=3FACE still use a camera-facing approximation below.
|
||||
if bt == 2:
|
||||
return BaseMaterial3D.BILLBOARD_FIXED_Y
|
||||
if bt == 0:
|
||||
return BaseMaterial3D.BILLBOARD_DISABLED
|
||||
return BaseMaterial3D.BILLBOARD_ENABLED
|
||||
|
||||
func _blend(src: int, dst: int) -> int:
|
||||
# D3D blend: 5=SRCALPHA 2=ONE 4=INVSRCALPHA 3=SRCCOLOR
|
||||
# D3DBLEND: 5=SRCALPHA, 6=INVSRCALPHA, 2=ONE, 4=INVSRCCOLOR.
|
||||
# Ordinary transparency must not fall through to additive glow.
|
||||
if src == 5 and dst == 6:
|
||||
return BaseMaterial3D.BLEND_MODE_MIX
|
||||
# Remaining pairs retain the existing approximations, not exact D3D parity.
|
||||
if dst == 2:
|
||||
return BaseMaterial3D.BLEND_MODE_ADD
|
||||
if dst == 4:
|
||||
|
||||
@@ -97,3 +97,26 @@ func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot
|
||||
if fx:
|
||||
fx.global_position = global_pos
|
||||
return fx
|
||||
|
||||
func spawn_motion(event: Dictionary, view: Node3D, world_parent: Node3D) -> Node3D:
|
||||
var effect := spawn(String(event.get("effect", "")), world_parent, true)
|
||||
if effect == null:
|
||||
return null
|
||||
var anchor := preload("res://fx/motion_effect_anchor.gd").new()
|
||||
effect.add_child(anchor)
|
||||
if not anchor.configure(view, event):
|
||||
effect.free()
|
||||
return null
|
||||
return effect
|
||||
|
||||
func spawn_target(event: Dictionary, target: Node3D, world_parent: Node3D) -> Node3D:
|
||||
if not is_instance_valid(target) or bool(event.get("fishing_effect", false)):
|
||||
return null # Fishing requires its separate water landing position.
|
||||
var effect := spawn(String(event.get("effect", "")), world_parent, true)
|
||||
if effect == null: return null
|
||||
var anchor := preload("res://fx/target_effect_anchor.gd").new()
|
||||
effect.add_child(anchor)
|
||||
if not anchor.configure(target, event.get("pos", Vector3.ZERO), bool(event.get("following", false))):
|
||||
effect.free()
|
||||
return null
|
||||
return effect
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
extends RefCounted
|
||||
# All MSE/MDE vectors are Z-up. Convert orientation before the cm->m scale.
|
||||
static func direction(source: Vector3) -> Vector3:
|
||||
return Vector3(source.x, source.z, -source.y)
|
||||
static func position(source: Vector3) -> Vector3:
|
||||
return direction(source) * 0.01
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3htid787d4uy
|
||||
+2
-2
@@ -115,10 +115,10 @@ func parse_bytes(data: PackedByteArray) -> Dictionary:
|
||||
positions.resize(vertex_count)
|
||||
for i in vertex_count:
|
||||
var base := offset + i * 12
|
||||
positions[i] = Vector3(
|
||||
positions[i] = preload("res://fx/effect_space.gd").position(Vector3(
|
||||
float(data.decode_float(base)),
|
||||
float(data.decode_float(base + 4)),
|
||||
float(data.decode_float(base + 8))) * 0.01
|
||||
float(data.decode_float(base + 8))))
|
||||
offset += vertex_count * 12
|
||||
|
||||
var indices := PackedInt32Array()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 40250 ActorInstanceMotionEvent / ActorInstanceAttach: bone * offset * actor
|
||||
# in row-vector order. The Godot equivalent is actor * offset * bone.
|
||||
extends Node
|
||||
|
||||
const SOURCE_TO_METRES := Transform3D(Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0)), Vector3.ZERO)
|
||||
var model: Node3D
|
||||
var animator: Node
|
||||
var event := {}
|
||||
|
||||
func configure(view: Node3D, data: Dictionary) -> bool:
|
||||
model = view.get("model")
|
||||
animator = view.get("anim")
|
||||
event = data.duplicate()
|
||||
# Sample immediately, then after animation (default priority 0) every frame.
|
||||
process_priority = 100
|
||||
if not update_anchor():
|
||||
return false
|
||||
var attached := bool(event.get("attaching", false))
|
||||
set_process(not bool(event.get("independent", false)) and
|
||||
(not attached or bool(event.get("following", false))))
|
||||
return true
|
||||
|
||||
func update_anchor() -> bool:
|
||||
if not is_instance_valid(model) or not model.is_inside_tree():
|
||||
return false
|
||||
var bone := Transform3D.IDENTITY
|
||||
if bool(event.get("attaching", false)) and not bool(event.get("independent", false)):
|
||||
if not is_instance_valid(animator):
|
||||
return false
|
||||
var pose: Dictionary = animator.get_effect_bone_pose(String(event.get("bone", "")))
|
||||
if pose.is_empty():
|
||||
return false # No silent substitution of the actor origin for a missing bone.
|
||||
bone = pose.transform
|
||||
var offset := Transform3D(Basis.IDENTITY, event.get("pos", Vector3.ZERO))
|
||||
get_parent().global_transform = model.global_transform * offset * bone * SOURCE_TO_METRES.affine_inverse()
|
||||
return true
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not update_anchor():
|
||||
get_parent().queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvib4rwg2v55o
|
||||
@@ -0,0 +1,139 @@
|
||||
# Particle-only legacy texture-stage operations. Static operation 4 keeps the
|
||||
# StandardMaterial path; animated textures use per-particle array sampling.
|
||||
# RGB is saturated before framebuffer blending;
|
||||
# alpha remains texture alpha * particle alpha, never multiplied by RGB gain.
|
||||
extends RefCounted
|
||||
|
||||
static var _shaders := {}
|
||||
const SOURCE := """
|
||||
shader_type spatial;
|
||||
render_mode unshaded, cull_disabled, depth_draw_never, __BLEND__;
|
||||
uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform float color_gain = 2.0;
|
||||
uniform int color_operation = 5;
|
||||
uniform int billboard_mode = 0;
|
||||
uniform bool random_rotation = false;
|
||||
uniform float rotation_speed = 0.0;
|
||||
uniform float rotation_lifetime = 1.0;
|
||||
void vertex() {
|
||||
float particle_angle = INSTANCE_CUSTOM.x;
|
||||
if (random_rotation) {
|
||||
float direction = INSTANCE_CUSTOM.z < 0.5 ? -1.0 : 1.0;
|
||||
particle_angle += direction * rotation_speed * INSTANCE_CUSTOM.y * rotation_lifetime;
|
||||
}
|
||||
if (billboard_mode != 0) {
|
||||
mat4 facing = mat4(normalize(INV_VIEW_MATRIX[0]), normalize(INV_VIEW_MATRIX[1]),
|
||||
normalize(INV_VIEW_MATRIX[2]), MODEL_MATRIX[3]);
|
||||
if (billboard_mode == 2) {
|
||||
facing = mat4(vec4(normalize(cross(vec3(0.0, 1.0, 0.0), MAIN_CAM_INV_VIEW_MATRIX[2].xyz)), 0.0),
|
||||
vec4(0.0, 1.0, 0.0, 0.0),
|
||||
vec4(normalize(cross(MAIN_CAM_INV_VIEW_MATRIX[0].xyz, vec3(0.0, 1.0, 0.0))), 0.0),
|
||||
MODEL_MATRIX[3]);
|
||||
} else if (billboard_mode == 5) {
|
||||
vec3 up = vec3(0.0, 1.0, 0.0);
|
||||
vec3 right = cross(up, MAIN_CAM_INV_VIEW_MATRIX[2].xyz);
|
||||
right = length(right) > 0.000001 ? normalize(right) : vec3(1.0, 0.0, 0.0);
|
||||
// Source Y billboard's non-zero rotation branch (including its 90 degree offset).
|
||||
float a = particle_angle;
|
||||
if (a != 0.0) {
|
||||
vec3 source_cross = -right;
|
||||
up = up * -sin(a) - source_cross * cos(a);
|
||||
right = -(source_cross * -sin(a) + vec3(0.0, 1.0, 0.0) * cos(a));
|
||||
}
|
||||
// Source Z rotation becomes world Y rotation after coordinate conversion.
|
||||
float c = cos(UV2.x), s = sin(UV2.x);
|
||||
mat3 turn = mat3(vec3(c, 0.0, -s), vec3(0.0, 1.0, 0.0), vec3(s, 0.0, c));
|
||||
right = turn * right;
|
||||
up = turn * up;
|
||||
facing = mat4(vec4(right, 0.0), vec4(up, 0.0), vec4(cross(right, up), 0.0), MODEL_MATRIX[3]);
|
||||
} else if (billboard_mode == 4) {
|
||||
// EffectLib LIE: source Up=(cos(a),-sin(a),0),
|
||||
// Cross=(sin(a),cos(a),0), converted from Z-up to Y-up.
|
||||
float a = particle_angle;
|
||||
facing = mat4(vec4(sin(a), 0.0, -cos(a), 0.0),
|
||||
vec4(cos(a), 0.0, sin(a), 0.0),
|
||||
vec4(0.0, 1.0, 0.0, 0.0), MODEL_MATRIX[3]);
|
||||
} else if (billboard_mode == 3) {
|
||||
float a = particle_angle;
|
||||
facing *= mat4(vec4(cos(a), -sin(a), 0.0, 0.0), vec4(sin(a), cos(a), 0.0, 0.0),
|
||||
vec4(0.0, 0.0, 1.0, 0.0), vec4(0.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
mat4 scale_matrix = mat4(vec4(length(MODEL_MATRIX[0].xyz), 0.0, 0.0, 0.0),
|
||||
vec4(0.0, length(MODEL_MATRIX[1].xyz), 0.0, 0.0),
|
||||
vec4(0.0, 0.0, length(MODEL_MATRIX[2].xyz), 0.0),
|
||||
vec4(0.0, 0.0, 0.0, 1.0));
|
||||
MODELVIEW_MATRIX = VIEW_MATRIX * facing * scale_matrix;
|
||||
MODELVIEW_NORMAL_MATRIX = mat3(MODELVIEW_MATRIX);
|
||||
}
|
||||
}
|
||||
void fragment() {
|
||||
vec4 texel = texture(albedo_texture, UV);
|
||||
vec3 result = texel.rgb * COLOR.rgb * color_gain;
|
||||
if (color_operation == 2) { result = COLOR.rgb; }
|
||||
else if (color_operation == 3) { result = texel.rgb; }
|
||||
else if (color_operation == 8) { result = COLOR.rgb + texel.rgb - vec3(0.5); }
|
||||
ALBEDO = clamp(result, vec3(0.0), vec3(1.0));
|
||||
ALPHA = texel.a * COLOR.a;
|
||||
}
|
||||
"""
|
||||
|
||||
const ANIMATION_UNIFORMS := """
|
||||
uniform sampler2DArray frame_textures : source_color, filter_linear, repeat_enable;
|
||||
uniform int frame_count = 1;
|
||||
uniform int animation_type = 0;
|
||||
uniform bool random_start = false;
|
||||
uniform float frame_delay = 0.05;
|
||||
uniform float particle_lifetime = 1.0;
|
||||
varying flat float texture_frame;
|
||||
float frame_random(float seed) { return fract(sin(seed * 127.1 + 311.7) * 43758.5453); }
|
||||
"""
|
||||
const ANIMATION_VERTEX := """
|
||||
float seed = INSTANCE_CUSTOM.z;
|
||||
float steps = frame_delay > 0.000001 ? max(ceil(INSTANCE_CUSTOM.y * particle_lifetime / frame_delay) - 1.0, 0.0) : 0.0;
|
||||
float count = float(frame_count);
|
||||
float direction = animation_type == 2 ? -1.0 : 1.0;
|
||||
if (animation_type == 4) { direction = seed < 0.5 ? 1.0 : -1.0; }
|
||||
float first = animation_type == 4 && direction < 0.0 ? count - 1.0 : 0.0;
|
||||
if (random_start) { first = floor(frame_random(seed + 1.0) * count); }
|
||||
texture_frame = first;
|
||||
if (animation_type != 0 && steps > 0.0) {
|
||||
texture_frame = animation_type == 3 ? floor(frame_random(seed + steps * 3.17) * count) : mod(first + direction * steps, count);
|
||||
}
|
||||
"""
|
||||
|
||||
static func build(base: StandardMaterial3D, operation: int, frames: Texture2DArray = null,
|
||||
properties: Dictionary = {}, lifetime := 1.0) -> Material:
|
||||
var lie := int(properties.get("BillboardType", -1)) == 3
|
||||
var multiface := int(properties.get("BillboardType", -1)) in [4, 5]
|
||||
var random_rotation := int(properties.get("RotationType", 0)) == 4
|
||||
if operation not in [2, 3, 5, 6, 8] and frames == null and not lie and not multiface and not random_rotation:
|
||||
return base
|
||||
var blend := "blend_add" if base.blend_mode == BaseMaterial3D.BLEND_MODE_ADD else "blend_mix"
|
||||
var key := blend + ("_animated" if frames else "")
|
||||
if not _shaders.has(key):
|
||||
var shader := Shader.new()
|
||||
var source := SOURCE.replace("__BLEND__", blend)
|
||||
if frames:
|
||||
source = source.replace("void vertex() {", ANIMATION_UNIFORMS + "\nvoid vertex() {" + ANIMATION_VERTEX)
|
||||
source = source.replace("texture(albedo_texture, UV)", "texture(frame_textures, vec3(UV, texture_frame))")
|
||||
shader.code = source
|
||||
_shaders[key] = shader
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = _shaders[key]
|
||||
material.set_shader_parameter("albedo_texture", base.albedo_texture)
|
||||
material.set_shader_parameter("color_operation", operation)
|
||||
material.set_shader_parameter("color_gain", 2.0 if operation == 5 else (4.0 if operation == 6 else 1.0))
|
||||
material.set_shader_parameter("billboard_mode", 5 if multiface else (4 if lie else int(base.billboard_mode)))
|
||||
if random_rotation and not lie and not multiface:
|
||||
material.set_shader_parameter("billboard_mode", 5 if int(properties.get("BillboardType", 1)) == 2 else 3)
|
||||
material.set_shader_parameter("random_rotation", random_rotation)
|
||||
material.set_shader_parameter("rotation_speed", deg_to_rad(float(properties.get("RotationSpeed", 0))))
|
||||
material.set_shader_parameter("rotation_lifetime", lifetime)
|
||||
if frames:
|
||||
material.set_shader_parameter("frame_textures", frames)
|
||||
material.set_shader_parameter("frame_count", frames.get_layers())
|
||||
material.set_shader_parameter("animation_type", int(properties.get("TexAniType", 0)))
|
||||
material.set_shader_parameter("random_start", bool(properties.get("TexAniRandomStartFrameFlag", 0)))
|
||||
material.set_shader_parameter("frame_delay", float(properties.get("TexAniDelay", 0.05)))
|
||||
material.set_shader_parameter("particle_lifetime", lifetime)
|
||||
return material
|
||||
@@ -0,0 +1 @@
|
||||
uid://cu4fhnm5bep65
|
||||
@@ -0,0 +1,31 @@
|
||||
# EffectLib TwoSideRenderer / ThreeSideRenderer angles, carried per face in UV2.
|
||||
# One particle simulation drives every face (no independent emitter randomness).
|
||||
extends RefCounted
|
||||
|
||||
static func build(size: Vector2, billboard: int) -> Mesh:
|
||||
if billboard not in [4, 5]:
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = size
|
||||
return quad
|
||||
var angles := [-30.0, 30.0] if billboard == 4 else [0.0, -60.0, 60.0]
|
||||
var vertices := PackedVector3Array()
|
||||
var normals := PackedVector3Array()
|
||||
var uv := PackedVector2Array()
|
||||
var uv2 := PackedVector2Array()
|
||||
var corners := [Vector2(0, 0), Vector2(0, 1), Vector2(1, 1),
|
||||
Vector2(0, 0), Vector2(1, 1), Vector2(1, 0)]
|
||||
for angle in angles:
|
||||
for corner in corners:
|
||||
vertices.append(Vector3((corner.x - 0.5) * size.x, (0.5 - corner.y) * size.y, 0))
|
||||
normals.append(Vector3(0, 0, 1))
|
||||
uv.append(corner)
|
||||
uv2.append(Vector2(deg_to_rad(angle), 0))
|
||||
var arrays := []
|
||||
arrays.resize(Mesh.ARRAY_MAX)
|
||||
arrays[Mesh.ARRAY_VERTEX] = vertices
|
||||
arrays[Mesh.ARRAY_NORMAL] = normals
|
||||
arrays[Mesh.ARRAY_TEX_UV] = uv
|
||||
arrays[Mesh.ARRAY_TEX_UV2] = uv2
|
||||
var mesh := ArrayMesh.new()
|
||||
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
return mesh
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2act25qwmh5q
|
||||
@@ -0,0 +1,43 @@
|
||||
# Target-following effects use actor-local offset. Snapshot effects use the
|
||||
# fly-target's world position and a world-aligned offset (40250 type 10).
|
||||
extends Node
|
||||
const CONVERSION := Basis(Vector3(0.01, 0, 0), Vector3(0, 0, -0.01), Vector3(0, 0.01, 0))
|
||||
var target: WeakRef
|
||||
var offset := Vector3.ZERO
|
||||
func configure(node: Node3D, source_offset: Vector3, following: bool) -> bool:
|
||||
if not is_instance_valid(node) or not node.is_inside_tree(): return false
|
||||
target = weakref(node)
|
||||
offset = CONVERSION * source_offset
|
||||
process_priority = 100
|
||||
set_process(following)
|
||||
if following:
|
||||
_process(0.0)
|
||||
else:
|
||||
get_parent().global_position = fly_target_position(node) + offset
|
||||
return true
|
||||
static func fly_target_position(node: Node3D) -> Vector3:
|
||||
# The reference model-index 0 sphere is built from bone OBBs, not the
|
||||
# union of visible attachments or the rendered vertex AABB.
|
||||
var models := node.find_children("*", "Metin2Model", true, false)
|
||||
if node.has_method("get_fly_target_bounds"): models.push_front(node)
|
||||
for model in models:
|
||||
var result: Dictionary = model.get_fly_target_bounds()
|
||||
if result.get("valid", false):
|
||||
var body_bounds: AABB = result.bounds
|
||||
return model.to_global(body_bounds.get_center())
|
||||
# The reference returns the model bounding sphere centre, not its feet.
|
||||
var meshes := node.find_children("*", "MeshInstance3D", true, false)
|
||||
var bounds := AABB()
|
||||
var found := false
|
||||
for mesh in meshes:
|
||||
if mesh.mesh == null or not mesh.is_visible_in_tree(): continue
|
||||
var box: AABB = mesh.global_transform * mesh.get_aabb()
|
||||
bounds = bounds.merge(box) if found else box
|
||||
found = true
|
||||
return bounds.get_center() if found else node.global_position
|
||||
func _process(_delta: float) -> void:
|
||||
var node: Node3D = target.get_ref() if target else null
|
||||
if not is_instance_valid(node) or not node.is_inside_tree():
|
||||
get_parent().queue_free()
|
||||
return
|
||||
get_parent().global_transform = node.global_transform * Transform3D(Basis.IDENTITY, offset)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhnn4cqiuego2
|
||||
Reference in New Issue
Block a user