Files

548 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// 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);
};
})();