LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

+547
View File
@@ -0,0 +1,547 @@
"use strict";
// The script is the source of truth. Property edits replace only their own line so comments,
// unknown keys, model/light blocks, and the original byte encoding survive a round trip.
(() => {
const FXE = { src: null, rows: [], where: null, name: "", text: "", saved: "", selected: null, playing: true };
window.FXE = FXE;
let view = null, kit = null, inst = null, last = 0, loadSeq = 0, modelLoadSeq = 0;
const textures = new Map();
let modelViews = [];
const bytesToRaw = (bytes) => {
let out = "";
for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192));
return out;
};
const rawToBytes = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255);
const ending = () => FXE.text.includes("\r\n") ? "\r\n" : FXE.text.includes("\r") ? "\r" : "\n";
const field = (key, label, type = "text", options = []) => ({ key, label, type, options });
const commonSystem = [
field("quota", "Max particles", "number"), field("cull_each", "Cull each", "bool"),
field("follow_parent", "Follow parent"), field("end_condition", "End condition", "select", ["never_end", "time_limit"]),
field("limit_time", "End time (ms)", "number"), field("start_create_particles", "Start count", "number"),
field("link_biped_name", "Linked bone"), field("random_sync_index", "Random sync", "number"),
];
const schema = {
info: [
["General", field("direction", "Direction", "vec3"), field("collision_box", "Collision box", "text"),
field("skeleton_name", "Skeleton"), field("animation_name", "Animation"), field("update_always", "Update always", "bool")],
],
particle: [
["System", ...commonSystem],
["Appearance", field("texture", "Texture"), field("default_width", "Width min / max", "vec2"),
field("default_height", "Height min / max", "vec2"), field("render_type", "Render type", "select", ["add", "src_alpha", "screen", "lighten", "darken", "subtract", "rev_subtract"]),
field("tex_filtering", "Texture filter", "select", ["bilinear", "point", "trilinear"]), field("depth_check", "Depth check", "bool"),
field("modulate2x", "Modulate ×2", "bool"), field("billboard_type", "Billboard", "select", ["point", "common", "direction"]),
field("common_dir", "Common direction", "vec3"), field("common_dir_none_billboard", "Fixed direction", "bool")],
["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")],
],
model: [
["Model", field("mesh_file", "Mesh file"), ...commonSystem],
["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")],
],
emitter: [
["Emission", field("emit_pos", "Position", "vec3"), field("emit_dir", "Direction", "vec3"),
field("emit_angle", "Angle", "number"), field("emit_rate", "Per second", "number"),
field("emit_speed", "Speed min / max", "vec2"), field("emit_live_time", "Life min / max", "vec2"),
field("emit_wait_time", "Wait (ms)", "number"), field("emit_start_tex_rotation", "Texture rotation", "vec2"),
field("start_rot_axis", "Start rotation axis", "vec3"), field("start_rot_angle", "Start rotation angle", "vec2"),
field("area_size", "Area size", "vec3"), field("inner_size", "Inner size", "vec3")],
["Curves", field("color_rate", "Colour curve"), field("alpha_rate", "Alpha curve")],
],
light: [
["Light", field("type", "Type", "select", ["point", "spot", "directional"]),
field("position", "Position", "vec3"), field("targetpos", "Target", "vec3"), field("direction", "Direction", "vec3"),
field("diffuse", "Diffuse", "vec4"), field("specular", "Specular", "vec4"), field("attenuation", "Attenuation", "vec3"),
field("spot_theta", "Spot inner", "number"), field("spot_phi", "Spot outer", "number"),
field("spot_falloff", "Falloff", "number"), field("light_start", "Start", "number"), field("light_end", "End", "number"),
field("light_texture", "Light texture"), field("decal_texture", "Decal texture")],
],
};
const affectorSchema = {
linear: [field("force_vector", "Force", "vec3"), field("force_method", "Method"), field("force_rate", "Force curve")],
scale: [field("scale_rate", "Scale curve")],
texrotation: [field("rot_range", "Rotation range", "vec2"), field("rot_speed", "Speed min / max", "vec2")],
rotation: [field("rot_axis", "Axis", "vec3"), field("rot_speed", "Speed min / max", "vec2")],
emit: [field("power", "Power", "number"), field("force_rate", "Force curve"), field("change_direction", "Change direction", "bool")],
};
function tree(text) {
const lines = text.split(/\r\n|\n|\r/), roots = [], stack = [];
let header = -1;
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 === "{" ? lines[header]?.trim() || "" : t.slice(0, -1).trim();
const [kind = "", ...rest] = h.split(/\s+/);
const node = { kind: kind.toLowerCase(), type: rest.join(" "), start: t === "{" ? header : i, open: i, end: -1, children: [], parent: stack.at(-1) || null };
if (node.parent) node.parent.children.push(node); else roots.push(node);
stack.push(node);
} else if (t === "}") {
if (!stack.length) throw new Error(`Unexpected } at line ${i + 1}`);
stack.pop().end = i;
} else if (!stack.length || /^(info|particle|model|light|emitter|affector)\b/i.test(t)) header = i;
}
if (stack.length) throw new Error(`Missing } for ${stack.at(-1).kind}`);
return { lines, roots };
}
const flatten = (nodes) => nodes.flatMap((n) => [n, ...flatten(n.children)]);
const nodeAt = (parsed, start) => flatten(parsed.roots).find((n) => n.start === start);
function attrs(parsed, node) {
const nested = node.children.map((n) => [n.start, n.end]);
const out = [];
for (let i = node.open + 1; i < node.end; i++) {
if (nested.some(([a, b]) => i >= a && i <= b)) continue;
const m = /^\s*([\w-]+)(?:\s+(.*))?$/.exec(parsed.lines[i]);
if (m && !m[1].startsWith("//")) out.push({ line: i, key: m[1], value: m[2] || "" });
}
return out;
}
function setText(text, selection = FXE.selected) {
FXE.text = text;
if ($("fx-script").value !== text) $("fx-script").value = text;
let parsed;
try {
parsed = tree(text);
$("fx-parse-status").textContent = `${parsed.roots.length} blocks`;
} catch (err) {
$("fx-parse-status").textContent = err.message;
parsed = null;
}
FXE.parsed = parsed;
FXE.selected = parsed && nodeAt(parsed, selection) ? selection : parsed?.roots[0]?.start ?? null;
renderHierarchy();
renderProperties();
refreshPreview();
const dirty = text !== FXE.saved;
$("fx-dirty").hidden = !dirty;
$("fx-save").disabled = !dirty;
$("fx-save-as").disabled = !text;
$("fx-title").textContent = FXE.name || "Untitled";
}
function renderHierarchy() {
const box = $("fx-hierarchy");
if (!FXE.parsed) return box.replaceChildren(el("small", { class: "muted", text: "Fix the script braces to inspect blocks." }));
const rows = [];
const totals = { particle: 0, model: 0 };
const walk = (nodes, depth) => nodes.forEach((n) => {
const label = n.kind === "particle" || n.kind === "model" ? `${n.kind === "model" ? "Model" : "Particle"} ${totals[n.kind]++}` : n.kind === "emitter" || n.kind === "affector" ? `${n.kind} ${n.type}` : n.kind;
rows.push(el("button", { class: `fx-node${FXE.selected === n.start ? " on" : ""}`, style: `padding-left:${8 + depth * 16}px`, text: label, onclick: () => { FXE.selected = n.start; renderHierarchy(); renderProperties(); } }));
walk(n.children, depth + 1);
});
walk(FXE.parsed.roots, 0);
box.replaceChildren(...rows);
}
function setField(node, key, value) {
const parsed = tree(FXE.text), current = nodeAt(parsed, node.start);
if (!current) return;
const lines = parsed.lines;
const a = attrs(parsed, current).find((x) => x.key.toLowerCase() === key.toLowerCase());
if (a) {
const indent = /^\s*/.exec(lines[a.line])[0];
lines[a.line] = `${indent}${a.key}${value ? ` ${value}` : ""}`;
} else {
const first = attrs(parsed, current)[0];
const indent = first ? /^\s*/.exec(lines[first.line])[0] : /^\s*/.exec(lines[current.open])[0] + "\t";
const at = current.children[0]?.start ?? current.end;
lines.splice(at, 0, `${indent}${key}${value ? ` ${value}` : ""}`);
}
setText(lines.join(ending()), current.start);
}
function setBlockType(node, type) {
const parsed = tree(FXE.text), lines = parsed.lines;
const line = lines[node.start];
lines[node.start] = line.replace(/^(\s*(?:emitter|affector)\s+)\S+/i, `$1${type}`);
setText(lines.join(ending()), node.start);
}
async function browseResource(node, key, current) {
if (!FXE.src) return toast("Choose an effect source first.", true);
const mesh = key === "mesh_file";
let names;
try { names = await call("mdl:names", FXE.src, mesh ? "mesh" : "texture"); }
catch (err) { return toast(err.message, true); }
const list = el("div", { class: "fx-resource-list" });
const search = el("input", { type: "search", placeholder: mesh ? "Search meshes" : "Search textures", spellcheck: "false" });
let chosen = current;
const shown = el("small", { class: "muted" });
const render = () => {
const q = search.value.trim().toLowerCase();
const matches = names.filter((n) => n.toLowerCase().includes(q));
shown.textContent = `${matches.length.toLocaleString()} matches · showing first 100`;
list.replaceChildren(...matches.slice(0, 100).map((name) => {
const value = mesh ? name.replace(/\.msh$/i, "") : name;
return el("button", { class: `fx-resource${value.toLowerCase() === chosen.toLowerCase() ? " on" : ""}`, text: name,
onclick: () => { chosen = value; render(); } });
}));
};
search.oninput = render;
render();
const ok = await modal(mesh ? "Choose effect mesh" : "Choose effect texture", [search, shown, list],
[{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]);
if (ok && chosen) setField(node, key, chosen);
}
function control(node, spec, value) {
if (/^vec[234]$/.test(spec.type)) {
const size = Number(spec.type.slice(-1)), parts = value.trim().split(/\s+/).filter(Boolean);
const wrap = el("div", { class: "fx-vector" });
const inputs = Array.from({ length: size }, (_, i) => el("input", {
type: "number", step: "any", value: parts[i] || "", placeholder: ["X", "Y", "Z", "W"][i],
onchange: () => setField(node, spec.key, inputs.map((x) => x.value || "0").join(" ")),
}));
wrap.replaceChildren(...inputs);
return wrap;
}
if (spec.type === "bool" || spec.type === "select") {
const options = spec.type === "bool" ? ["true", "false"] : spec.options;
const values = value && !options.some((x) => x.toLowerCase() === value.toLowerCase()) ? [value, ...options] : options;
const select = el("select", { onchange: (e) => setField(node, spec.key, e.target.value) }, [
el("option", { value: "", text: "—" }), ...values.map((x) => el("option", { value: x, text: x })),
]);
select.value = values.find((x) => x.toLowerCase() === value.toLowerCase()) || "";
return select;
}
const input = el("input", { type: spec.type === "number" ? "number" : "text", step: spec.type === "number" ? "any" : undefined,
value, spellcheck: "false", onchange: (e) => setField(node, spec.key, e.target.value.trim()) });
if (["mesh_file", "texture", "light_texture", "decal_texture"].includes(spec.key)) {
return el("div", { class: "fx-resource-field" }, [input, el("button", { class: "iconbtn only", title: "Browse resources", text: "…", onclick: () => browseResource(node, spec.key, input.value.trim()) })]);
}
return input;
}
function renderProperties() {
const node = FXE.parsed && nodeAt(FXE.parsed, FXE.selected);
$("fx-selection").textContent = node ? `${node.kind} ${node.type}` : "";
$("fx-add-field").disabled = !node;
const system = node?.kind === "particle" || node?.kind === "model" ? node : node?.parent;
const hasSystem = system?.kind === "particle" || system?.kind === "model";
$("fx-add-emitter").disabled = !hasSystem || system.children.some((x) => x.kind === "emitter");
$("fx-add-affector").disabled = !hasSystem;
$("fx-duplicate-block").disabled = !node || !["particle", "model"].includes(node.kind);
const siblings = node && FXE.parsed ? FXE.parsed.roots.filter((x) => x.kind === node.kind) : [];
$("fx-move-up").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) <= 0;
$("fx-move-down").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) >= siblings.length - 1;
$("fx-delete-block").disabled = !node || node.kind === "info";
const box = $("fx-properties");
if (!node) return box.replaceChildren(el("span", { class: "muted", text: "Select a block." }));
const groups = node.kind === "affector" ? [[`${node.type || "Affector"} settings`, field("wait_time", "Wait (ms)", "number"), ...(affectorSchema[node.type.toLowerCase()] || [])]] : schema[node.kind] || [];
const fields = [], existing = attrs(FXE.parsed, node), shown = new Set();
if (node.kind === "emitter" || node.kind === "affector") {
fields.push(el("label", { text: "Type" }));
const options = node.kind === "emitter" ? ["Point", "Box", "Cylinder", "Ellipse", "Ellipsoid", "HollowEllipsoid"] : ["Linear", "Scale", "TexRotation", "Rotation", "Emit"];
const values = options.some((x) => x.toLowerCase() === node.type.toLowerCase()) ? options : [node.type, ...options];
const select = el("select", { onchange: (e) => setBlockType(node, e.target.value) }, values.map((x) => el("option", { value: x, text: x })));
select.value = values.find((x) => x.toLowerCase() === node.type.toLowerCase());
fields.push(select);
}
for (const group of groups) {
fields.push(el("div", { class: "fx-group", text: group[0] }));
for (const spec of group.slice(1)) {
const a = existing.find((x) => x.key.toLowerCase() === spec.key);
shown.add(spec.key);
fields.push(el("label", { text: spec.label, title: spec.key }));
fields.push(control(node, spec, a?.value || ""));
}
}
const other = existing.filter((a) => !shown.has(a.key.toLowerCase()));
if (other.length) fields.push(el("div", { class: "fx-group", text: "Other fields" }));
for (const a of other) {
fields.push(el("label", { text: a.key, title: a.key }));
fields.push(control(node, field(a.key, a.key), a.value));
}
box.replaceChildren(...fields);
}
function mutateLines(fn, selected = FXE.selected) {
if (!FXE.parsed) return;
const lines = FXE.parsed.lines.slice();
fn(lines);
setText(lines.join(ending()), selected);
}
function ensureView() {
if (view) return view;
try { view = new GL3D.View($("fx-canvas"), { alpha: false }); }
catch (err) { toast(`Effect preview unavailable: ${err.message}`, true); return null; }
// ioEffectEdit renders directly to an opaque back buffer. Additive DDS
// textures carry black RGB around their glow; SRC_ALPHA + ONE leaves that
// black unchanged on the gray target without changing the texture data.
view.env = { clear: [94 / 255, 94 / 255, 94 / 255] };
kit = MeshKit.create(view, () => FXE.src);
view.opts.lit = false;
view.frame([-180, -40, -180], [180, 220, 180]);
drawGrid();
return view;
}
function drawGrid() {
if (!view) return;
if (!$("fx-grid").classList.contains("on")) return view.setOverlay("fx-grid", null);
const a = [];
const 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("fx-grid", new Float32Array(a));
}
function clearModels() {
modelLoadSeq++;
for (const entry of modelViews) for (const id of entry?.slots || []) view?.removeMesh(id);
modelViews = [];
}
async function loadModel(index, spec, seq) {
const name = (spec.attrs.mesh_file || "").trim().replace(/\.msh$/i, "");
if (!name || name.toLowerCase() === "none" || !FXE.src) return;
try {
const hit = await kit.readNamed(`${name}.msh`, "mesh", true);
if (!hit || seq !== modelLoadSeq) return;
const mesh = await kit.loadMesh(hit, name);
if (seq !== modelLoadSeq) return view.removeMesh(mesh.id);
view.setVisible(mesh.id, false);
modelViews[index] = { mesh, slots: [mesh.id] };
await kit.loadTextures(mesh);
if (seq !== modelLoadSeq) return view.removeMesh(mesh.id);
syncModels();
} catch (err) { if (seq === modelLoadSeq) console.warn(`Effect model ${name}: ${err.message}`); }
}
function syncModels() {
if (!view || !inst) return;
inst.modelSystems.forEach((system, index) => {
const entry = modelViews[index];
if (!entry) return;
kit.animate(entry.mesh, system.time / 1000);
system.parts.forEach((part, i) => {
let id = entry.slots[i];
if (!id) {
id = `fx-model-${modelLoadSeq}-${index}-${i}`;
const m = entry.mesh;
view.addMesh(id, { pos: m.data.pos, normal: m.data.normal, uv0: m.data.uv0, uv1: m.data.uv1, indices: m.data.indices, groups: m.groups });
entry.slots[i] = id;
}
view.setTransformQuaternion(id, part.pos, part.modelScale, part.modelRotation);
const t = Math.min(1, part.age / part.life), color = system.color(t);
view.setTint(id, [color[0] / 255, color[1] / 255, color[2] / 255, Math.max(0, Math.min(1, system.alpha(t)[0]))]);
view.setVisible(id, true);
});
entry.slots.forEach((id, i) => { if (i >= system.parts.length) view.setVisible(id, false); });
});
}
function refreshPreview() {
inst = null;
clearModels();
if (!FXE.text || !ensureView()) return;
try { inst = Effects.instance(Effects.parse(FXE.text), [0, 0, 0]); }
catch (err) { $("fx-parse-status").textContent = err.message; }
view.setParticles([]);
if (inst) {
const seq = modelLoadSeq;
inst.def.models.forEach((spec, index) => loadModel(index, spec, seq));
}
// Loaded textures stay attached to this WebGL context across script edits.
}
function texture(name) {
if (!name || !FXE.src || !view) return null;
const key = name.toLowerCase();
if (!textures.has(key)) {
textures.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);
textures.set(key, t.tex);
});
}
return textures.get(key);
}
async function tick(now) {
const dt = Math.min(100, Math.max(0, now - (last || now)));
last = now;
if (view && inst && FXE.playing && $("view-fx").classList.contains("on")) {
Effects.update(inst, dt);
syncModels();
const parts = Effects.build(inst, view.cameraAxes());
for (const p of parts) p.tex = texture(p.texture);
view.setParticles(parts);
$("fx-stats").textContent = `${Effects.count(inst)} particles · ${inst.systems.length} particle systems · ${inst.modelSystems.length} model systems`;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
const list = new VTable($("fx-list"), [{ key: 1, label: "Effect", width: 220, grow: true, title: true }, { key: 2, label: "Pack", width: 75, cls: "mono dim" }], { multi: false, id: "fx" });
list.keyOf = (r) => r[0];
list.sortBy(1);
list.onOpen = (r) => openHit(() => call("mdl:readKey", FXE.src, r[0]), r[1]);
function filterList() {
const q = $("fx-search").value.trim().toLowerCase();
const rows = FXE.rows.filter((r) => r[1].toLowerCase().includes(q));
list.setRows(rows);
$("fx-count").textContent = rows.length.toLocaleString();
}
$("fx-search").oninput = filterList;
async function loadList() {
if (!FXE.src) return;
try {
const entries = await call("fx:list", FXE.src);
FXE.rows = entries.filter(([, path]) => /(^|\/)resource\/effect\/.+\.txt$/i.test(path))
.map(([key, path, pack]) => [key, path.replace(/^.*?resource\/effect\//i, ""), pack || "", path]);
filterList();
} catch (err) { toast(err.message, true); }
}
async function chooseSource() {
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
const options = [...profileSourceOption(), ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file })),
{ value: "packs", title: "Folder with .iop packs…", sub: "Game client packs" }, { value: "dir", title: "Extracted resource folder…", sub: "Folder containing resource/effect" }];
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: "fxsrc", checked: i === 0, onchange: () => { choice = o.value; } }),
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
])));
if (!(await modal("Effect 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 };
}
try {
const d = await call("mdl:describe", choice);
FXE.src = choice;
$("fx-src-label").textContent = choice.kind === "dir" ? "Extracted folder" : "Packs";
$("fx-src").title = d.label;
kit?.clearTextures(); textures.clear(); refreshPreview();
await loadList();
} catch (err) { toast(err.message, true); }
}
$("fx-src").onclick = chooseSource;
async function openHit(read, name) {
if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return;
const seq = ++loadSeq;
try {
const hit = await read();
if (!hit || seq !== loadSeq) return;
FXE.where = hit.where; FXE.name = name || hit.source; FXE.saved = bytesToRaw(hit.bytes);
FXE.selected = null; setText(FXE.saved);
$("fx-empty").hidden = true;
view?.resize();
} catch (err) { toast(err.message, true); }
}
$("fx-open-file").onclick = async () => {
const file = await call("fx:openFile");
if (file) await openHit(() => call("mdl:readFile", file), file.replace(/^.*[\\/]/, ""));
};
$("fx-new").onclick = async () => {
if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return;
FXE.where = null; FXE.name = "new_effect.txt"; FXE.saved = ""; FXE.selected = null;
setText("info\r\n{\r\n}\r\n\r\nparticle\r\n{\r\nquota 100\r\ndefault_width 50 50\r\ndefault_height 50 50\r\nrender_type add\r\ntexture effect.dds\r\nemitter point\r\n{\r\nemit_rate 20\r\nemit_speed 40 80\r\nemit_live_time 1000 1500\r\n}\r\n}\r\n");
$("fx-empty").hidden = true;
view?.resize();
};
$("fx-script").oninput = (e) => setText(e.target.value);
$("fx-add-particle").onclick = () => {
const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/);
const at = lines.length;
lines.push("particle", "{", "quota 100", "default_width 50 50", "default_height 50 50", "render_type add", "emitter point", "{", "emit_rate 20", "emit_speed 40 80", "emit_live_time 1000 1500", "}", "}");
setText(lines.join(ending()), at);
};
$("fx-add-model").onclick = () => {
const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/);
const at = lines.length;
lines.push("model", "{", "mesh_file none", "quota 20", "cull_each false", "follow_parent none", "end_condition never_end", "limit_time 1000", "start_create_particles 1", "link_biped_name none", "random_sync_index -1", "pos_offset 0 0 0", "rot_offset 0 0 0 1", "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 1", "emit_speed 0 0", "emit_live_time 1000 1000", "}", "}");
setText(lines.join(ending()), at);
};
$("fx-add-emitter").onclick = () => {
const selected = nodeAt(FXE.parsed, FXE.selected);
const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent;
if (!system || !["particle", "model"].includes(system.kind) || system.children.some((x) => x.kind === "emitter")) return;
mutateLines((lines) => lines.splice(system.end, 0, "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 10", "emit_speed 0 0", "emit_live_time 1000 1000", "}"), system.start);
};
$("fx-add-affector").onclick = async () => {
const selected = nodeAt(FXE.parsed, FXE.selected);
const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent;
if (!system || !["particle", "model"].includes(system.kind)) return;
const type = await prompt("Add affector", "Type (Linear, Scale, TexRotation, Rotation, Emit)", "Linear");
if (!type || !/^[a-z][\w-]*$/i.test(type)) return;
mutateLines((lines) => lines.splice(system.end, 0, `affector ${type}`, "{", "wait_time 0", "}"), system.start);
};
$("fx-duplicate-block").onclick = () => {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node || !["particle", "model"].includes(node.kind)) return;
const lines = FXE.parsed.lines.slice(), block = lines.slice(node.start, node.end + 1);
lines.splice(node.end + 1, 0, "", ...block);
setText(lines.join(ending()), node.end + 2);
};
function moveBlock(direction) {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node || !["particle", "model"].includes(node.kind)) return;
const siblings = FXE.parsed.roots.filter((x) => x.kind === node.kind);
const other = siblings[siblings.indexOf(node) + direction];
if (!other) return;
const [a, b] = node.start < other.start ? [node, other] : [other, node];
const lines = FXE.parsed.lines.slice();
const first = lines.slice(a.start, a.end + 1), gap = lines.slice(a.end + 1, b.start), second = lines.slice(b.start, b.end + 1);
lines.splice(a.start, b.end - a.start + 1, ...second, ...gap, ...first);
const selected = node === a ? a.start + second.length + gap.length : a.start;
setText(lines.join(ending()), selected);
}
$("fx-move-up").onclick = () => moveBlock(-1);
$("fx-move-down").onclick = () => moveBlock(1);
$("fx-delete-block").onclick = async () => {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node || node.kind === "info") return;
if (!(await confirmBox("Remove block", `Remove ${node.kind} ${node.type}?`, "Remove", "danger"))) return;
mutateLines((lines) => lines.splice(node.start, node.end - node.start + 1), node.parent?.start ?? null);
};
$("fx-add-field").onclick = async () => {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node) return;
const key = await prompt("Add field", "Script key (for example: texture)", "");
if (!key || !/^[a-z][\w-]*$/i.test(key)) return;
mutateLines((lines) => lines.splice(node.end, 0, `${key} `), node.start);
};
$("fx-play").onclick = () => {
FXE.playing = !FXE.playing;
$("fx-play").classList.toggle("on", FXE.playing);
if (!FXE.playing) view?.setParticles([]);
};
$("fx-restart").onclick = refreshPreview;
$("fx-grid").onclick = () => { $("fx-grid").classList.toggle("on"); drawGrid(); };
$("fx-reset").onclick = () => view?.reset();
document.querySelector('[data-view="fx"]').addEventListener("click", () => {
ensureView();
view?.resize();
if (!FXE.src) { const src = profileSource(); if (src) { FXE.src = src; $("fx-src-label").textContent = "Profile packs"; loadList(); } }
});
async function save(as = false) {
if (!FXE.text) return;
if ([...FXE.text].some((c) => c.charCodeAt(0) > 255)) return toast("This script contains characters outside its original 8-bit encoding. Use ASCII for new fields.", true);
let where = FXE.where;
if (as || !where) {
const file = await call("dialog:saveAs", FXE.name || "new_effect.txt", "txt");
if (!file) return;
where = { kind: "file", file };
}
try {
await call("mdl:writeBack", where, rawToBytes(FXE.text));
FXE.where = where; FXE.saved = FXE.text;
if (where.kind === "file") FXE.name = where.file.replace(/^.*[\\/]/, "");
$("fx-title").textContent = FXE.name;
$("fx-dirty").hidden = true; $("fx-save").disabled = true;
toast(`Saved ${FXE.name}`);
if (FXE.src) { await call("mdl:reload", FXE.src); await loadList(); }
} catch (err) { toast(err.message, true); }
}
$("fx-save").onclick = () => save();
$("fx-save-as").onclick = () => save(true);
document.addEventListener("keydown", (e) => {
if (!$("view-fx").classList.contains("on") || !e.ctrlKey || e.key.toLowerCase() !== "s") return;
e.preventDefault(); save();
});
FXE.previewAsset = async (src, name) => {
const changed = FXE.src?.kind !== src.kind || FXE.src?.folder !== src.folder || FXE.src?.sessionId !== src.sessionId;
FXE.src = src;
$("fx-src-label").textContent = "Hero source";
document.querySelector('[data-view="fx"]').click();
if (changed) await loadList();
await openHit(() => call("mdl:read", src, name, "effect", true), name);
};
})();