Files

1118 lines
72 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// FX Lab: an easy particle effect maker (inspired by patch-gg/pixelfx: presets, palette ramps,
// live preview, sprite-sheet export). A preset is a list of layers with friendly settings; every
// layer becomes one Lost Saga `particle` block. The generated script is the preview's input
// (Effects simulator, the same one as the FX and Map tabs), so what plays is what exports.
// Modes: free (own view), on the hero (borrowed Hero viewport, linked to a bone), hit target.
(() => {
const FXL = { src: null, preset: null, sel: 0, playing: true, mode: "free", user: [], text: "" };
window.FXL = FXL;
// ---------- helpers ----------
const clone = (o) => JSON.parse(JSON.stringify(o));
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const f2 = (n) => (Number(n) || 0).toFixed(2);
const v3 = (a) => a.map(f2).join(" ");
const hexRgb = (h) => { const n = parseInt(String(h).replace("#", ""), 16) || 0; return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const safeName = (s) => String(s || "effect").trim().replace(/[^\w\-]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase() || "effect";
const b64 = (bytes) => { let s = ""; for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192)); return btoa(s); };
const latin1 = (text) => Uint8Array.from(text, (c) => c.charCodeAt(0) & 255);
function seeded(seed) { let s = seed >>> 0 || 1; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); }
// ---------- procedural sprites (white RGB, shape in alpha: works for add and src_alpha) ----------
const SPRITES = {
soft: "Soft glow", dot: "Dot", ring: "Ring", spark: "Spark", star: "Star", flare: "Flare",
smoke: "Smoke puff", slash: "Slash arc", square: "Square", plus: "Plus",
};
const spriteCache = new Map(); // shape → canvas 128²
function sprite(shape) {
if (spriteCache.has(shape)) return spriteCache.get(shape);
const N = 128, c = document.createElement("canvas");
c.width = c.height = N;
const ctx = c.getContext("2d"), img = ctx.createImageData(N, N), px = img.data;
const rnd = seeded(7);
const blobs = Array.from({ length: 14 }, () => [rnd() * 0.9 - 0.45, rnd() * 0.9 - 0.45, 0.18 + rnd() * 0.22]);
for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
const u = (x + 0.5) / N * 2 - 1, v = (y + 0.5) / N * 2 - 1, r = Math.hypot(u, v);
let a = 0;
switch (shape) {
case "dot": a = clamp((0.85 - r) / 0.12, 0, 1); break;
case "ring": a = Math.exp(-(((r - 0.72) / 0.1) ** 2)); break;
case "spark": a = Math.exp(-((u / 0.95) ** 2) * 2.2 - ((v / 0.09) ** 2)); break;
case "star": {
const arm = Math.exp(-Math.abs(u) * 30) + Math.exp(-Math.abs(v) * 30);
a = clamp(arm * Math.max(0, 1 - r) * 1.4 + Math.exp(-r * r * 30), 0, 1); break;
}
case "flare": a = clamp(Math.exp(-r * r * 6) * 0.9 + (Math.exp(-Math.abs(v) * 40) + Math.exp(-Math.abs(u) * 40)) * Math.max(0, 1 - r) * 0.6, 0, 1); break;
case "smoke": {
let d = 0;
for (const [bx, by, br] of blobs) d += Math.exp(-((u - bx) ** 2 + (v - by) ** 2) / (br * br));
a = clamp(d * 0.55, 0, 1) * clamp((1 - r) * 2.2, 0, 1); break;
}
case "slash": {
const ang = Math.atan2(v, u), band = Math.exp(-(((r - 0.7) / (0.05 + 0.12 * clamp((ang + Math.PI) / Math.PI - 0.5, 0, 1))) ** 2));
a = ang > -Math.PI * 0.95 && ang < 0.1 ? band * clamp((ang + Math.PI * 0.95) / 1.2, 0, 1) : 0; break;
}
case "square": a = Math.max(Math.abs(u), Math.abs(v)) < 0.8 ? 1 : clamp((0.88 - Math.max(Math.abs(u), Math.abs(v))) / 0.08, 0, 1); break;
case "plus": a = (Math.abs(u) < 0.22 && Math.abs(v) < 0.8) || (Math.abs(v) < 0.22 && Math.abs(u) < 0.8) ? 1 : 0; break;
default: a = clamp(1 - r, 0, 1) ** 2; // soft
}
const o = (y * N + x) * 4;
px[o] = px[o + 1] = px[o + 2] = 255;
px[o + 3] = Math.round(clamp(a, 0, 1) * 255);
}
ctx.putImageData(img, 0, 0);
spriteCache.set(shape, c);
return c;
}
// Imported PNG → canvas (power of two, ≤ 256) kept by data URL.
const imageCache = new Map();
function imageCanvas(dataUrl) {
if (imageCache.has(dataUrl)) return imageCache.get(dataUrl);
imageCache.set(dataUrl, null);
const img = new Image();
img.onload = () => {
const p2 = (n) => 2 ** Math.round(Math.log2(clamp(n, 8, 256)));
const c = document.createElement("canvas");
c.width = p2(img.width); c.height = p2(img.height);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
imageCache.set(dataUrl, c);
rebuild();
if (FXL.preset) renderLayers();
};
img.src = dataUrl;
return null;
}
// ---------- model ----------
function layer(over = {}) {
return Object.assign({
name: "Layer", on: true,
tex: { kind: "proc", shape: "soft" }, blend: "add", billboard: "point", depth: true, mod2x: false,
quota: 100, rate: 30, burst: 0, duration: 0, delay: 0,
shape: "Point", area: [40, 40, 40], inner: [0.5, 0.5, 0.5], pos: [0, 60, 0], dir: [0, 1, 0], spread: 20,
speed: [40, 80], life: [800, 1200], gravity: [0, 0, 0], attract: 0, spin: [0, 0], rot: [0, 360],
size: [40, 60], aspect: 1, sizeCurve: [[0, 1], [1, 1]],
colors: [[0, "#ffffff"], [1, "#ffffff"]], alpha: [[0, 0], [0.15, 1], [1, 0]],
bone: "none", offset: [0, 0, 0], follow: "none", commonDir: [0, 0, 1], fixedDir: true,
}, over);
}
const P = (name, cat, layers, extra = {}) => ({ name, cat, builtin: true, layers: layers.map((l) => layer(l)), ...extra });
const BUILTIN = [
P("Fire", "Elements", [
{ name: "Flame", tex: { kind: "proc", shape: "soft" }, rate: 45, shape: "Cylinder", area: [30, 6, 30], pos: [0, 10, 0], spread: 12, speed: [50, 90], life: [600, 900], gravity: [0, 80, 0], size: [45, 65], sizeCurve: [[0, 0.6], [0.25, 1], [1, 0.25]], colors: [[0, "#fff2a8"], [0.35, "#ff9a1f"], [0.75, "#d42a06"], [1, "#3a0a02"]], alpha: [[0, 0], [0.1, 1], [0.7, 0.7], [1, 0]], spin: [-60, 60] },
{ name: "Embers", tex: { kind: "proc", shape: "dot" }, rate: 12, shape: "Cylinder", area: [40, 10, 40], pos: [0, 20, 0], spread: 25, speed: [60, 140], life: [900, 1500], gravity: [0, 40, 0], attract: 15, size: [5, 9], colors: [[0, "#ffd36b"], [1, "#ff4a10"]], alpha: [[0, 1], [0.8, 1], [1, 0]] },
]),
P("Smoke", "Elements", [
{ name: "Smoke", tex: { kind: "proc", shape: "smoke" }, blend: "src_alpha", rate: 10, shape: "Cylinder", area: [30, 5, 30], pos: [0, 20, 0], spread: 15, speed: [25, 45], life: [1800, 2600], gravity: [6, 10, 0], size: [50, 70], sizeCurve: [[0, 0.6], [1, 2.4]], colors: [[0, "#8a8a8a"], [1, "#4a4a4a"]], alpha: [[0, 0], [0.2, 0.55], [1, 0]], spin: [-25, 25] },
]),
P("Explosion", "Combat", [
{ name: "Flash", tex: { kind: "proc", shape: "flare" }, rate: 0, burst: 1, duration: 100, quota: 2, speed: [0, 0], life: [350, 350], size: [160, 160], sizeCurve: [[0, 0.4], [0.3, 1.6], [1, 2.2]], colors: [[0, "#fffbe0"], [1, "#ff8a20"]], alpha: [[0, 1], [1, 0]], depth: false },
{ name: "Fireball", tex: { kind: "proc", shape: "soft" }, rate: 0, burst: 24, duration: 100, shape: "Ellipsoid", area: [40, 40, 40], spread: 180, speed: [60, 160], life: [500, 800], gravity: [0, 60, 0], size: [60, 90], sizeCurve: [[0, 0.5], [0.3, 1.2], [1, 1.6]], colors: [[0, "#fff1b0"], [0.3, "#ff8c1a"], [0.7, "#a3260a"], [1, "#2a0c06"]], alpha: [[0, 1], [0.6, 0.8], [1, 0]], spin: [-90, 90] },
{ name: "Sparks", tex: { kind: "proc", shape: "spark" }, billboard: "direction", rate: 0, burst: 40, duration: 100, spread: 180, speed: [250, 480], life: [350, 650], gravity: [0, -420, 0], size: [26, 40], aspect: 0.35, colors: [[0, "#fff7c2"], [1, "#ff7a14"]], alpha: [[0, 1], [0.7, 1], [1, 0]] },
{ name: "Smoke", tex: { kind: "proc", shape: "smoke" }, blend: "src_alpha", rate: 0, burst: 10, duration: 100, delay: 0, shape: "Ellipsoid", area: [60, 30, 60], spread: 180, speed: [20, 60], life: [1200, 1800], gravity: [0, 25, 0], size: [70, 100], sizeCurve: [[0, 0.6], [1, 1.8]], colors: [[0, "#5a4a40"], [1, "#2a2624"]], alpha: [[0, 0], [0.2, 0.6], [1, 0]] },
]),
P("Hit spark", "Combat", [
{ name: "Flash", tex: { kind: "proc", shape: "star" }, rate: 0, burst: 1, duration: 100, quota: 2, pos: [0, 0, 0], speed: [0, 0], life: [220, 220], size: [110, 110], sizeCurve: [[0, 0.5], [0.4, 1.3], [1, 0.2]], colors: [[0, "#ffffff"], [1, "#9fe3ff"]], alpha: [[0, 1], [1, 0]], spin: [-200, 200], depth: false },
{ name: "Sparks", tex: { kind: "proc", shape: "spark" }, billboard: "direction", rate: 0, burst: 16, duration: 100, pos: [0, 0, 0], spread: 180, speed: [220, 420], life: [180, 320], gravity: [0, -200, 0], size: [30, 46], aspect: 0.3, colors: [[0, "#ffffff"], [1, "#59c8ff"]], alpha: [[0, 1], [1, 0]], depth: false },
], { mode: "hit" }),
P("Sword glow trail", "Weapon", [
{ name: "Trail", tex: { kind: "proc", shape: "soft" }, rate: 70, quota: 120, pos: [0, 0, 0], speed: [0, 4], life: [220, 320], size: [26, 34], sizeCurve: [[0, 1], [1, 0.1]], colors: [[0, "#b7f0ff"], [1, "#3a6bff"]], alpha: [[0, 0.9], [1, 0]], bone: "Bip01 R Hand", offset: [0, 0, 0] },
{ name: "Glints", tex: { kind: "proc", shape: "star" }, rate: 8, pos: [0, 0, 0], speed: [5, 20], spread: 180, life: [250, 400], size: [14, 22], sizeCurve: [[0, 0.2], [0.4, 1], [1, 0]], colors: [[0, "#ffffff"], [1, "#9fd8ff"]], alpha: [[0, 1], [1, 0]], spin: [-180, 180], bone: "Bip01 R Hand" },
], { mode: "bone" }),
P("Fire fist", "Weapon", [
{ name: "Flame", tex: { kind: "proc", shape: "soft" }, rate: 50, pos: [0, 0, 0], shape: "Ellipsoid", area: [14, 14, 14], spread: 30, speed: [20, 50], life: [300, 500], gravity: [0, 140, 0], size: [24, 36], sizeCurve: [[0, 1], [1, 0.2]], colors: [[0, "#fff0a0"], [0.4, "#ff8a1c"], [1, "#b01e04"]], alpha: [[0, 0], [0.1, 1], [1, 0]], spin: [-90, 90], bone: "Bip01 R Hand" },
], { mode: "bone" }),
P("Power aura", "Buff", [
{ name: "Aura", tex: { kind: "proc", shape: "soft" }, rate: 35, shape: "Cylinder", area: [60, 110, 60], pos: [0, 0, 0], speed: [30, 60], spread: 5, life: [700, 1000], gravity: [0, 40, 0], size: [30, 45], sizeCurve: [[0, 0.4], [0.4, 1], [1, 0.2]], colors: [[0, "#ffe27a"], [1, "#ff8a00"]], alpha: [[0, 0], [0.3, 0.7], [1, 0]], bone: "Bip01", offset: [0, 0, 0], follow: "pos_only" },
{ name: "Ground ring", tex: { kind: "proc", shape: "ring" }, billboard: "common", commonDir: [0, 0, 1], fixedDir: true, rate: 2, quota: 4, pos: [0, 2, 0], speed: [0, 0], life: [900, 900], size: [140, 140], sizeCurve: [[0, 0.3], [1, 1.3]], colors: [[0, "#fff1a0"], [1, "#ff9a10"]], alpha: [[0, 0], [0.2, 0.9], [1, 0]] },
], { mode: "bone" }),
P("Heal", "Buff", [
{ name: "Crosses", tex: { kind: "proc", shape: "plus" }, rot: [0, 0], rate: 10, shape: "Cylinder", area: [80, 100, 80], pos: [0, 60, 0], speed: [30, 50], spread: 0, life: [900, 1300], size: [14, 20], sizeCurve: [[0, 0.3], [0.2, 1], [1, 0.8]], colors: [[0, "#c8ffb8"], [1, "#3ee06a"]], alpha: [[0, 0], [0.2, 1], [1, 0]] },
{ name: "Glow", tex: { kind: "proc", shape: "soft" }, rate: 25, shape: "Cylinder", area: [70, 40, 70], pos: [0, 10, 0], speed: [40, 70], spread: 0, life: [800, 1100], size: [26, 38], sizeCurve: [[0, 1], [1, 0.2]], colors: [[0, "#9fff9a"], [1, "#16b85a"]], alpha: [[0, 0], [0.2, 0.6], [1, 0]] },
{ name: "Ground ring", tex: { kind: "proc", shape: "ring" }, billboard: "common", commonDir: [0, 0, 1], fixedDir: true, rate: 1.5, quota: 3, pos: [0, 2, 0], speed: [0, 0], life: [1200, 1200], size: [150, 150], sizeCurve: [[0, 0.4], [1, 1.2]], colors: [[0, "#c8ffb8"], [1, "#3ee06a"]], alpha: [[0, 0], [0.3, 0.8], [1, 0]] },
], { mode: "bone" }),
P("Magic sparkle", "Magic", [
{ name: "Sparkles", tex: { kind: "proc", shape: "star" }, rate: 30, shape: "Ellipsoid", area: [120, 120, 120], pos: [0, 80, 0], spread: 180, speed: [5, 20], life: [500, 900], size: [12, 26], sizeCurve: [[0, 0], [0.4, 1], [1, 0]], colors: [[0, "#9ff6ff"], [1, "#b46bff"]], alpha: [[0, 1], [1, 1]], spin: [-120, 120] },
]),
P("Vortex", "Magic", [
{ name: "Swirl", tex: { kind: "proc", shape: "dot" }, rate: 60, quota: 200, shape: "HollowEllipsoid", area: [220, 40, 220], inner: [0.9, 0.9, 0.9], pos: [0, 60, 0], spread: 0, dir: [0, 1, 0], speed: [10, 20], attract: -110, life: [1000, 1400], size: [8, 14], colors: [[0, "#c18bff"], [1, "#4a2bff"]], alpha: [[0, 0], [0.3, 1], [1, 0]] },
{ name: "Core", tex: { kind: "proc", shape: "flare" }, rate: 3, quota: 6, pos: [0, 60, 0], speed: [0, 0], life: [800, 800], size: [90, 110], sizeCurve: [[0, 0.6], [0.5, 1], [1, 0.6]], colors: [[0, "#e7c8ff"], [1, "#6b3bff"]], alpha: [[0, 0], [0.5, 0.8], [1, 0]], spin: [-60, 60] },
]),
P("Snow", "Weather", [
{ name: "Flakes", tex: { kind: "proc", shape: "dot" }, blend: "src_alpha", rate: 40, quota: 400, shape: "Box", area: [500, 10, 500], pos: [0, 320, 0], dir: [0, -1, 0], spread: 15, speed: [40, 70], life: [5000, 6000], gravity: [10, 0, 0], size: [5, 10], colors: [[0, "#ffffff"], [1, "#dfefff"]], alpha: [[0, 0], [0.1, 0.9], [0.9, 0.9], [1, 0]] },
]),
P("Slash", "Weapon", [
{ name: "Arc", tex: { kind: "proc", shape: "slash" }, rate: 0, burst: 1, duration: 100, quota: 2, pos: [0, 100, 40], speed: [0, 0], life: [260, 260], size: [180, 180], sizeCurve: [[0, 0.7], [1, 1.15]], rot: [0, 0], spin: [500, 500], colors: [[0, "#ffffff"], [1, "#7fd0ff"]], alpha: [[0, 1], [1, 0]], depth: false },
]),
];
// ---------- preset → Lost Saga effect script ----------
/** Texture file name the layer writes and its source → { file, src } (file = basename). */
function texOf(L) {
const t = L.tex || {};
if (t.kind === "pack") return { file: t.name.split(/[\\/]/).pop(), path: t.name, pack: true };
if (t.kind === "image") return { file: `fxlab_${safeName(t.label || "image")}.dds`, path: `EffectTexture/fxlab_${safeName(t.label || "image")}.dds`, image: t.data };
const shape = SPRITES[t.shape] ? t.shape : "soft";
return { file: `fxlab_${shape}.dds`, path: `EffectTexture/fxlab_${shape}.dds`, shape };
}
const SHAPES = ["Point", "Box", "Cylinder", "Ellipse", "Ellipsoid", "HollowEllipsoid"];
// Which layer fields each script key / affector comes from. A layer read from a script keeps
// the original text of every key whose fields were not edited (exact round trip).
const KEY_FIELDS = {
quota: ["quota"], follow_parent: ["follow"], default_width: ["size"], default_height: ["size", "aspect"], render_type: ["blend"],
depth_check: ["depth"], texture: ["tex"], billboard_type: ["billboard"], end_condition: ["duration"], limit_time: ["duration", "delay"],
common_dir: ["commonDir"], common_dir_none_billboard: ["fixedDir"], start_create_particles: ["burst"], link_biped_name: ["bone"],
pos_offset: ["offset"], modulate2x: ["mod2x"],
emit_pos: ["pos"], emit_dir: ["dir"], emit_angle: ["spread"], emit_rate: ["rate"], emit_start_tex_rotation: ["rot", "rotStart"],
emit_speed: ["speed"], emit_live_time: ["life"], emit_wait_time: ["delay"], color_rate: ["colors"], alpha_rate: ["alpha"],
area_size: ["area", "shape"], inner_size: ["inner", "shape"],
};
const AFF_FIELDS = { scale: ["sizeCurve", "scaleY"], texrotation: ["rot", "spin"], linear: ["gravity", "forceMethod", "gravityRate"], emit: ["attract", "attractRate", "attractChangeDir"] };
const STATIC_KEYS = ["cull_each", "tex_filtering", "rot_offset", "random_sync_index"];
const ALL_FIELDS = [...new Set([...Object.values(KEY_FIELDS).flat(), ...Object.values(AFF_FIELDS).flat(), "shape"])];
const snapshot = (L) => Object.fromEntries(ALL_FIELDS.map((f) => [f, JSON.stringify(L[f] ?? null)]));
const unchanged = (L, fields) => !!L.orig && fields.every((f) => JSON.stringify(L[f] ?? null) === L.orig.fields[f]);
function affectorLines(type, L) {
const rad = (d) => (d * Math.PI) / 180;
if (type === "scale") {
const sc = L.sizeCurve.slice().sort((a, b) => a[0] - b[0]);
return ["affector Scale", "{", "\twait_time\t0.00", `\tscale_rate\t${sc.flatMap(([t, s]) => [f2(t), f2(s), f2(s * (L.scaleY || 1))]).join(" ")}`, "}"];
}
if (type === "texrotation") return ["affector TexRotation", "{", "\twait_time\t0.00", `\trot_range\t${f2(L.rot[0])} ${f2(L.rot[1])}`, `\trot_speed\t${f2(rad(L.spin[0]))} ${f2(rad(L.spin[1]))}`, "}"];
if (type === "linear") return ["affector Linear", "{", "\twait_time\t0.00", `\tforce_vector\t${v3(L.gravity)}`, `\tforce_method\t${L.forceMethod || "add"}`, `\tforce_rate\t${L.gravityRate || "0.00 1.00"}`, "}"];
return ["affector Emit", "{", "\twait_time\t0.00", `\tpower\t${f2(L.attract)}`, `\tforce_rate\t${L.attractRate || "0.00 1.00"}`, `\tchange_direction\t${L.attractChangeDir ? "true" : "false"}`, "}"];
}
const affectorNeeded = (type, L) => (type === "scale" ? L.sizeCurve.some(([, s]) => Math.abs(s - 1) > 1e-3) || (L.scaleY && L.scaleY !== 1)
: type === "texrotation" ? !!(L.spin[0] || L.spin[1]) : type === "linear" ? L.gravity.some((g) => g) : !!L.attract);
function toScript(preset) {
const out = [], ln = (s = "", d = 0) => out.push("\t".repeat(d) + s);
const raw = (lines, d) => {
const tabs = (l) => l.match(/^\t*/)[0].length;
const base = Math.min(...lines.filter((l) => l.trim()).map(tabs));
for (const l of lines) if (l.trim()) ln(l.trim(), d + tabs(l) - base);
};
// LS bursts (start_create_particles) fire at creation and ignore emit_wait_time, and
// limit_time counts from creation: a delayed burst becomes a 50 ms rate-based block.
// Layers read from an LS script (lsBurst) keep the script's own meaning.
const layers = [];
for (const L of preset.layers.filter((l) => l.on)) {
if (L.burst > 0 && L.delay > 0 && !L.lsBurst) {
layers.push({ ...L, rate: Math.max(1, Math.round(L.burst)) * 20, burst: 0, quota: Math.max(1, Math.round(L.burst)), duration: 50 });
if (L.rate > 0) layers.push({ ...L, burst: 0 });
} else layers.push(L);
}
if (preset.info?.length) {
ln("info"); ln("{");
for (const [k, v] of preset.info) ln(v === "" ? k : `${k} ${v}`, 1);
ln("}");
} else {
let ext = 50;
for (const L of layers) ext = Math.max(ext, ...L.area.map((a) => a / 2), L.size[1]);
ln("// Made with LS Tools FX Lab"); ln("info"); ln("{");
ln(`collision_box ${f2(-ext)} ${f2(-ext)} ${f2(-ext)} ${f2(ext)} ${f2(ext)} ${f2(ext)}`, 1);
ln("direction 0.00 1.00 0.00", 1); ln("}");
}
for (const L of layers) {
const tex = texOf(L), oneShot = L.duration > 0, o = L.orig;
/** Key line: original text while its fields are unchanged; keys the script did not have are added only once edited. */
const put = (group, key, gen, d, sep) => {
const fields = KEY_FIELDS[key] || [];
if (o) {
const has = Object.prototype.hasOwnProperty.call(o[group], key);
if (has && unchanged(L, fields)) return ln(`${o.names[group][key]}${sep}${o[group][key]}`.trimEnd(), d);
if (!has && unchanged(L, fields)) return;
}
ln(`${key}${sep}${gen}`, d);
};
const hRatio = o && unchanged(L, ["size", "aspect"]) ? null : L.aspect;
ln("particle"); ln("{");
const sys = [["quota", Math.max(o ? 0 : 1, Math.round(L.quota))], ["follow_parent", L.follow],
["default_width", `${f2(L.size[0])} ${f2(L.size[1])}`], ["default_height", `${f2(L.size[0] * (hRatio ?? L.aspect))} ${f2(L.size[1] * (hRatio ?? L.aspect))}`],
["render_type", L.blend], ["depth_check", L.depth ? "true" : "false"], ["texture", tex.path],
["billboard_type", L.billboard], ["end_condition", oneShot ? "time_limit" : "never_end"], ["limit_time", f2(oneShot ? L.delay + L.duration : 1000)],
["common_dir", v3(L.commonDir)], ["common_dir_none_billboard", L.fixedDir ? "true" : "false"],
["start_create_particles", Math.round(L.burst)], ["link_biped_name", L.bone && L.bone !== "none" ? L.bone : "none"],
["pos_offset", v3(L.offset)], ["modulate2x", L.mod2x ? "true" : "false"]];
if (!o) {
sys.splice(1, 0, ["cull_each", "false"]);
sys.splice(6, 0, ["tex_filtering", "bilinear"]);
sys.push(["rot_offset", "0.00 0.00 0.00 1.00"], ["random_sync_index", -1]);
for (const [k, v] of sys) ln(`${k} ${v}`, 1);
} else {
// the script's own key order; keys it did not have follow once edited
const gen = Object.fromEntries(sys);
for (const [k, v] of o.orderP) {
const lk = k.toLowerCase();
if (gen[lk] !== undefined) put("p", lk, gen[lk], 1, " ");
else ln(v === "" ? k : `${k} ${v}`, 1);
}
for (const [k, v] of sys) if (!Object.prototype.hasOwnProperty.call(o.p, k)) put("p", k, v, 1, " ");
}
ln();
ln(`emitter ${o && unchanged(L, ["shape"]) ? o.emType : SHAPES.includes(L.shape) ? L.shape : "Point"}`, 1); ln("{", 1);
const colors = L.colors.slice().sort((a, b) => a[0] - b[0]).flatMap(([t, c]) => [f2(t), ...hexRgb(c).map(f2)]).join(" ");
const alpha = L.alpha.slice().sort((a, b) => a[0] - b[0]).flatMap(([t, a]) => [f2(t), f2(a)]).join(" ");
const startRot = L.rotStart || L.rot;
const em = [["emit_pos", v3(L.pos)], ["emit_dir", v3(L.dir)], ["emit_angle", f2(L.spread)], ["emit_rate", f2(L.rate)],
["emit_start_tex_rotation", `${f2(startRot[0])} ${f2(startRot[1])}`], ["emit_speed", `${f2(L.speed[0])} ${f2(L.speed[1])}`],
["emit_live_time", `${f2(L.life[0])} ${f2(L.life[1])}`], ["emit_wait_time", f2(L.delay)], ["color_rate", colors], ["alpha_rate", alpha]];
const needArea = L.shape !== "Point", needInner = L.shape === "Ellipse" || L.shape === "HollowEllipsoid";
if (!o) {
if (needArea) em.push(["area_size", v3(L.area)]);
if (needInner) em.push(["inner_size", v3(L.inner)]);
for (const [k, v] of em) ln(`${k}\t${v}`, 2);
} else {
em.push(["area_size", v3(L.area)], ["inner_size", v3(L.inner)]);
const gen = Object.fromEntries(em);
for (const [k, v] of o.orderE) {
const lk = k.toLowerCase();
if (gen[lk] !== undefined) put("e", lk, gen[lk], 2, "\t");
else ln(v === "" ? k : `${k}\t${v}`, 2);
}
for (const [k, v] of em) {
if (Object.prototype.hasOwnProperty.call(o.e, k) || (k === "area_size" && !needArea) || (k === "inner_size" && !needInner)) continue;
put("e", k, v, 2, "\t");
}
}
ln("}", 1); ln();
const done = new Set();
for (const entry of L.affOrder || []) {
const fields = AFF_FIELDS[entry.type];
if (!fields || unchanged(L, fields)) raw(entry.lines, 1);
else raw(affectorLines(entry.type, L), 1);
if (fields) done.add(entry.type);
ln();
}
for (const type of ["scale", "texrotation", "linear", "emit"]) {
if (done.has(type) || !affectorNeeded(type, L)) continue;
raw(affectorLines(type, L), 1); ln();
}
ln("}");
}
for (const block of preset.passthrough || []) raw(block, 0);
return out.join("\r\n") + "\r\n";
}
// ---------- Lost Saga effect script → preset (re-edit existing effects) ----------
/** Blocks of a script: [{ kind, type, lines (raw, block included), attrs: [[key, value]], children }] */
function blocksOf(text) {
const lines = text.split(/\r\n|\n|\r/);
const roots = [], stack = [];
let header = null;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t.startsWith("//")) continue;
if (t === "{" || t.endsWith("{")) {
const h = t === "{" ? header?.text || "" : t.slice(0, -1).trim();
const [kind = "", ...rest] = h.split(/\s+/);
const node = { kind: kind.toLowerCase(), type: rest.join(" "), start: t === "{" ? header?.line ?? i : i, attrs: [], children: [] };
if (stack.length) stack.at(-1).children.push(node); else roots.push(node);
stack.push(node);
header = null;
} else if (t === "}") {
const node = stack.pop();
if (!node) throw new Error(`Unexpected } at line ${i + 1}`);
node.end = i;
node.lines = lines.slice(node.start, i + 1);
} else {
// a line is a block header only when "{" follows it; otherwise it is an attribute
let j = i + 1;
while (j < lines.length && (!lines[j].trim() || lines[j].trim().startsWith("//"))) j++;
if (lines[j]?.trim() === "{") header = { text: t, line: i };
else if (stack.length) { const m = /^(\S+)\s*(.*)$/.exec(t); stack.at(-1).attrs.push([m[1], m[2]]); }
}
}
if (stack.length) throw new Error(`Missing } for ${stack.at(-1).kind}`);
return roots;
}
const numsOf = (s) => String(s || "").trim().split(/\s+/).filter(Boolean).map(Number).filter((n) => !isNaN(n));
const SHAPE_OF = Object.fromEntries(SHAPES.map((s) => [s.toLowerCase(), s]));
const rgbHex = (r, g, b) => "#" + [r, g, b].map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join("");
const constRate = (s) => { const n = numsOf(s); for (let i = 1; i < n.length; i += 2) if (Math.abs(n[i] - 1) > 1e-3) return false; return true; };
/** → { preset, notes } */
function fromScript(text, name) {
const notes = [];
const preset = { name: name.replace(/\.txt$/i, ""), cat: "LS effect", layers: [], info: [], passthrough: [] };
const roots = blocksOf(text);
let n = 0;
for (const b of roots) {
if (b.kind === "info") { preset.info = b.attrs; continue; }
if (b.kind !== "particle") { preset.passthrough.push(b.lines); notes.push(`${b.kind} block kept as is${b.kind === "model" ? " (mesh preview: hero mode or FX tab)" : ""}`); continue; }
n++;
const a = new Map(b.attrs.map(([k, v]) => [k.toLowerCase(), v]));
const emitter = b.children.find((c) => c.kind === "emitter");
const e = new Map((emitter?.attrs || []).map(([k, v]) => [k.toLowerCase(), v]));
const pair = (s, d) => { const v = numsOf(s); return v.length >= 2 ? v.slice(0, 2) : v.length ? [v[0], v[0]] : d; };
const vec = (s, d) => { const v = numsOf(s); return v.length >= 3 ? v.slice(0, 3) : d; };
const w = pair(a.get("default_width"), [100, 100]), h = pair(a.get("default_height"), w);
const tex = (a.get("texture") || "").trim();
const proc = /(?:^|[\\/])fxlab_(\w+)\.dds$/i.exec(tex);
const L = layer({
name: `Particle ${n}`, lsBurst: true,
tex: proc && SPRITES[proc[1].toLowerCase()] ? { kind: "proc", shape: proc[1].toLowerCase() } : { kind: "pack", name: tex || "none" },
blend: (a.get("render_type") || "add").toLowerCase(), billboard: (a.get("billboard_type") || "point").toLowerCase(),
depth: a.get("depth_check") !== "false", mod2x: a.get("modulate2x") === "true",
quota: Number(a.get("quota")) || 0, burst: Number(a.get("start_create_particles")) || 0,
follow: (a.get("follow_parent") || "none").toLowerCase(), bone: (a.get("link_biped_name") || "none").trim() || "none",
offset: vec(a.get("pos_offset"), [0, 0, 0]), commonDir: vec(a.get("common_dir"), [0, 1, 0]), fixedDir: a.get("common_dir_none_billboard") === "true",
size: w, aspect: w[1] ? Math.round((h[1] / w[1]) * 1000) / 1000 : 1,
shape: SHAPE_OF[(emitter?.type || "point").toLowerCase()] || "Point",
pos: vec(e.get("emit_pos"), [0, 0, 0]), dir: vec(e.get("emit_dir"), [0, 1, 0]), spread: Number(e.get("emit_angle")) || 0,
rate: e.has("emit_rate") ? Number(e.get("emit_rate")) || 0 : 10, speed: pair(e.get("emit_speed"), [1, 1]), life: pair(e.get("emit_live_time"), [5000, 5000]),
delay: Number(e.get("emit_wait_time")) || 0, rot: pair(e.get("emit_start_tex_rotation"), [0, 0]),
area: vec(e.get("area_size"), [100, 100, 100]), inner: vec(e.get("inner_size"), [0.5, 0.5, 0.5]),
gravity: [0, 0, 0], attract: 0, spin: [0, 0], sizeCurve: [[0, 1], [1, 1]],
});
const limit = Number(a.get("limit_time")) || 0;
L.duration = (a.get("end_condition") || "").toLowerCase() === "time_limit" ? Math.max(1, limit - L.delay) : 0;
const curve = (s, k) => { const v = numsOf(s), out = []; for (let i = 0; i < v.length; i += k + 1) out.push([v[i], v.slice(i + 1, i + 1 + k)]); return out; };
const cr = curve(e.get("color_rate"), 3);
L.colors = cr.length ? cr.map(([t, c]) => [t, rgbHex(c[0] ?? 255, c[1] ?? 255, c[2] ?? 255)]) : [[0, "#ffffff"]];
const ar = curve(e.get("alpha_rate"), 1);
L.alpha = ar.length ? ar.map(([t, v]) => [t, v[0] ?? 1]) : [[0, 1]];
// keys FX Lab does not model are written back as they are
L.extra = b.attrs.filter(([k]) => !KEY_FIELDS[k.toLowerCase()] || STATIC_KEYS.includes(k.toLowerCase()));
L.extraEmitter = (emitter?.attrs || []).filter(([k]) => !KEY_FIELDS[k.toLowerCase()]);
L.affOrder = [];
const seen = new Set();
for (const f of b.children.filter((c) => c.kind === "affector")) {
const t = f.type.toLowerCase(), fa = new Map(f.attrs.map(([k, v]) => [k.toLowerCase(), v]));
const simple = !seen.has(t) && !(Number(fa.get("wait_time")) > 0);
let mapped = false;
if (simple && t === "scale") {
const k = curve(fa.get("scale_rate"), 2);
if (k.length) {
L.sizeCurve = k.map(([tt, s]) => [tt, s[0] ?? 1]);
const ratios = k.map(([, s]) => (s[0] ? (s[1] ?? s[0]) / s[0] : 1));
if (ratios.some((r) => Math.abs(r - ratios[0]) > 1e-3)) notes.push(`Particle ${n}: Scale X / Y curves differ; editing the size curve makes Y follow X`);
L.scaleY = Math.round(ratios[0] * 1000) / 1000;
mapped = true;
}
} else if (simple && t === "texrotation") {
L.rotStart = L.rot;
L.rot = pair(fa.get("rot_range"), L.rot);
L.spin = pair(fa.get("rot_speed"), [0, 0]).map((r) => Math.round((r * 180) / Math.PI * 10) / 10);
mapped = true;
} else if (simple && t === "linear") {
L.gravity = vec(fa.get("force_vector"), [0, -100, 0]);
L.forceMethod = fa.get("force_method") || "add";
L.gravityRate = constRate(fa.get("force_rate")) ? "" : fa.get("force_rate");
mapped = true;
} else if (simple && t === "emit") {
L.attract = Number(fa.get("power")) || 0;
L.attractRate = constRate(fa.get("force_rate")) ? "" : fa.get("force_rate");
L.attractChangeDir = fa.get("change_direction") === "true";
mapped = true;
}
if (mapped) seen.add(t);
else notes.push(`Particle ${n}: affector ${f.type} kept as is (not editable here)`);
L.affOrder.push({ type: mapped ? t : "raw", lines: f.lines });
}
const keyed = (attrs) => Object.fromEntries(attrs.filter(([k]) => KEY_FIELDS[k.toLowerCase()]).map(([k, v]) => [k.toLowerCase(), v]));
const names = (attrs) => Object.fromEntries(attrs.map(([k]) => [k.toLowerCase(), k]));
L.orig = { p: keyed(b.attrs), e: keyed(emitter?.attrs || []), names: { p: names(b.attrs), e: names(emitter?.attrs || []) }, emType: emitter?.type || "Point", fields: null, orderP: b.attrs, orderE: emitter?.attrs || [] };
if (!emitter) notes.push(`Particle ${n}: no emitter block; one is written on save`);
L.orig.fields = snapshot(layer(clone(L)));
preset.layers.push(L);
}
return { preset, notes };
}
FXL.fromScript = fromScript;
FXL.toScript = toScript;
// ---------- views & textures ----------
let view = null, kit = null, inst = null, last = 0, cycleAt = 0;
const viewTex = new WeakMap(); // GL3D view → Map(file → texture)
const texSources = new Map(); // lowercase file → texOf()
function customTexture(name, v) {
const src = texSources.get(String(name).toLowerCase());
if (!src || src.pack) return null;
let m = viewTex.get(v);
if (!m) viewTex.set(v, (m = new Map()));
const key = src.image ? `img:${src.image.length}:${src.image.slice(-32)}` : `proc:${src.shape}`;
if (!m.has(key)) {
const c = src.image ? imageCanvas(src.image) : sprite(src.shape);
if (!c) return null;
const t = v.texture(c), gl = v.gl;
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
m.set(key, t);
}
return m.get(key);
}
const packTex = new Map();
function texture(name) {
const custom = customTexture(name, view);
if (custom) return custom;
if (!name || !FXL.src || !kit) return null;
const key = name.toLowerCase();
if (!packTex.has(key)) {
packTex.set(key, null);
kit.texture(name).then((t) => {
if (!t.tex) return;
const gl = view.gl;
gl.bindTexture(gl.TEXTURE_2D, t.tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
packTex.set(key, t.tex);
});
}
return packTex.get(key);
}
const BG = { gray: [94 / 255, 94 / 255, 94 / 255], dark: [0.09, 0.085, 0.1], black: [0, 0, 0] };
function ensureView() {
if (view) return view;
try { view = new GL3D.View($("fxl-canvas"), { alpha: false }); }
catch (err) { toast(`FX Lab preview unavailable: ${err.message}`, true); return null; }
view.env = { clear: BG[$("fxl-bg").value] || BG.gray };
view.opts.lit = false;
kit = MeshKit.create(view, () => FXL.src);
view.frame([-150, -20, -150], [150, 200, 150]);
drawGrid();
return view;
}
function drawGrid() {
if (!view) return;
if (!$("fxl-grid").classList.contains("on")) return view.setOverlay("fxl-grid", null);
const a = [], line = (x, z, xx, zz, c) => a.push(x, 0, z, ...c, xx, 0, zz, ...c);
for (let v = -300; v <= 300; v += 50) {
const c = v === 0 ? [0.82, 0.69, 0.48, 0.62] : [0.7, 0.7, 0.7, 0.35];
line(v, -300, v, 300, c); line(-300, v, 300, v, c);
}
view.setOverlay("fxl-grid", new Float32Array(a));
}
// ---------- rebuild + play ----------
let rebuildTimer = 0;
function rebuild() {
clearTimeout(rebuildTimer);
rebuildTimer = setTimeout(rebuildNow, 60);
}
function rebuildNow() {
if (!FXL.preset) return;
texSources.clear();
for (const L of FXL.preset.layers) { const t = texOf(L); texSources.set(t.file.toLowerCase(), t); }
FXL.text = toScript(FXL.preset);
$("fxl-script").value = FXL.text;
restart();
}
const cycleMs = () => {
const ls = FXL.preset?.layers.filter((l) => l.on) || [];
const oneShot = ls.length && ls.every((l) => l.duration > 0);
const span = Math.max(500, ...ls.map((l) => (l.duration > 0 ? l.duration : 2000) + l.delay + l.life[1]));
return oneShot ? span + 500 : 4000;
};
function heroHost() { return { stage: $("fxl-stage"), viewId: "view-fxlab" }; }
function restart() {
if (!FXL.text) return;
const def = Effects.parse(FXL.text);
if (FXL.mode === "free") {
if (!ensureView()) return;
inst = Effects.instance(def, [0, 0, 0]);
view.setParticles([]);
} else if (window.MDL?.startHeroFx && MDL.heroPreview) {
inst = null;
const anim = $("fxl-anim").value;
if (anim) MDL.playHeroMotion(anim).catch(() => {});
MDL.startHeroFx(def, { target: FXL.mode === "hit", duration: cycleMs(), customTexture });
cycleAt = performance.now() + cycleMs();
}
}
function tick(now) {
const dt = Math.min(100, Math.max(0, now - (last || now)));
last = now;
if ($("view-fxlab").classList.contains("on")) {
if (FXL.mode === "free" && view && inst && FXL.playing) {
Effects.update(inst, dt);
const parts = Effects.build(inst, view.cameraAxes());
for (const p of parts) p.tex = texture(p.texture);
view.setParticles(parts);
$("fxl-stats").textContent = `${Effects.count(inst)} particles · ${inst.systems.length} layers`;
} else if (FXL.mode !== "free" && window.MDL?.heroPreview) {
if (FXL.playing && now >= cycleAt && cycleAt) restart();
$("fxl-stats").textContent = `${MDL.heroFxCount()} particles · on hero`;
}
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// ---------- hero mode ----------
async function setMode(mode) {
FXL.mode = mode;
$("fxl-mode").value = mode;
$("fxl-anim").hidden = mode === "free";
if (mode === "free") {
if ($("mdl-canvas")?.parentElement === $("fxl-stage")) MDL.embedHero(false);
$("fxl-canvas").hidden = false;
ensureView(); view?.resize();
cycleAt = 0;
return restart();
}
if (!FXL.src) { toast("Choose the game packs first (hero model and animations come from them).", true); return setMode("free"); }
$("fxl-canvas").hidden = true;
try {
await MDL.previewHero(FXL.src, [], null, { embedded: true, host: heroHost() });
MDL.setHeroTransform([0, 0, 0], 0);
MDL.setHeroPracticeTarget(mode === "hit" ? [0, 0, 130] : null);
await fillAnims();
renderProps();
restart();
} catch (err) { toast(`Hero preview: ${err.message}`, true); $("fxl-canvas").hidden = false; FXL.mode = "free"; $("fxl-mode").value = "free"; }
}
let animNames = null;
async function fillAnims() {
if (!animNames) {
try { animNames = (await call("mdl:names", FXL.src, "animation")).filter((n) => /\.ani$/i.test(n) && /(^|\/)(sp2_)?human|attack|skill|delay|run|walk/i.test(n)).slice(0, 4000); }
catch { animNames = []; }
}
const sel = $("fxl-anim"), cur = sel.value;
const pick = animNames.filter((n) => /attack|skill|delay/i.test(n)).slice(0, 600);
sel.replaceChildren(el("option", { value: "", text: "No animation (stand)" }), ...pick.map((n) => el("option", { value: n.split("/").pop(), text: n.split("/").pop() })));
sel.value = cur;
}
// ---------- presets ----------
function load(preset) {
FXL.preset = clone(preset);
delete FXL.preset.builtin;
FXL.preset.layers = FXL.preset.layers.map((l) => layer(l));
FXL.sel = 0;
FXL.notes = preset.notes || [];
renderNotes();
FXL.where = null;
updateSave();
$("fxl-name").value = safeName(preset.name);
renderLayers(); renderProps(); renderPresets();
rebuildNow();
if (preset.mode && preset.mode !== FXL.mode && FXL.src) setMode(preset.mode);
}
async function loadUser() {
try { FXL.user = await call("fxlab:presets"); } catch (err) { FXL.user = []; toast(err.message, true); }
renderPresets();
}
function renderPresets() {
const q = $("fxl-search").value.trim().toLowerCase();
const match = (p) => !q || `${p.name} ${p.cat || ""}`.toLowerCase().includes(q);
const row = (p, user) => el("div", { class: `ims-set fxl-preset${FXL.preset?.name === p.name ? " on" : ""}`, onclick: () => load(p) }, [
el("b", { text: p.name }),
el("span", { text: `${p.cat || "Saved"} · ${p.layers.length} layer${p.layers.length === 1 ? "" : "s"}${p.mode && p.mode !== "free" ? ` · ${p.mode === "hit" ? "hit" : "hero"}` : ""}` }),
user ? el("button", { class: "iconbtn only danger fxl-del", title: "Delete preset", onclick: async (e) => {
e.stopPropagation();
if (!(await confirmBox("Delete preset", `Delete "${p.name}" from the FX Lab library?`, "Delete", "danger"))) return;
await call("fxlab:deletePreset", p.name); loadUser();
} }, [icon("trash")]) : null,
]);
const user = FXL.user.filter(match), built = BUILTIN.filter(match);
$("fxl-presets").replaceChildren(
...(user.length ? [el("div", { class: "fxl-group", text: "My presets" }), ...user.map((p) => row(p, true))] : []),
el("div", { class: "fxl-group", text: "Built-in" }), ...built.map((p) => row(p, false)));
}
const currentPreset = () => ({ ...clone(FXL.preset), name: $("fxl-name").value.trim() || "my_effect", mode: FXL.mode, version: 1, app: "LS Tools FX Lab" });
// ---------- layers ----------
function renderLayers() {
const box = $("fxl-layers");
box.replaceChildren(...FXL.preset.layers.map((L, i) => el("div", { class: `fxl-layer${i === FXL.sel ? " on" : ""}`, onclick: () => { FXL.sel = i; renderLayers(); renderProps(); } }, [
el("input", { type: "checkbox", checked: L.on, title: "Visible", onclick: (e) => e.stopPropagation(), onchange: (e) => { L.on = e.target.checked; rebuild(); } }),
el("canvas", { class: "fxl-swatch", width: 22, height: 22 }),
el("span", { text: L.name }),
el("em", { class: "muted", text: `${L.blend}${L.bone !== "none" ? ` · ${L.bone}` : ""}` }),
])));
box.querySelectorAll(".fxl-swatch").forEach((c, i) => drawSwatch(c, FXL.preset.layers[i]));
}
function drawSwatch(c, L) {
const ctx = c.getContext("2d");
ctx.fillStyle = "#222"; ctx.fillRect(0, 0, c.width, c.height);
const t = texOf(L), src = t.image ? imageCanvas(t.image) : t.shape ? sprite(t.shape) : null;
if (!src) return;
const g = ctx.createLinearGradient(0, 0, c.width, 0);
for (const [p, col] of L.colors.slice().sort((a, b) => a[0] - b[0])) g.addColorStop(clamp(p, 0, 1), col);
const tmp = document.createElement("canvas"); tmp.width = c.width; tmp.height = c.height;
const tc = tmp.getContext("2d");
tc.drawImage(src, 0, 0, c.width, c.height); tc.globalCompositeOperation = "source-in"; tc.fillStyle = g; tc.fillRect(0, 0, c.width, c.height);
ctx.drawImage(tmp, 0, 0);
}
const selLayer = () => FXL.preset?.layers[FXL.sel];
// ---------- property widgets ----------
const changed = () => { rebuild(); const c = $("fxl-layers").children[FXL.sel]?.querySelector("canvas"); if (c) drawSwatch(c, selLayer()); };
function numRow(label, obj, key, { min = 0, max = 100, step = 1, idx = null, hint = "" } = {}) {
const get = () => (idx === null ? obj[key] : obj[key][idx]);
const set = (v) => { if (idx === null) obj[key] = v; else obj[key][idx] = v; changed(); };
const range = el("input", { type: "range", min, max, step, value: clamp(get(), min, max) });
const num = el("input", { type: "number", step, value: get(), class: "fxl-num" });
range.oninput = () => { num.value = range.value; set(Number(range.value)); };
num.onchange = () => { const v = Number(num.value) || 0; range.value = clamp(v, min, max); set(v); };
return [el("label", { text: label, title: hint }), el("div", { class: "fxl-slider" }, [range, num])];
}
function pairRow(label, obj, key, { step = 1, hint = "" } = {}) {
const inp = (i) => el("input", { type: "number", step, value: obj[key][i], title: i ? "max" : "min", onchange: (e) => { obj[key][i] = Number(e.target.value) || 0; changed(); } });
return [el("label", { text: label, title: hint }), el("div", { class: "fxl-pair" }, [inp(0), inp(1)])];
}
function vecRow(label, obj, key, hint = "") {
return [el("label", { text: label, title: hint }), el("div", { class: "fx-vector" }, obj[key].map((v, i) => el("input", { type: "number", step: 1, value: v, title: "XYZ"[i], onchange: (e) => { obj[key][i] = Number(e.target.value) || 0; changed(); } })))];
}
function selRow(label, obj, key, options, after) {
const s = el("select", { onchange: () => { obj[key] = s.value; changed(); after?.(); } }, options.map((o) => { const [v, t] = Array.isArray(o) ? o : [o, o]; return el("option", { value: v, text: t }); }));
s.value = obj[key];
return [el("label", { text: label }), s];
}
function boolRow(label, obj, key) {
return [el("label", { text: label }), el("input", { type: "checkbox", class: "fxl-check", checked: obj[key], onchange: (e) => { obj[key] = e.target.checked; changed(); } })];
}
const group = (t) => el("div", { class: "fx-group", text: t });
/** Curve editor: points [[t, v]], t 0..1, v 0..vmax. Drag; double-click adds; right-click removes. */
function curveEditor(points, vmax, color = "#e9b43c") {
const c = el("canvas", { class: "fxl-curve", width: 300, height: 80 });
const W = () => c.width, H = () => c.height, pad = 6;
const toXY = ([t, v]) => [pad + t * (W() - 2 * pad), H() - pad - (v / vmax) * (H() - 2 * pad)];
const fromXY = (x, y) => [clamp((x - pad) / (W() - 2 * pad), 0, 1), clamp(((H() - pad - y) / (H() - 2 * pad)) * vmax, 0, vmax)];
const draw = () => {
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, W(), H());
ctx.fillStyle = "rgba(255,255,255,0.04)"; ctx.fillRect(0, 0, W(), H());
ctx.strokeStyle = "rgba(255,255,255,0.12)"; ctx.beginPath();
for (let k = 1; k < 4; k++) { const y = pad + (k / 4) * (H() - 2 * pad); ctx.moveTo(pad, y); ctx.lineTo(W() - pad, y); }
ctx.stroke();
points.sort((a, b) => a[0] - b[0]);
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath();
const first = toXY(points[0]); ctx.moveTo(pad, first[1]);
for (const p of points) { const [x, y] = toXY(p); ctx.lineTo(x, y); }
ctx.lineTo(W() - pad, toXY(points[points.length - 1])[1]); ctx.stroke();
ctx.fillStyle = "#fff";
for (const p of points) { const [x, y] = toXY(p); ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); }
ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.font = "10px sans-serif"; ctx.fillText(String(vmax), 2, 10);
};
const local = (e) => { const r = c.getBoundingClientRect(); return [(e.clientX - r.left) * (W() / r.width), (e.clientY - r.top) * (H() / r.height)]; };
const hitIdx = (x, y) => points.findIndex((p) => { const [px, py] = toXY(p); return Math.hypot(px - x, py - y) < 8; });
let drag = -1;
c.onpointerdown = (e) => { if (e.button !== 0) return; drag = hitIdx(...local(e)); if (drag >= 0) c.setPointerCapture(e.pointerId); };
c.onpointermove = (e) => {
if (drag < 0) return;
const [t, v] = fromXY(...local(e)), p = points[drag];
p[0] = Math.round(t * 100) / 100; p[1] = Math.round(v * 100) / 100;
draw(); changed();
};
c.onpointerup = () => { drag = -1; };
c.ondblclick = (e) => { points.push(fromXY(...local(e)).map((n) => Math.round(n * 100) / 100)); draw(); changed(); };
c.oncontextmenu = (e) => { e.preventDefault(); const i = hitIdx(...local(e)); if (i >= 0 && points.length > 1) { points.splice(i, 1); draw(); changed(); } };
draw();
return c;
}
/** Colour ramp: stops [[t, "#hex"]]. Click a stop to recolour, drag to move, double-click the bar to add, right-click to remove. */
function gradientEditor(stops) {
const wrap = el("div", { class: "fxl-grad" });
const bar = el("div", { class: "fxl-grad-bar" }), marks = el("div", { class: "fxl-grad-marks" });
const picker = el("input", { type: "color", class: "fxl-grad-picker" });
let editing = null;
picker.oninput = () => { if (editing) { editing[1] = picker.value; paint(); changed(); } };
const paint = () => {
stops.sort((a, b) => a[0] - b[0]);
bar.style.background = `linear-gradient(90deg, ${stops.map(([t, c]) => `${c} ${(t * 100).toFixed(1)}%`).join(", ")})`;
marks.replaceChildren(...stops.map((s) => {
const m = el("div", { class: "fxl-grad-stop", title: "Click: colour · drag: move · right-click: remove" });
m.style.left = `${s[0] * 100}%`; m.style.background = s[1];
let moved = false;
m.onpointerdown = (e) => {
if (e.button !== 0) return;
moved = false; m.setPointerCapture(e.pointerId);
m.onpointermove = (ev) => { const r = marks.getBoundingClientRect(); s[0] = Math.round(clamp((ev.clientX - r.left) / r.width, 0, 1) * 100) / 100; m.style.left = `${s[0] * 100}%`; moved = true; bar.style.background = `linear-gradient(90deg, ${stops.slice().sort((a, b) => a[0] - b[0]).map(([t, c]) => `${c} ${(t * 100).toFixed(1)}%`).join(", ")})`; changed(); };
m.onpointerup = () => { m.onpointermove = null; if (!moved) { editing = s; picker.value = s[1]; picker.click(); } else paint(); };
};
m.oncontextmenu = (e) => { e.preventDefault(); if (stops.length > 1) { stops.splice(stops.indexOf(s), 1); paint(); changed(); } };
return m;
}));
};
bar.ondblclick = (e) => {
const r = bar.getBoundingClientRect(), t = Math.round(clamp((e.clientX - r.left) / r.width, 0, 1) * 100) / 100;
const near = stops.reduce((a, b) => (Math.abs(b[0] - t) < Math.abs(a[0] - t) ? b : a));
stops.push([t, near[1]]); paint(); changed();
};
paint();
wrap.append(bar, marks, picker);
return wrap;
}
/** pixelfx-style palette ramps: one click sets the colour stops. */
const RAMPS = {
Fire: ["#fff2a8", "#ff9a1f", "#d42a06", "#3a0a02"], Ice: ["#ffffff", "#9fe8ff", "#3a8cff", "#0c2a7a"],
Poison: ["#e8ffb0", "#7cff3a", "#1f9a3a", "#0a3a1a"], Arcane: ["#ffe6ff", "#d18bff", "#7a3aff", "#1a0a5a"],
Holy: ["#ffffff", "#fff3b0", "#ffc94a", "#a86a10"], Blood: ["#ff9a9a", "#e01a2a", "#7a0612", "#2a0206"],
Electric: ["#ffffff", "#c8f4ff", "#5ac8ff", "#2a3aff"], Shadow: ["#b8a8c8", "#5a4a7a", "#2a1a3a", "#0a0610"], White: ["#ffffff", "#ffffff"],
};
function rampButtons(L, after) {
return el("div", { class: "fxl-ramps" }, Object.entries(RAMPS).map(([name, cols]) => {
const b = el("button", { class: "fxl-ramp", title: name, onclick: () => { L.colors = cols.map((c, i) => [Math.round((i / Math.max(1, cols.length - 1)) * 100) / 100, c]); after(); changed(); } });
b.style.background = `linear-gradient(90deg, ${cols.join(", ")})`;
return b;
}));
}
function spritePicker(L) {
const wrap = el("div", { class: "fxl-sprites" });
for (const [shape, label] of Object.entries(SPRITES)) {
const c = el("canvas", { width: 34, height: 34, title: label, class: `fxl-sprite${L.tex.kind === "proc" && L.tex.shape === shape ? " on" : ""}`, onclick: () => { L.tex = { kind: "proc", shape }; renderProps(); changed(); } });
const ctx = c.getContext("2d"); ctx.fillStyle = "#181818"; ctx.fillRect(0, 0, 34, 34); ctx.drawImage(sprite(shape), 1, 1, 32, 32);
wrap.append(c);
}
const packBtn = el("button", { class: `iconbtn${L.tex.kind === "pack" ? " on" : ""}`, title: "Use a texture from the game packs (resource/texture)", onclick: () => pickPackTexture(L) }, [icon("box"), el("span", { text: L.tex.kind === "pack" ? L.tex.name.split("/").pop() : "Pack…" })]);
const fileBtn = el("button", { class: `iconbtn${L.tex.kind === "image" ? " on" : ""}`, title: "Use a PNG / JPG image (exported as DDS)", onclick: () => pickImage(L) }, [icon("image"), el("span", { text: L.tex.kind === "image" ? L.tex.label : "Image…" })]);
wrap.append(packBtn, fileBtn);
return wrap;
}
async function pickPackTexture(L) {
if (!FXL.src) return toast("Choose the game packs first.", true);
let names;
try { names = (await call("mdl:names", FXL.src, "texture")).filter((n) => /\.(dds|tga|png|jpg)$/i.test(n)); } catch (err) { return toast(err.message, true); }
const search = el("input", { type: "search", placeholder: "Search textures (EffectTexture/…)", spellcheck: "false" });
const listBox = el("div", { class: "fx-resource-list" });
let choice = null;
const fill = () => {
const q = search.value.trim().toLowerCase();
const rows = names.filter((n) => (q ? n.toLowerCase().includes(q) : /^effecttexture\//i.test(n))).slice(0, 300);
listBox.replaceChildren(...rows.map((n) => el("button", { class: `fx-resource${choice === n ? " on" : ""}`, text: n, onclick: () => { choice = n; fill(); }, ondblclick: () => { choice = n; $("modal-actions").lastChild.click(); } })));
};
search.oninput = fill; fill();
if (!(await modal("Pack texture", [search, listBox], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }])) || !choice) return;
const name = /^effecttexture\//i.test(choice) ? `EffectTexture/${choice.slice(14)}` : choice;
L.tex = { kind: "pack", name };
packTex.clear();
renderProps(); changed();
}
async function pickImage(L) {
const file = await call("dialog:file", "Sprite image (PNG / JPG)");
if (!file) return;
try {
const hit = await call("mdl:readFile", file);
const ext = file.split(".").pop().toLowerCase();
const blob = new Blob([hit.bytes], { type: ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png" });
const data = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(blob); });
L.tex = { kind: "image", label: safeName(file.split(/[\\/]/).pop().replace(/\.\w+$/, "")), data };
renderProps(); changed();
} catch (err) { toast(err.message, true); }
}
function renderProps() {
const L = selLayer(), box = $("fxl-props");
if (!L) { $("fxl-layer-name").textContent = ""; return box.replaceChildren(el("small", { class: "muted", text: "Add a layer to start." })); }
$("fxl-layer-name").textContent = L.name;
const rows = [];
const add = (...r) => rows.push(...r.flat());
const nameIn = el("input", { type: "text", value: L.name, spellcheck: "false", onchange: (e) => { L.name = e.target.value || "Layer"; renderLayers(); $("fxl-layer-name").textContent = L.name; } });
add(el("label", { text: "Name" }), nameIn);
add(group("Sprite"));
add(el("div", { class: "fxl-wide" }, [spritePicker(L)]));
add(selRow("Blend", L, "blend", [["add", "Add (glow)"], ["src_alpha", "Alpha (smoke)"], ["screen", "Screen"], ["lighten", "Lighten"], ["darken", "Darken"], ["subtract", "Subtract"]]));
add(selRow("Facing", L, "billboard", [["point", "Camera (billboard)"], ["direction", "Along motion (sparks)"], ["common", "Fixed direction (rings)"]], renderProps));
if (L.billboard === "common") { add(vecRow("Sprite up axis", L, "commonDir", "The sprite top points this way. 0 0 1 + Fixed = flat on the floor")); add(boolRow("Fixed (no camera turn)", L, "fixedDir")); }
add(boolRow("Depth test", L, "depth"), boolRow("Brighter ×2", L, "mod2x"));
add(group("Emission"));
add(numRow("Per second", L, "rate", { max: 200, step: 0.5, hint: "Particles spawned per second (0 = burst only)" }));
add(numRow("Burst at start", L, "burst", { max: 200 }));
add(numRow("Max particles", L, "quota", { min: 1, max: 1000 }));
add(numRow("Duration ms", L, "duration", { max: 5000, step: 50, hint: "0 = loop forever; > 0 = one-shot that stops emitting after this time" }));
add(numRow("Delay ms", L, "delay", { max: 3000, step: 10 }));
add(selRow("Shape", L, "shape", SHAPES, renderProps));
if (L.shape !== "Point") add(vecRow("Shape size", L, "area", "Width / height / depth of the emit area"));
if (L.shape === "Ellipse" || L.shape === "HollowEllipsoid") add(vecRow("Inner (0-1)", L, "inner"));
add(vecRow("Position", L, "pos", "Emitter position (Y up; hero is ~180 tall)"));
add(vecRow("Direction", L, "dir"));
add(numRow("Spread °", L, "spread", { max: 180, hint: "Cone angle around the direction (180 = all directions)" }));
add(group("Motion"));
add(pairRow("Speed min / max", L, "speed", { max: 800 }));
add(pairRow("Life ms min / max", L, "life", { min: 50, max: 6000, step: 10 }));
add(vecRow("Gravity / force", L, "gravity", "Constant force per second (negative Y falls)"));
add(numRow("Push / pull", L, "attract", { min: -400, max: 400, hint: "Moves particles away from (+) or toward (−) the emitter" }));
add(pairRow("Start angle °", L, "rot", { max: 360 }));
add(pairRow("Spin °/s", L, "spin", { min: -720, max: 720, step: 5 }));
add(group("Size"));
add(pairRow("Size min / max", L, "size", { min: 1, max: 400 }));
add(numRow("Height ratio", L, "aspect", { min: 0.05, max: 4, step: 0.05, hint: "Height = width × ratio (0.3 for streaks)" }));
add(el("label", { text: "Size over life" }), curveEditor(L.sizeCurve, 4, "#7fd0ff"));
add(group("Colour"));
add(el("label", { text: "Palette" }), rampButtons(L, renderProps));
add(el("label", { text: "Colour over life" }), gradientEditor(L.colors));
add(el("label", { text: "Alpha over life" }), curveEditor(L.alpha, 1, "#e9b43c"));
add(group("Attach (hero)"));
const bones = window.MDL?.heroBones?.() || [];
const boneIn = el("input", { type: "text", value: L.bone, list: "fxl-bones", spellcheck: "false", title: "Bone the layer follows on the hero (none = at the hero's feet / hit point)", onchange: (e) => { L.bone = e.target.value.trim() || "none"; renderLayers(); changed(); } });
add(el("label", { text: "Bone" }), el("div", { class: "fx-resource-field" }, [boneIn, el("datalist", { id: "fxl-bones" }, ["none", "Bip01", "Bip01 R Hand", "Bip01 L Hand", "Bip01 Head", "Bip01 Spine", "Bip01 R Foot", "Bip01 L Foot", ...bones].filter((v, i, a) => a.indexOf(v) === i).map((b) => el("option", { value: b })))]));
add(selRow("Follow", L, "follow", [["none", "Leave trail (world)"], ["pos_only", "Follow position"], ["all", "Follow position + rotation"]]));
add(vecRow("Bone offset", L, "offset"));
box.replaceChildren(...rows);
}
// ---------- actions ----------
$("fxl-search").oninput = renderPresets;
$("fxl-new").onclick = () => load({ name: "my_effect", layers: [layer({ name: "Layer 1" })] });
$("fxl-add-layer").onclick = () => { FXL.preset.layers.push(layer({ name: `Layer ${FXL.preset.layers.length + 1}` })); FXL.sel = FXL.preset.layers.length - 1; renderLayers(); renderProps(); rebuild(); };
$("fxl-dup-layer").onclick = () => { const L = selLayer(); if (!L) return; FXL.preset.layers.splice(FXL.sel + 1, 0, { ...clone(L), name: `${L.name} copy` }); FXL.sel++; renderLayers(); renderProps(); rebuild(); };
$("fxl-up-layer").onclick = () => { const i = FXL.sel; if (i < 1) return; const ls = FXL.preset.layers; [ls[i - 1], ls[i]] = [ls[i], ls[i - 1]]; FXL.sel--; renderLayers(); rebuild(); };
$("fxl-del-layer").onclick = () => { if (!selLayer()) return; FXL.preset.layers.splice(FXL.sel, 1); FXL.sel = Math.max(0, FXL.sel - 1); renderLayers(); renderProps(); rebuild(); };
$("fxl-play").onclick = () => { FXL.playing = !FXL.playing; $("fxl-play").classList.toggle("on", FXL.playing); if (FXL.playing) restart(); };
$("fxl-restart").onclick = restart;
$("fxl-grid").onclick = () => { $("fxl-grid").classList.toggle("on"); drawGrid(); };
$("fxl-reset").onclick = () => (FXL.mode === "free" ? view?.reset() : MDL.view?.reset());
$("fxl-bg").onchange = () => { if (view) { view.env = { clear: BG[$("fxl-bg").value] }; view.dirty = true; } };
$("fxl-mode").onchange = () => setMode($("fxl-mode").value);
$("fxl-anim").onchange = restart;
$("fxl-save-preset").onclick = async () => {
const p = currentPreset();
try { await call("fxlab:savePreset", p); FXL.preset.name = p.name; toast(`Saved preset ${p.name}`); loadUser(); }
catch (err) { toast(err.message, true); }
};
const importJson = async () => {
try {
const text = await call("fxlab:openJson");
if (!text) return;
const p = JSON.parse(text);
if (!Array.isArray(p.layers)) throw new Error("Not an FX Lab preset (no layers)");
load(p);
} catch (err) { toast(err.message, true); }
};
function renderNotes() {
const box = $("fxl-notes");
box.hidden = !FXL.notes?.length;
box.replaceChildren(el("summary", { class: "muted", text: `Notes (${FXL.notes?.length || 0})` }), el("ul", {}, (FXL.notes || []).map((n) => el("li", { text: n }))));
}
// ---------- existing Lost Saga effects ----------
const bytesToLatin1 = (bytes) => { let s = ""; for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192)); return s; };
/** Open a Lost Saga effect script for editing. where: assets.writeBack target ({ kind: "file", file } or a pack entry). */
function openScript(text, name, where = null) {
const { preset, notes } = fromScript(text, name);
if (!preset.layers.length && !preset.passthrough.length) throw new Error(`${name}: no particle blocks`);
preset.notes = notes;
preset.mode = preset.layers.some((l) => l.bone !== "none") ? "bone" : FXL.mode;
load(preset);
FXL.where = where;
FXL.whereName = name;
FXL.savedText = toScript(FXL.preset);
updateSave();
toast(`Opened ${name}: ${preset.layers.length} particle layer${preset.layers.length === 1 ? "" : "s"}${notes.length ? ` · ${notes.length} note${notes.length === 1 ? "" : "s"}` : ""}`);
}
FXL.openScript = openScript;
function updateSave() {
$("fxl-save").hidden = !FXL.where;
$("fxl-save-label").textContent = FXL.where ? `Save ${FXL.whereName || ""}` : "Save";
}
async function openFromPacks() {
if (!FXL.src) { await chooseSource(); if (!FXL.src) return; }
let rows;
try {
rows = (await call("fx:list", FXL.src)).filter(([, p]) => /(^|\/)resource\/effect\/.+\.txt$/i.test(p)).map(([key, p, pack]) => ({ key, name: p.replace(/^.*?resource\/effect\//i, ""), pack: pack || "" }));
} catch (err) { return toast(err.message, true); }
const search = el("input", { type: "search", placeholder: `Search ${rows.length.toLocaleString()} effects`, spellcheck: "false" });
const listBox = el("div", { class: "fx-resource-list" });
let choice = null;
const fill = () => {
const q = search.value.trim().toLowerCase();
const hits = rows.filter((r) => !q || r.name.toLowerCase().includes(q)).slice(0, 400);
listBox.replaceChildren(...hits.map((r) => el("button", { class: `fx-resource${choice === r ? " on" : ""}`, text: r.name, title: r.pack, onclick: () => { choice = r; fill(); }, ondblclick: () => { choice = r; $("modal-actions").lastChild.click(); } })));
};
search.oninput = fill; fill();
if (!(await modal("Open Lost Saga effect", [search, listBox], [{ label: "Cancel", value: false }, { label: "Open", kind: "primary", value: true }])) || !choice) return;
try {
const hit = await call("mdl:readKey", FXL.src, choice.key);
openScript(bytesToLatin1(hit.bytes), choice.name.split("/").pop(), hit.where);
} catch (err) { toast(err.message, true); }
}
async function openFile() {
const file = await call("fx:openFile");
if (!file) return;
try {
const hit = await call("mdl:readFile", file);
openScript(bytesToLatin1(hit.bytes), file.replace(/^.*[\\/]/, ""), hit.where || { kind: "file", file });
} catch (err) { toast(err.message, true); }
}
$("fxl-open").onclick = (e) => showMenu([
{ label: "Lost Saga effect from packs…", icon: "box", run: openFromPacks },
{ label: "Lost Saga effect file (.txt)…", icon: "folder", run: openFile },
"-",
{ label: "FX Lab preset (.json)…", icon: "import", run: importJson },
], { anchor: e.currentTarget });
$("fxl-save").onclick = async () => {
if (!FXL.where || !FXL.preset) return;
const text = toScript(FXL.preset);
if ([...text].some((c) => c.charCodeAt(0) > 255)) return toast("The script has characters outside the 8-bit encoding; use ASCII names.", true);
const custom = [...new Set(FXL.preset.layers.filter((l) => l.on && l.tex.kind !== "pack").map((l) => texOf(l).file))];
const inPack = FXL.where.kind !== "file";
const msg = [`Overwrite ${FXL.whereName} ${inPack ? "inside its game pack" : "on disk"} with the edited script?`];
if (custom.length) msg.push(`These textures are not in the packs and are not written by Save: ${custom.join(", ")}. Use Export LS effect for them.`);
if (!(await confirmBox("Save effect", msg, "Save"))) return;
try {
await call("mdl:writeBack", FXL.where, latin1(text));
FXL.savedText = text;
if (inPack && FXL.src) await call("mdl:reload", FXL.src).catch(() => {});
toast(`Saved ${FXL.whereName}`);
} catch (err) { toast(err.message, true); }
};
$("fx-to-lab").onclick = () => {
const fx = window.FXE;
if (!fx?.text) return toast("Open an effect in the FX tab first.", true);
if (fx.src && !FXL.src) setSource(fx.src);
document.querySelector('[data-view="fxlab"]').click();
try { openScript(fx.text, fx.name || "effect.txt", fx.where || null); } catch (err) { toast(err.message, true); }
};
$("fxl-export-json").onclick = async () => {
const p = currentPreset();
try { const f = await call("fxlab:saveJson", `${safeName(p.name)}.json`, JSON.stringify(p, null, 2)); if (f) toast(`Saved ${f}`); }
catch (err) { toast(err.message, true); }
};
/** Files of the LS export: resource/effect/<name>.txt + resource/texture/EffectTexture/<tex>.dds (not for pack textures). */
function exportFiles(name) {
const files = [{ path: `resource/effect/${name}.txt`, b64: b64(latin1(toScript(FXL.preset))) }];
const done = new Set();
for (const L of FXL.preset.layers.filter((l) => l.on)) {
const t = texOf(L);
if (t.pack || done.has(t.file)) continue;
done.add(t.file);
const c = t.image ? imageCache.get(t.image) : sprite(t.shape);
if (!c) throw new Error(`Image for ${L.name} is still loading`);
const px = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
const dds = TexKit.encodeDds(new Uint8Array(px.buffer.slice(0)), c.width, c.height, { format: "DXT5", mips: true });
files.push({ path: `resource/texture/${t.path}`, b64: b64(dds.bytes) });
}
return files;
}
FXL.exportFiles = exportFiles;
FXL.exportTo = async (folder) => call("fxlab:writeFiles", folder, exportFiles(safeName($("fxl-name").value)));
$("fxl-export-ls").onclick = async () => {
if (!FXL.preset?.layers.some((l) => l.on)) return toast("Nothing to export: no visible layer.", true);
const folder = await call("dialog:folder", "Export folder (resource/effect + resource/texture are created inside)");
if (!folder) return;
try {
const written = await FXL.exportTo(folder);
toast(`Exported ${written.length} files. Add the resource folder to a pack with IOP Manager.`);
call("shell:show", written[0]).catch(() => {});
} catch (err) { toast(err.message, true); }
};
// ---------- sprite sheet (pixelfx-style: PNG grid + Aseprite JSON) ----------
let sheetView = null;
function ensureSheetView(w, h) {
if (!sheetView) {
const host = el("div", { class: "fxl-offscreen" });
const c = el("canvas", {});
host.append(c); document.body.append(host);
sheetView = new GL3D.View(c, { alpha: false });
sheetView.opts.lit = false;
sheetView.host = host;
}
sheetView.host.style.width = `${w / (devicePixelRatio || 1)}px`;
sheetView.host.style.height = `${h / (devicePixelRatio || 1)}px`;
sheetView.canvas.width = w; sheetView.canvas.height = h;
return sheetView;
}
async function renderSheet({ frames = 24, fps = 24, size = 256, cols = 6, transparent = true, prewarm = true } = {}) {
if (!ensureView()) throw new Error("No preview");
const sv = ensureSheetView(size, size);
sv.cam = { ...view.cam, target: view.cam.target.slice() };
sv.env = { clear: [0, 0, 0] };
const sheetKit = MeshKit.create(sv, () => FXL.src);
const def = Effects.parse(toScript(FXL.preset));
const names = [...new Set(def.particles.map((p) => (p.attrs.texture || "").split(/[\\/]/).pop()))];
const texMap = new Map();
for (const n of names) {
const custom = customTexture(n, sv);
if (custom) texMap.set(n, custom);
else if (FXL.src) { const t = await sheetKit.texture(n); if (t.tex) texMap.set(n, t.tex); }
}
const one = FXL.preset.layers.filter((l) => l.on).every((l) => l.duration > 0);
const it = Effects.instance(def, [0, 0, 0]);
it.heroPreview = true; // no auto restart while rendering
const stepMs = (ms) => { while (ms > 0) { const d = Math.min(16, ms); Effects.update(it, d); ms -= d; } };
stepMs(prewarm && !one ? 1500 : 1000 / fps);
const rows = Math.ceil(frames / cols);
const sheet = el("canvas", { width: cols * size, height: rows * size });
const ctx = sheet.getContext("2d");
const json = { frames: {}, meta: { app: "LS Tools FX Lab", version: "1", image: "", format: "RGBA8888", size: { w: sheet.width, h: sheet.height }, scale: "1", frameTags: [{ name: FXL.preset.name, from: 0, to: frames - 1, direction: "forward" }], fxlab: { fps, loop: !one, columns: cols, pivot: { x: 0.5, y: 0.5 } } } };
for (let f = 0; f < frames; f++) {
if (f) stepMs(1000 / fps);
const parts = Effects.build(it, sv.cameraAxes());
for (const p of parts) p.tex = texMap.get(p.texture) || null;
sv.setParticles(parts);
sv.draw();
const x = (f % cols) * size, y = Math.floor(f / cols) * size;
ctx.drawImage(sv.canvas, 0, 0, size, size, x, y, size, size);
json.frames[`${safeName(FXL.preset.name)} ${f}.png`] = { frame: { x, y, w: size, h: size }, rotated: false, trimmed: false, spriteSourceSize: { x: 0, y: 0, w: size, h: size }, sourceSize: { w: size, h: size }, duration: Math.round(1000 / fps) };
}
if (transparent) {
// rendered on black: brightness becomes alpha (exact for additive layers)
const img = ctx.getImageData(0, 0, sheet.width, sheet.height), d = img.data;
for (let i = 0; i < d.length; i += 4) {
const a = Math.max(d[i], d[i + 1], d[i + 2]);
if (a) { d[i] = Math.min(255, (d[i] * 255) / a); d[i + 1] = Math.min(255, (d[i + 1] * 255) / a); d[i + 2] = Math.min(255, (d[i + 2] * 255) / a); }
d[i + 3] = a;
}
ctx.putImageData(img, 0, 0);
}
return { canvas: sheet, json };
}
FXL.renderSheet = renderSheet;
$("fxl-export-sheet").onclick = async () => {
if (!FXL.preset) return;
const inputs = { frames: el("input", { type: "number", value: 24, min: 1, max: 120 }), fps: el("input", { type: "number", value: 24, min: 1, max: 60 }), size: el("select", {}, [64, 128, 192, 256, 384, 512].map((n) => el("option", { value: n, text: `${n} px`, selected: n === 256 }))), cols: el("input", { type: "number", value: 6, min: 1, max: 32 }), transparent: el("input", { type: "checkbox", checked: true }) };
const body = el("div", { class: "fxl-form" }, [
el("p", { class: "muted", text: "Renders the free preview from the current camera angle." }),
el("label", {}, ["Frames ", inputs.frames]), el("label", {}, ["FPS ", inputs.fps]), el("label", {}, ["Frame size ", inputs.size]),
el("label", {}, ["Columns ", inputs.cols]), el("label", {}, [inputs.transparent, " Transparent background (glow → alpha)"]),
]);
if (!(await modal("Sprite sheet", body, [{ label: "Cancel", value: false }, { label: "Render…", kind: "primary", value: true }]))) return;
const file = await call("dialog:savePng", `${safeName($("fxl-name").value)}_sheet.png`);
if (!file) return;
try {
const { canvas, json } = await renderSheet({ frames: Number(inputs.frames.value) || 24, fps: Number(inputs.fps.value) || 24, size: Number(inputs.size.value) || 256, cols: Number(inputs.cols.value) || 6, transparent: inputs.transparent.checked });
json.meta.image = file.split(/[\\/]/).pop();
await call("file:writeBase64", file, canvas.toDataURL("image/png").split(",")[1]);
await call("file:writeBase64", file.replace(/\.png$/i, "") + ".json", btoa(unescape(encodeURIComponent(JSON.stringify(json, null, 2)))));
toast(`Saved ${json.meta.image} + .json`);
} catch (err) { toast(err.message, true); }
};
// ---------- source ----------
async function chooseSource() {
const options = [...profileSourceOption(), { value: "packs", title: "Folder with .iop packs…", sub: "Game client packs" }, { value: "dir", title: "Extracted resource folder…", sub: "Folder containing resource/" }];
let choice = options[0]?.value;
const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [
el("input", { type: "radio", name: "fxlsrc", checked: i === 0, onchange: () => { choice = o.value; } }),
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
])));
if (!(await modal("FX Lab source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return;
if (choice === "packs" || choice === "dir") {
const folder = await call("dialog:folder", choice === "packs" ? "Folder with .iop packs" : "Extracted resources");
if (!folder) return;
choice = { kind: choice, folder };
}
await setSource(choice);
}
async function setSource(src) {
try {
const d = await call("mdl:describe", src);
FXL.src = src; animNames = null; packTex.clear(); kit?.clearTextures?.();
$("fxl-src-label").textContent = src.kind === "dir" ? "Extracted folder" : "Packs";
$("fxl-src").title = d.label;
rebuild();
} catch (err) { toast(err.message, true); }
}
FXL.setSource = setSource;
FXL.setMode = setMode;
FXL.load = load;
FXL.builtin = BUILTIN;
$("fxl-src").onclick = chooseSource;
document.querySelector('[data-view="fxlab"]').addEventListener("click", () => {
if (!FXL.src && profileSource()) setSource(profileSource());
if (FXL.mode === "free") { ensureView(); view?.resize(); restart(); }
else setMode(FXL.mode); // re-borrow the hero viewport (the Hero tab may have taken it back)
});
load(BUILTIN[0]);
loadUser();
})();