- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
864 lines
34 KiB
GDScript
864 lines
34 KiB
GDScript
# 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")
|
|
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 := ""
|
|
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
|
|
var _playing := false
|
|
var _cleanup_remaining := -1.0
|
|
## MSE-defined playback length of the last play(); -1 while looping (owner-bound).
|
|
var defined_lifetime_ms := -1
|
|
## Why the node left the tree: "cleanup" when its own playback clock freed it.
|
|
var finish_reason := ""
|
|
|
|
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:
|
|
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:
|
|
finish_reason = "cleanup"
|
|
queue_free()
|
|
|
|
func play(force_one_shot := false) -> void:
|
|
# 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
|
|
defined_lifetime_ms = roundi(_longest_life() * 1000.0) if (force_one_shot or one_shot) else -1
|
|
for state in _particle_states:
|
|
state["clock"] = 0.0
|
|
state["emission_stopped"] = false
|
|
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
|
|
defined_lifetime_ms = -1
|
|
for e in _emitters:
|
|
e.emitting = false
|
|
for mesh in _mesh_nodes:
|
|
mesh.visible = 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.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)
|
|
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 = _position_at(positions, 0.0)
|
|
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 = 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))
|
|
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 := 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), 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),
|
|
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))
|
|
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
|
|
# 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)
|
|
# 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。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:
|
|
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 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 + 混合)
|
|
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 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 = ParticleColorMaterial.build(mat, int(_n(prop.get("ColorOperationType", 4))), frames, prop, g.lifetime)
|
|
_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,
|
|
"emission_started": 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", [])
|
|
# 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))
|
|
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 _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:
|
|
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 = _position_at(positions, 0.0)
|
|
|
|
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 = _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]))
|
|
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.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))))
|
|
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 := 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
|
|
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 EffectSpace.direction(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_textures(prop: Dictionary) -> Array[Texture2D]:
|
|
var rows: Variant = prop.get("TextureFiles", [])
|
|
if not rows is Array or rows.is_empty():
|
|
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", ""))
|
|
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)
|
|
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:
|
|
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
|
|
|
|
# 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 = 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 _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()
|
|
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:
|
|
# 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:
|
|
# 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:
|
|
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
|