extends SceneTree const EffectPlayer = preload("res://fx/effect_player.gd") var failures := 0 func check(ok: bool, label: String) -> void: if not ok: failures += 1 printerr("FAIL: " + label) func _init() -> void: var fx := EffectPlayer.new() for kind in [0, 2, 3]: fx.build({"particles": [{"particle": {"RotationType": kind, "RotationSpeed": 180, "RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 60, "BillboardType": 1}}]}) var g: GPUParticles3D = fx._emitters[-1] var pm: ParticleProcessMaterial = g.process_material var speed := 0.0 if kind == 0 else (180.0 if kind == 2 else -180.0) check(pm.angular_velocity_min == speed and pm.angular_velocity_max == speed, "constant rotation type %d uses degrees without random slowing" % kind) check(pm.angle_min == 30.0 and pm.angle_max == 60.0, "initial angle range type %d" % kind) check(g.material_override.billboard_mode == BaseMaterial3D.BILLBOARD_PARTICLES, "camera billboard consumes particle rotation type %d" % kind) check(fx._billboard(0) == BaseMaterial3D.BILLBOARD_DISABLED, "non-billboard unchanged") check(fx._billboard(1) == BaseMaterial3D.BILLBOARD_ENABLED, "mesh camera billboard unchanged") check(fx._billboard(2) == BaseMaterial3D.BILLBOARD_FIXED_Y, "source Y enum is two") check(fx._billboard(4) != BaseMaterial3D.BILLBOARD_FIXED_Y, "two-face is not misclassified as Y") fx.free() var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/7budongbakbu_throw.mse") var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) check(not spec.get("particles", []).is_empty(), "real shaman rotation resource loaded") var count := 0 for particle in spec.get("particles", []): var prop: Dictionary = particle.particle.duplicate(true) var kind := int(prop.get("RotationType", 0)) if kind != 2 and kind != 3: continue # Isolate rotation/material configuration without loading original textures. prop.erase("TextureFiles") var real_fx := EffectPlayer.new() real_fx.build({"particles": [{"particle": prop}]}) var pm: ParticleProcessMaterial = real_fx._emitters[0].process_material var expected := float(prop.get("RotationSpeed", 0)) * (1.0 if kind == 2 else -1.0) check(pm.angular_velocity_min == expected and pm.angular_velocity_max == expected, "real constant speed") check(pm.angle_min == float(prop.get("RotationRandomStartingBegin", 0)) and pm.angle_max == float(prop.get("RotationRandomStartingEnd", 0)), "real initial angle range") count += 1 real_fx.free() check(count > 0, "real constant-rotation coverage") print("real rotation tracks: particles=%d" % count) time_event_rotation() real_time_event_rotation() print("effect_rotation_test: failures=%d" % failures) quit(1 if failures else 0) func time_event_rotation() -> void: var fx := EffectPlayer.new() fx.build({"particles": [{"particle": {"RotationType": 1, "RotationSpeed": 999, "TimeEventRotation": [[0.0, 180.0], [0.5, -180.0], [1.0, 0.0]], "RotationRandomStartingBegin": 30, "RotationRandomStartingEnd": 30}}, {"particle": {"RotationType": 1, "RotationSpeed": 999}}]}) var pm: ParticleProcessMaterial = fx._emitters[0].process_material check(pm.angular_velocity_curve is CurveTexture, "TIME_EVENT has a speed curve") if pm.angular_velocity_curve is CurveTexture: var curve: Curve = pm.angular_velocity_curve.curve # Godot 4.7 curve branch integrates sampled value * base rate directly # into CUSTOM.x radians; unlike the constant branch it does not convert. check(is_equal_approx(curve.sample(0.0) * pm.angular_velocity_min, PI), "birth rate is pi radians/sec") check(is_zero_approx(curve.sample(0.25)), "speed passes through zero") check(is_equal_approx(curve.sample(0.75) * pm.angular_velocity_min, -PI / 2.0), "negative rate preserved") check(is_zero_approx(curve.sample(1.0)), "end stops without erasing earlier motion") var angle := deg_to_rad(pm.angle_min) for i in range(2000): angle += curve.sample((float(i) + 0.5) / 2000.0) * pm.angular_velocity_min * 2.0 / 2000.0 check(absf(angle - deg_to_rad(-60.0)) < 0.0001, "two-second lifetime integrates speed plus initial angle") var empty: ParticleProcessMaterial = fx._emitters[1].process_material check(empty.angular_velocity_curve is CurveTexture, "empty TIME_EVENT remains an explicit zero curve") if empty.angular_velocity_curve is CurveTexture: check(is_zero_approx(empty.angular_velocity_curve.curve.sample(0.5)), "empty track ignores RotationSpeed fallback") fx.free() func real_time_event_rotation() -> void: var count := 0 var samples := 0 for name in ["jeungsok_blow", "8chojeonbu", "jeungryeok_hand"]: var path := AssetRoot.path().path_join("PC/ymir work/pc/shaman/effect/" + name + ".mse") var spec: Dictionary = preload("res://fx/mse.gd").new().parse_file(path) check(not spec.get("particles", []).is_empty(), "real TIME_EVENT file " + name) for particle in spec.get("particles", []): if int(particle.particle.get("RotationType", 0)) != 1: continue var rows: Array = particle.particle.get("TimeEventRotation", []) var fx := EffectPlayer.new() fx.build({"particles": [{"particle": {"RotationType": 1, "TimeEventRotation": rows}}]}) var pm: ParticleProcessMaterial = fx._emitters[0].process_material check(pm.angular_velocity_curve is CurveTexture, "real TIME_EVENT material") if pm.angular_velocity_curve is CurveTexture: for row in rows: if float(row[0]) >= 0.0 and float(row[0]) <= 1.0: var actual: float = pm.angular_velocity_curve.curve.sample(float(row[0])) * pm.angular_velocity_min check(is_equal_approx(actual, deg_to_rad(float(row[1]))), "real TIME_EVENT radians " + name) samples += 1 count += 1 fx.free() check(count > 0 and samples > 0, "real time-dependent speed coverage") print("real time-event tracks: particles=%d keys=%d" % [count, samples])