"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); }; })();