# EffectPlayer (P5) —— 解析后的 `.mse` spec → Godot 节点树(GPUParticles3D 为主)。 # # var fx := preload("res://fx/effect_player.gd").new() # fx.build(spec, assets_root) # spec 来自 fx/mse.gd # add_child(fx) # 挂到要出特效的位置(可 reparent 到骨骼) # fx.play() # 或 fx.play(true) 一次性 # # 覆盖 Particle 组:发射形状 / 速率 / 寿命 / 方向 / 重力 / 缩放曲线 / 颜色渐变 / # billboard / 加法混合 / 旋转;粒子 TextureFiles 通过 UiAssets + C++ DDS 解码加载。 # Mesh 组:读取 ClientVS22 的 .mde v1/v2、展开 TriangleList、播放几何帧与 IFL 贴图。 # SimpleLight:按参考的范围曲线 / 生命周期创建 OmniLight3D;现有资产集仍缺少逐项视觉样本。 extends Node3D const UiAssets = preload("res://ui/ui_assets.gd") const Mde = preload("res://fx/mde.gd") var spec := {} var assets_root := "" var one_shot := false var _emitters: Array[GPUParticles3D] = [] var _particle_states: Array[Dictionary] = [] var _mesh_nodes: Array[Node3D] = [] var _mesh_states: Array[Dictionary] = [] var _lights: Array[OmniLight3D] = [] var _light_states: Array[Dictionary] = [] var _bsphere_r := 0.0 static var _glow_tex: Texture2D func build(mse_spec: Dictionary, assets := "") -> void: spec = mse_spec assets_root = assets _bsphere_r = float(spec.get("bsphere_r", 0.0)) for p in spec.get("particles", []): var e := _build_particle(p) if e: add_child(e) _emitters.append(e) for m in spec.get("meshes", []): var mn := _build_mesh(m) if mn: add_child(mn) _mesh_nodes.append(mn) for l in spec.get("lights", []): var light := _build_light(l) if light: add_child(light) _lights.append(light) func _process(delta: float) -> void: 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) 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. 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) func stop() -> void: for e in _emitters: e.emitting = false for light in _lights: light.visible = false func _longest_life() -> float: var m := 1.0 for e in _emitters: var start := float(e.get_meta("start_time", 0.0)) var cycle := float(e.get_meta("cycle_length", 0.0)) var loop := bool(e.get_meta("cycle_loop", false)) var loop_count := int(e.get_meta("loop_count", 0)) var emission_window := 0.0 if cycle > 0.0 and (not loop or loop_count > 0): emission_window = cycle * (loop_count if loop else 1) m = maxf(m, start + emission_window + e.lifetime) for state in _mesh_states: m = maxf(m, float(state.get("duration", 0.0))) for state in _light_states: m = maxf(m, float(state.get("duration", 0.0))) return m # --- particle --------------------------------------------------------- func _build_particle(p: Dictionary) -> GPUParticles3D: var emit: Dictionary = p.get("emitter", {}) var prop: Dictionary = p.get("particle", {}) var g := GPUParticles3D.new() 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) var positions: Array = p.get("position", []) 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.amount = maxi(1, int(_n(emit.get("MaxEmissionCount", 16)))) g.explosiveness = 0.0 var life := _last_val(emit.get("TimeEventLifeTime", []), 1.0) g.lifetime = clampf(life, 0.05, 12.0) # ParticleSystemData.cpp supplies 0.05 when CycleLength is absent. Keep an # explicit zero distinct: it means the source does not request a cycle gate. 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.set_meta("cycle_length", maxf(cycle, 0.0)) g.set_meta("cycle_loop", loop != 0) g.set_meta("loop_count", maxi(loop_count, 0)) g.set_meta("particle_lifetime", g.lifetime) g.set_meta("emission_stopped", false) var pm := ParticleProcessMaterial.new() # 发射形状 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 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) 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), maxf(absf(emitting_size.y) * 0.5, 0.005), maxf(absf(emitting_size.z) * 0.5, 0.005)) 3: # CEmitterProperty::EMITTER_SHAPE_SPHERE pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE if emit_from_edge else ParticleProcessMaterial.EMISSION_SHAPE_SPHERE pm.emission_sphere_radius = maxf(0.01, radius) _: pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT # 方向 / 速度 var vel := _last_val(emit.get("TimeEventEmittingVelocity", []), 0.0) * 0.01 var direction := _vec3(emit.get("EmittingDirection", [0.0, 0.0, 0.0])) direction += Vector3(_last_val(emit.get("TimeEventEmittingDirectionX", []), 0.0), _last_val(emit.get("TimeEventEmittingDirectionY", []), 0.0), _last_val(emit.get("TimeEventEmittingDirectionZ", []), 0.0)) 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 # position. ParticleProcessMaterial has no radial-velocity primitive, so keep # the source mode visible and use a broad directional fallback until the # particle-shader path is implemented. This avoids silently treating those # modes as FREE while preserving the normal velocity range and gravity. var advanced_type := int(g.get_meta("emitter_advanced_type", 0)) if advanced_type == 1 or advanced_type == 2: pm.spread = 180.0 pm.initial_velocity_min = vel * 0.6 pm.initial_velocity_max = vel # 重力 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 # 旋转:ParticleProperty 的 0=NONE、1=TIME_EVENT、2=CW、3=CCW、 # 4=RANDOM_DIRECTION。Godot 没有完全相同的每粒子时间表,先保留方向和 # 时间表末值,避免把 NONE 误当成恒速旋转。 var rotation_type := int(_n(prop.get("RotationType", 0))) var rspeed := _n(prop.get("RotationSpeed", 0.0)) if rotation_type == 1: rspeed = _last_val(prop.get("TimeEventRotation", []), rspeed) 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) g.process_material = pm # 绘制网格 + 材质(billboard + 混合) var qm := QuadMesh.new() qm.size = Vector2(1, 1) g.draw_pass_1 = qm var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.billboard_mode = _billboard(int(_n(prop.get("BillboardType", 1)))) 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() mat.vertex_color_use_as_albedo = true g.material_override = mat _particle_states.append({ "node": g, "rows": positions, "clock": 0.0, "start_time": float(p.get("start_time", 0.0)), "cycle_length": maxf(cycle, 0.0), "cycle_loop": loop != 0, "loop_count": maxi(loop_count, 0), "emission_stopped": false, }) return g func _advance_particle(state: Dictionary, delta: float) -> void: var g: GPUParticles3D = state.get("node") if g == null or not is_instance_valid(g): return var rows: Array = state.get("rows", []) if rows.is_empty(): return var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) state["clock"] = clock var start := float(state.get("start_time", 0.0)) if clock < start: g.emitting = false return var local := clock - start var cycle := float(state.get("cycle_length", 0.0)) var loop := bool(state.get("cycle_loop", false)) var loop_count := int(state.get("loop_count", 0)) var emission_finished := cycle > 0.0 and ((not loop and local >= cycle) or (loop and loop_count > 0 and local >= cycle * loop_count)) if emission_finished: g.emitting = false state["emission_stopped"] = true return if not bool(state.get("emission_stopped", false)): g.position = _position_at(rows, local) func _build_mesh(m: Dictionary) -> Node3D: var mde_path := _resolve_mde(String(m.get("mesh_file", ""))) if mde_path == "": return null var mesh_data := Mde.new().parse_file(mde_path) if mesh_data.is_empty(): return null var n := Node3D.new() 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 var geometries: Array = mesh_data.get("geometries", []) var elements: Array = m.get("elements", []) var states: Array[Dictionary] = [] for geometry_index in geometries.size(): var geometry: Dictionary = geometries[geometry_index] var frames: Array = geometry.get("frames", []) if frames.is_empty(): continue var meshes: Array = [] for frame in frames: var array_mesh := _array_mesh(frame) meshes.append(array_mesh) var first_mesh: ArrayMesh = null for candidate in meshes: if candidate is ArrayMesh: first_mesh = candidate break if first_mesh == null: continue var mi := MeshInstance3D.new() mi.name = String(geometry.get("name", "geometry_%d" % geometry_index)) mi.mesh = first_mesh var element: Dictionary = elements[geometry_index] if geometry_index < elements.size() else {} var textures := _load_mesh_textures(mde_path, String(geometry.get("diffuse", ""))) mi.material_override = _mesh_material(element, textures) n.add_child(mi) states.append({ "node": mi, "frames": frames, "meshes": meshes, "element": element, "textures": textures, "frame": -1, "texture_frame": -1, }) var frame_delay := maxf(float(m.get("frame_delay", 0.02)), 0.0001) var frame_count := int(mesh_data.get("frame_count", 0)) var loop_count := int(m.get("loop_count", 0)) var loops := loop_count if loop_count > 0 else 1 var duration := float(m.get("start_time", 0.0)) + frame_count * frame_delay * loops var state := { "node": n, "children": states, "position": positions, "clock": 0.0, "start_time": float(m.get("start_time", 0.0)), "frame_delay": frame_delay, "loop": int(m.get("loop", 0)) != 0, "loop_count": loop_count, "duration": maxf(duration, frame_delay), } _mesh_states.append(state) n.set_meta("mde_path", mde_path) n.set_meta("mde_version", int(mesh_data.get("version", 0))) n.set_meta("mesh_states", states) n.visible = false return n func _build_light(l: Dictionary) -> OmniLight3D: var light := OmniLight3D.new() 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 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])) var attenuation := float(l.get("attenuation1", 0.1)) + float(l.get("attenuation2", 0.0)) light.omni_attenuation = clampf(1.0 / maxf(1.0, 1.0 + attenuation * 10.0), 0.01, 1.0) light.visible = false var duration := maxf(float(l.get("duration", 1.0)), 0.0001) var loop_count := int(l.get("loop_count", 0)) var loops := loop_count if loop_count > 0 else 1 var state := { "node": light, "data": l, "clock": 0.0, "start_time": float(l.get("start_time", 0.0)), "duration": float(l.get("start_time", 0.0)) + duration * loops, "loop": int(l.get("loop", 0)) != 0, "loop_count": loop_count, } _light_states.append(state) light.set_meta("simple_light", true) light.set_meta("ambient", l.get("ambient", [])) light.set_meta("max_range_cm", float(l.get("max_range", 300.0))) return light func _advance_light(state: Dictionary, delta: float) -> void: var light: OmniLight3D = state.get("node") if light == null or not is_instance_valid(light): return var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) state["clock"] = clock var start := float(state.get("start_time", 0.0)) if clock < start: light.visible = false return var data: Dictionary = state.get("data", {}) var duration := maxf(float(data.get("duration", 1.0)), 0.0001) var local := clock - start var loop := bool(state.get("loop", false)) var loop_count := int(state.get("loop_count", 0)) if loop: if loop_count > 0 and local >= duration * loop_count: light.visible = false return local = fmod(local, duration) elif local >= duration: light.visible = false return light.visible = true light.position = _position_at(data.get("position", []), local) var range_ratio := clampf(_event_value(data.get("range", []), local, 1.0), 0.0, 1.0) light.omni_range = maxf(0.01, float(data.get("max_range", 300.0)) * range_ratio * 0.01) var diffuse: Array = data.get("diffuse", [0.0, 0.0, 0.0, 1.0]) if diffuse.size() >= 4: var color := light.light_color color.a = float(diffuse[3]) light.light_color = color func _advance_mesh(state: Dictionary, delta: float) -> void: var n: Node3D = state.get("node") if n == null or not is_instance_valid(n): return var clock := float(state.get("clock", 0.0)) + maxf(delta, 0.0) state["clock"] = clock var start := float(state.get("start_time", 0.0)) if clock < start: n.visible = false return var local := clock - start n.position = _position_at(state.get("position", []), local) var children: Array = state.get("children", []) if children.is_empty(): return var frame_delay := maxf(float(state.get("frame_delay", 0.02)), 0.0001) var frame_count := 0 for child in children: frame_count = maxi(frame_count, Array(child.get("frames", [])).size()) if frame_count < 1: return var loop := bool(state.get("loop", false)) var loop_count := int(state.get("loop_count", 0)) var frame_number := int(floor(local / frame_delay)) if loop: var total_frames := frame_count * (loop_count if loop_count > 0 else 1) if loop_count > 0 and frame_number >= total_frames: n.visible = false return frame_number = frame_number % frame_count else: if frame_number >= frame_count: n.visible = false return n.visible = true for child in children: _set_mesh_child_frame(child, frame_number, local) func _set_mesh_child_frame(state: Dictionary, frame_number: int, local: float) -> void: var frames: Array = state.get("frames", []) var meshes: Array = state.get("meshes", []) var mi: MeshInstance3D = state.get("node") if mi == null or frame_number < 0 or frame_number >= frames.size() or frame_number >= meshes.size(): return if int(state.get("frame", -1)) != frame_number: if meshes[frame_number] is ArrayMesh: mi.mesh = meshes[frame_number] state["frame"] = frame_number var frame: Dictionary = frames[frame_number] var visibility := clampf(float(frame.get("visibility", 1.0)), 0.0, 1.0) var element: Dictionary = state.get("element", {}) var alpha := _event_value(element.get("TimeEventAlpha", []), local, 1.0) var mat := mi.material_override as StandardMaterial3D if mat: var color := mat.albedo_color color.a = clampf(float(element.get("_base_alpha", color.a)) * visibility * alpha, 0.0, 1.0) mat.albedo_color = color var textures: Array = state.get("textures", []) if textures.size() > 0 and mat: var texture_delay := maxf(float(element.get("TextureAnimationFrameDelay", 0.02)), 0.0001) var texture_frame := int(element.get("TextureAnimationStartFrame", 0)) + int(floor(local / texture_delay)) if int(element.get("TextureAnimationLoopEnable", 1)) != 0: texture_frame = texture_frame % textures.size() else: texture_frame = mini(texture_frame, textures.size() - 1) if int(state.get("texture_frame", -1)) != texture_frame: mat.albedo_texture = textures[texture_frame] state["texture_frame"] = texture_frame func _array_mesh(frame: Dictionary) -> ArrayMesh: var vertices: PackedVector3Array = frame.get("vertices", PackedVector3Array()) var uvs: PackedVector2Array = frame.get("uvs", PackedVector2Array()) var count := mini(vertices.size(), uvs.size()) count -= count % 3 if count < 3: return null if count != vertices.size(): vertices = vertices.slice(0, count) uvs = uvs.slice(0, count) var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = vertices arrays[Mesh.ARRAY_TEX_UV] = uvs var mesh := ArrayMesh.new() mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) return mesh func _mesh_material(element: Dictionary, textures: Array) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED 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)))) var cf = element.get("ColorFactor", [1.0, 1.0, 1.0, 1.0]) var color := Color(1, 1, 1, 1) if cf is Array and cf.size() >= 4: color = Color(float(cf[0]), float(cf[1]), float(cf[2]), float(cf[3])) element["_base_alpha"] = color.a mat.albedo_color = color mat.albedo_texture = textures[0] if textures.size() > 0 else _glow() return mat func _load_mesh_textures(mde_path: String, diffuse: String) -> Array: var out: Array = [] if diffuse == "": return out var mde_dir := mde_path.get_base_dir() var diffuse_name := diffuse.replace("\\", "/").get_file() var diffuse_path := mde_dir.path_join(diffuse_name) if not FileAccess.file_exists(diffuse_path): diffuse_path = _resolve_asset_file(diffuse) if diffuse_path != "" and diffuse_path.get_extension().to_lower() == "ifl": for line in FileAccess.get_file_as_string(diffuse_path).split("\n"): var name := line.strip_edges().trim_prefix("\"").trim_suffix("\"") if name == "" or name.begins_with("#"): continue var tex := UiAssets.load_tex(diffuse_path.get_base_dir(), name) if tex: out.append(tex) else: var tex := UiAssets.load_tex(mde_dir, diffuse_name) if tex == null: tex = UiAssets.load_tex(assets_root, diffuse) if tex: out.append(tex) return out func _resolve_mde(file_name: String) -> String: if file_name == "": return "" var rel := file_name.replace("\\", "/") var mse_dir := String(spec.get("dir", "")) var sibling := mse_dir.path_join(rel.get_file()) if mse_dir != "" else "" if sibling != "" and FileAccess.file_exists(sibling): return sibling var direct := assets_root.path_join(rel.lstrip("/")) if assets_root != "" else "" if direct != "" and FileAccess.file_exists(direct): return direct return _scan_for_mde(assets_root, rel.get_file(), 8) if assets_root != "" else "" func _resolve_asset_file(file_name: String) -> String: var base := file_name.replace("\\", "/").get_file() var mse_dir := String(spec.get("dir", "")) var sibling := mse_dir.path_join(base) if mse_dir != "" else "" if sibling != "" and FileAccess.file_exists(sibling): return sibling return _scan_for_mde(assets_root, base, 8) if assets_root != "" else "" func _scan_for_mde(dir: String, basename: String, depth: int) -> String: if dir == "" or depth < 0: return "" var da := DirAccess.open(dir) if da == null: return "" for file in da.get_files(): if file.to_lower() == basename.to_lower(): return dir.path_join(file) for sub in da.get_directories(): if sub.begins_with("."): continue var result := _scan_for_mde(dir.path_join(sub), basename, depth - 1) if result != "": return result return "" func _position_at(rows, time: float) -> Vector3: # CEffectElementBase::GetPosition: each row's moving type describes the # segment starting at that row. Bezier uses P0 + controlPoint as its middle # control vertex, then keeps the result in the effect's centimetre space. if not rows is Array or rows.is_empty(): return Vector3.ZERO var first = rows[0] if not first is Array or first.size() < 5: return Vector3.ZERO if time <= float(first[0]): return _position_vector(first) * 0.01 for i in range(1, rows.size()): var current = rows[i] if not current is Array or current.size() < 5: continue var previous = rows[i - 1] if not previous is Array or previous.size() < 5: return _position_vector(current) * 0.01 var t0 := float(previous[0]) var t1 := float(current[0]) if time > t1: continue var t := 1.0 if is_zero_approx(t1 - t0) else clampf((time - t0) / (t1 - t0), 0.0, 1.0) 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 middle := p0 + control var inv := 1.0 - t return (p0 * inv * inv + middle * 2.0 * inv * t + p1 * t * t) * 0.01 return p0.lerp(p1, t) * 0.01 var last = rows[-1] return _position_vector(last) * 0.01 if last is Array and last.size() >= 5 else Vector3.ZERO 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 Vector3.ZERO func _event_value(rows, time: float, fallback: float) -> float: if not rows is Array or rows.is_empty(): return fallback var previous = rows[0] if previous is Array and previous.size() >= 2 and time <= float(previous[0]): return float(previous[1]) for row in rows: if not row is Array or row.size() < 2: continue var x := float(row[0]) if time <= x: var x0 := float(previous[0]) var y0 := float(previous[1]) var y1 := float(row[1]) var t := 1.0 if is_zero_approx(x - x0) else clampf((time - x0) / (x - x0), 0.0, 1.0) return lerpf(y0, y1, t) previous = row return float(previous[1]) if previous is Array and previous.size() >= 2 else fallback # --- helpers --------------------------------------------------------- func _n(v) -> float: if v is Array: return float(v[0]) if v.size() > 0 else 0.0 if v is float or v is int: return float(v) return 0.0 func _vec3(v) -> Vector3: if v is Array and v.size() >= 3: return Vector3(float(v[0]), float(v[1]), float(v[2])) return Vector3.ZERO func _particle_texture(prop: Dictionary) -> Texture2D: var rows: Variant = prop.get("TextureFiles", []) if not rows is Array or rows.is_empty(): return null # 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", "")) var textures: Array[Texture2D] = [] for row in rows: var file := String(row[0]) if row is Array and not row.is_empty() else String(row) if file == "": continue var tex: Texture2D = UiAssets.load_tex(mse_dir, file) if mse_dir != "" else null if tex == null: 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 # List 表末行的值(列 idx,默认 col 1 = 时间后第一个数) func _last_val(rows, def: float, col := 1) -> float: if rows is Array and rows.size() > 0: var r = rows[-1] if r is Array and r.size() > col and (r[col] is float or r[col] is int): 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: 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 func _alpha_ramp(rows, base: Color) -> GradientTexture1D: if not (rows is Array) or rows.size() < 1: return null 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))) var gt := GradientTexture1D.new() gt.gradient = grad return gt func _billboard(bt: int) -> int: # 0 = none/local, 1 = 面向相机, 4 = Y 轴 if bt == 4: 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 if dst == 2: return BaseMaterial3D.BLEND_MODE_ADD if dst == 4: return BaseMaterial3D.BLEND_MODE_MIX return BaseMaterial3D.BLEND_MODE_ADD # 程序化径向渐变(代替 .dds 粒子纹理) static func _glow() -> Texture2D: if _glow_tex == null: var s := 48 var img := Image.create(s, s, false, Image.FORMAT_RGBA8) for y in s: for x in s: var d := Vector2(x - s / 2.0, y - s / 2.0).length() / (s / 2.0) var a := clampf(1.0 - d, 0.0, 1.0) a = a * a img.set_pixel(x, y, Color(1, 1, 1, a)) _glow_tex = ImageTexture.create_from_image(img) return _glow_tex