"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/.txt + resource/texture/EffectTexture/.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(); })();