1118 lines
72 KiB
JavaScript
1118 lines
72 KiB
JavaScript
"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();
|
||
})();
|