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