"use strict"; // Effect scripts (resource/effect/*.txt) and their particle systems, following io3DEngine // (ioEffectFactory::ParseScript, ioParticleSystem::Update, ioParticleEmitter, the emit point // generators and affectors). Used by the map editor to play the map's effects. // // Units: times in ms, speeds / forces per second, angles in degrees (TexRotation speed: rad/s). // Everything is simulated in game space (map effects have no rotation or scale). const Effects = (() => { // ---------- parser ---------- /** → { info, particles, models } with editable attributes and nested emitters/affectors. */ function parse(text) { const lines = text.split(/\r\n|\r|\n/).map((l) => l.replace(/\s+$/, "").replace(/^\s+/, "")); let i = 0; const out = { info: {}, particles: [], models: [], light: false }; const skipToBrace = () => { while (i < lines.length && !lines[i].startsWith("{")) i++; i++; }; const readBlock = (onNested) => { const attrs = {}; while (i < lines.length) { const l = lines[i]; if (!l || l.startsWith("//")) { i++; continue; } if (l.startsWith("}")) { i++; break; } const sp = l.search(/\s/); const key = (sp < 0 ? l : l.slice(0, sp)).toLowerCase(); const rest = sp < 0 ? "" : l.slice(sp + 1).trim(); if (onNested && (key === "emitter" || key === "affector")) { i++; skipToBrace(); onNested(key, rest, readBlock(null)); continue; } attrs[key] = rest; i++; } return attrs; }; while (i < lines.length) { const l = lines[i]; if (!l || l.startsWith("//") || l.startsWith("{") || l.startsWith("}")) { i++; continue; } const tag = l.split(/\s/)[0].toLowerCase(); i++; skipToBrace(); if (tag === "particle" || tag === "model") { const ps = { emitter: null, affectors: [] }; ps.attrs = readBlock((kind, type, attrs) => { if (kind === "emitter") ps.emitter = { type: type.toLowerCase(), attrs }; else ps.affectors.push({ type: type.toLowerCase(), attrs }); }); out[tag === "model" ? "models" : "particles"].push(ps); } else { const attrs = readBlock(null); if (tag === "info") out.info = attrs; else if (tag === "light") out.light = true; } } return out; } // ---------- small helpers ---------- const nums = (s) => (s || "").trim().split(/\s+/).filter(Boolean).map(Number); const pair = (s, d) => { const n = nums(s); return n.length >= 2 ? n : n.length === 1 ? [n[0], n[0]] : d; }; const vec = (s, d) => { const n = nums(s); return n.length >= 3 ? n.slice(0, 3) : d; }; const rnd = (a, b) => a + Math.random() * (b - a); const norm = (v) => { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }; const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const quatMul = (a, b) => [ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], ]; const quatRotate = (q, v) => { const u = q.slice(0, 3), w = q[3]; const t = cross(u, v).map((n) => n * 2); const c = cross(u, t); return [v[0] + w * t[0] + c[0], v[1] + w * t[1] + c[1], v[2] + w * t[2] + c[2]]; }; /** Keyed curve "t v1 v2 …" (k values per key) → sampler(t01) → [v…] (linear, clamped) */ function curve(s, k, dflt) { const n = nums(s); const keys = []; for (let j = 0; j + k < n.length + 1; j += k + 1) { const vals = n.slice(j + 1, j + 1 + k); while (vals.length < k) vals.push(0); keys.push([n[j], vals]); } if (!keys.length) keys.push([0, dflt]); return (t) => { let i = 0; while (i < keys.length && keys[i][0] <= t) i++; if (i === 0) return keys[0][1]; if (i === keys.length) return keys[keys.length - 1][1]; const [t0, a] = keys[i - 1], [t1, b] = keys[i]; const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0; return a.map((v, j) => v + (b[j] - v) * f); }; } // ---------- one particle system ---------- function makeSystem(ps, model = false) { const a = ps.attrs; const e = ps.emitter?.attrs || {}; const type = ps.emitter?.type || "point"; const sys = { model, mesh: model ? (a.mesh_file || "").trim() : "", quota: Number(a.quota) || 0, width: pair(a.default_width, [100, 100]), height: pair(a.default_height, [100, 100]), render: (a.render_type || "add").toLowerCase(), depth: a.depth_check !== "false", texture: (a.texture || "").split(/[\\/]/).pop(), linkBone: (a.link_biped_name || "").trim(), linkOffset: vec(a.pos_offset, [0, 0, 0]), followParent: (a.follow_parent || "none").toLowerCase(), linkRotation: nums(a.rot_offset).length === 4 ? nums(a.rot_offset) : [0, 0, 0, 1], billboard: (a.billboard_type || "point").toLowerCase(), endLimit: (a.end_condition || "").toLowerCase() === "time_limit", limit: Number(a.limit_time) || 0, commonDir: norm(vec(a.common_dir, [0, 1, 0])), commonFixed: a.common_dir_none_billboard === "true", interval: Number(a.iteration_interval) || 0, burst: Number(a.start_create_particles) || 0, // In the standalone editor the owner/bone offset is not applied to an // unattached model. The emitter position is its visible local origin. offset: model ? [0, 0, 0] : vec(a.pos_offset, [0, 0, 0]), rotOffset: model ? nums(a.rot_offset) : [], mod2x: a.modulate2x === "true", // emitter shape: type, emitPos: vec(e.emit_pos, [0, 0, 0]), emitDir: vec(e.emit_dir, [0, 1, 0]), angle: ((Number(e.emit_angle) || 0) * Math.PI) / 180, rate: e.emit_rate !== undefined ? Number(e.emit_rate) : 10, speed: pair(e.emit_speed, [1, 1]), life: pair(e.emit_live_time, [5000, 5000]), wait: Number(e.emit_wait_time) || 0, texRot: pair(e.emit_start_tex_rotation, [0, 0]).map((d) => (d * Math.PI) / 180), startRotAxis: norm(vec(e.start_rot_axis, [0, 1, 0])), startRotAngle: pair(e.start_rot_angle, [0, 0]), area: vec(e.area_size, [100, 100, 100]), inner: nums(e.inner_size), color: curve(e.color_rate, 3, [255, 255, 255]), alpha: curve(e.alpha_rate, 1, [1]), affectors: ps.affectors.map((f) => ({ type: f.type, a: f.attrs, wait: Number(f.attrs.wait_time) || 0 })), }; for (const f of sys.affectors) { if (f.type === "linear") { f.force = vec(f.a.force_vector, [0, -100, 0]); f.rate = curve(f.a.force_rate, 1, [1]); } else if (f.type === "scale") f.scale = curve(f.a.scale_rate, model ? 3 : 2, model ? [1, 1, 1] : [1, 1]); else if (f.type === "texrotation") { f.range = pair(f.a.rot_range, [0, 0]).map((d) => (d * Math.PI) / 180); f.speed = pair(f.a.rot_speed, [0, 0]); // rad/s as the client uses it } else if (f.type === "emit") { f.power = Number(f.a.power) || 0; f.rate = curve(f.a.force_rate, 1, [1]); } } // emitter local axes (GenAreaAxis) const d = norm(sys.emitDir); let up = Math.abs(dot(d, [1, 0, 0])) > 0.99 ? [0, -1, 0] : [1, 0, 0]; const side = cross(up, d); up = cross(d, side); sys.axes = { d, up, side }; sys.parts = []; sys.time = 0; sys.acc = 0; sys.started = false; sys.stopped = false; // time_limit reached (no more emission) sys.ended = false; return sys; } function spawnPoint(s) { const { d, up, side } = s.axes; const [sx, sy, sz] = s.area; const RX = up.map((v) => (v * sx) / 2), RY = d.map((v) => (v * sy) / 2), RZ = side.map((v) => (v * sz) / 2); const c = s.emitPos; const at = (a, b, cc) => [c[0] + a * RX[0] + b * RY[0] + cc * RZ[0], c[1] + a * RX[1] + b * RY[1] + cc * RZ[1], c[2] + a * RX[2] + b * RY[2] + cc * RZ[2]]; const r = () => Math.random() * 2 - 1; switch (s.shape) { case "box": return at(r(), r(), r()); case "cylinder": { let x = r(), z = r(); const l = Math.hypot(x, z); if (l > 1) (x /= l), (z /= l); return at(x, r(), z); } case "ellipse": { const ang = Math.random() * Math.PI * 2; const ix = s.inner[0] ?? 0.5, iz = s.inner[1] ?? 0.5; return at(ix * Math.sin(ang), Math.random(), iz * Math.cos(ang)); } case "ellipsoid": { let x = r(), y = r(), z = r(); const l = Math.hypot(x, y, z); if (l > 1) (x /= l), (y /= l), (z /= l); return at(x, y, z); } case "hollowellipsoid": { const al = Math.random() * Math.PI * 2, be = Math.random() * Math.PI; const inner = s.inner.map((v) => Math.max(0, Math.min(1, v))); const rx = rnd(inner[0] ?? 0.5, 1), ry = rnd(inner[1] ?? 0.5, 1), rz = rnd(inner[2] ?? 0.5, 1); return at(rx * Math.cos(al) * Math.sin(be), ry * Math.cos(be), rz * Math.sin(al) * Math.sin(be)); } default: return c.slice(); } } /** emit_dir turned by a random angle within the cone, around a random azimuth. */ function coneDir(s) { const { d, up, side } = s.axes; if (!s.angle) return d.slice(); const th = Math.random() * s.angle, ph = Math.random() * Math.PI * 2; const u = norm(up), v = norm(side); const k = Math.sin(th); return norm([d[0] * Math.cos(th) + (u[0] * Math.cos(ph) + v[0] * Math.sin(ph)) * k, d[1] * Math.cos(th) + (u[1] * Math.cos(ph) + v[1] * Math.sin(ph)) * k, d[2] * Math.cos(th) + (u[2] * Math.cos(ph) + v[2] * Math.sin(ph)) * k]); } function spawn(s, origin, lead) { const rotation = s.ownerRotation && s.followParent !== "pos_only" ? s.ownerRotation : null; const p = rotation ? quatRotate(rotation, spawnPoint(s)) : spawnPoint(s); const dir = rotation ? quatRotate(rotation, coneDir(s)) : coneDir(s); const speed = rnd(s.speed[0], s.speed[1]); const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : s.offset; const part = { pos: [p[0] + origin[0] + offset[0] + dir[0] * speed * lead, p[1] + origin[1] + offset[1] + dir[1] * speed * lead, p[2] + origin[2] + offset[2] + dir[2] * speed * lead], dir, speed, age: 0, life: Math.max(1, rnd(s.life[0], s.life[1])), w0: rnd(s.width[0], s.width[1]), h0: rnd(s.height[0], s.height[1]), rot: rnd(s.texRot[0], s.texRot[1]), rotSpeed: 0, }; if (s.model) { // Effect scripts use Direct3D's left-handed axis-angle convention. const angle = -rnd(s.startRotAngle[0], s.startRotAngle[1]) * Math.PI / 360; const axis = s.startRotAxis; const q = [axis[0] * Math.sin(angle), axis[1] * Math.sin(angle), axis[2] * Math.sin(angle), Math.cos(angle)]; part.modelRotation = quatMul(rotation || [0, 0, 0, 1], quatMul(s.rotOffset.length === 4 ? s.rotOffset : [0, 0, 0, 1], q)); part.modelScale = s.affectors.find((f) => f.type === "scale")?.scale(0) || [1, 1, 1]; } for (const f of s.affectors) { if (f.type === "texrotation") { part.rot = rnd(f.range[0], f.range[1]); part.rotSpeed = rnd(f.speed[0], f.speed[1]); } } part.w = part.w0; part.h = part.h0; s.parts.push(part); } function step(s, dt, origin) { const dts = dt / 1000; if (!s.started) { s.started = true; for (let k = 0; k < Math.min(s.burst, s.quota || s.burst); k++) spawn(s, origin, 0); } s.time += dt; // expire + age s.parts = s.parts.filter((p) => p.age < p.life); for (const p of s.parts) p.age += dt; // affectors for (const f of s.affectors) { if (s.time < f.wait) continue; for (const p of s.parts) { const t = Math.min(1, p.age / p.life); if (f.type === "linear") { const r = f.rate(t)[0]; const v = [p.dir[0] * p.speed + f.force[0] * dts * r, p.dir[1] * p.speed + f.force[1] * dts * r, p.dir[2] * p.speed + f.force[2] * dts * r]; p.speed = Math.hypot(v[0], v[1], v[2]); if (p.speed > 1e-6) p.dir = [v[0] / p.speed, v[1] / p.speed, v[2] / p.speed]; } else if (f.type === "scale") { if (s.model) p.modelScale = f.scale(t); else { const [sx, sy] = f.scale(t); p.w = p.w0 * sx; p.h = p.h0 * sy; } } else if (f.type === "texrotation") p.rot += p.rotSpeed * dts; else if (f.type === "emit") { const emitPos = s.ownerRotation && s.followParent !== "pos_only" ? quatRotate(s.ownerRotation, s.emitPos) : s.emitPos; const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : [0, 0, 0]; const c = [origin[0] + offset[0] + emitPos[0], origin[1] + offset[1] + emitPos[1], origin[2] + offset[2] + emitPos[2]]; const d = norm([p.pos[0] - c[0], p.pos[1] - c[1], p.pos[2] - c[2]]); const k = f.power * dts * f.rate(t)[0]; p.pos[0] += d[0] * k; p.pos[1] += d[1] * k; p.pos[2] += d[2] * k; } } } // integrate for (const p of s.parts) { p.pos[0] += p.dir[0] * p.speed * dts; p.pos[1] += p.dir[1] * p.speed * dts; p.pos[2] += p.dir[2] * p.speed * dts; } // emit if (s.endLimit && s.time >= s.limit) s.stopped = true; if (!s.stopped && s.time >= s.wait && s.quota > 0) { s.acc += (s.rate * dt) / 1000; let n = Math.floor(s.acc); s.acc -= n; n = Math.min(n, s.quota - s.parts.length); for (let k = 0; k < n; k++) spawn(s, origin, (k * dts) / Math.max(1, n)); } if ((s.stopped || (!s.endLimit && s.rate < 0.1)) && !s.parts.length && s.time > 0) s.ended = true; } // ---------- an effect instance ---------- function instance(def, origin) { return { def, origin, systems: def.particles.map((p) => makeSystem(p)), modelSystems: def.models.map((m) => makeSystem(m, true)), restartAt: 0 }; } function update(inst, dt) { let live = false; for (const s of [...inst.systems, ...inst.modelSystems]) { if (s.ended) continue; if (s.heroAttached && s.followParent !== "none") { const base = s.ownerOrigin || inst.origin, offset = s.ownerOffset || [0, 0, 0]; const current = [base[0] + offset[0], base[1] + offset[1], base[2] + offset[2]]; const rotation = s.followParent === "all" ? (s.ownerRotation || [0, 0, 0, 1]) : [0, 0, 0, 1]; if (s.previousParent) { const inverse = [-s.previousRotation[0], -s.previousRotation[1], -s.previousRotation[2], s.previousRotation[3]]; for (const p of s.parts) { const local = quatRotate(inverse, [p.pos[0] - s.previousParent[0], p.pos[1] - s.previousParent[1], p.pos[2] - s.previousParent[2]]); const next = quatRotate(rotation, local); p.pos = [current[0] + next[0], current[1] + next[1], current[2] + next[2]]; p.dir = quatRotate(rotation, quatRotate(inverse, p.dir)); if (p.modelRotation) p.modelRotation = quatMul(rotation, quatMul(inverse, p.modelRotation)); } } s.previousParent = current; s.previousRotation = rotation; } if (s.interval > 0) { s.left = (s.left || 0) + dt; while (s.left >= s.interval) { step(s, s.interval, s.ownerOrigin || inst.origin); s.left -= s.interval; } } else step(s, Math.min(dt, 100), s.ownerOrigin || inst.origin); if (!s.ended) live = true; } // map effects play while their systems live; one-shot ones restart in the preview after a pause if (!live && !inst.heroPreview) { inst.restartAt += dt; if (inst.restartAt > 1200) { inst.systems = inst.def.particles.map(makeSystem); inst.modelSystems = inst.def.models.map((m) => makeSystem(m, true)); inst.restartAt = 0; } } } const BLEND = { add: { op: "add", src: "src_alpha", dst: "one" }, src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" }, screen: { op: "add", src: "inv_dest_color", dst: "one" }, lighten: { op: "max", src: "one", dst: "one" }, darken: { op: "min", src: "one", dst: "one" }, rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" }, subtract: { op: "subtract", src: "src_alpha", dst: "one" }, }; /** * Quads of every live particle (6 vertices × [x y z u v r g b a]) grouped per system. * cam: { right, up, dir } in game space. */ function build(inst, cam) { const out = []; for (const s of inst.systems) { if (!s.parts.length) continue; const data = new Float32Array(s.parts.length * 6 * 9); let o = 0; for (const p of s.parts) { const t = Math.min(1, p.age / p.life); const c = s.color(t); const al = Math.max(0, Math.min(1, s.alpha(t)[0])); let X, Y; if (s.billboard === "common") { Y = s.commonDir; X = s.commonFixed ? norm(cross(Math.abs(Y[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0], Y)) : norm(cross(Y, cam.dir)); } else if (s.billboard === "direction") { X = p.dir; Y = cross(X, cam.dir); } else { X = cam.right; Y = cam.up; } const hw = p.w / 2, hh = p.h / 2; const cs = Math.cos(p.rot), sn = Math.sin(p.rot); const uv = (u, v) => { // rotation about (0.5, 0.5) const x = u - 0.5, y = v - 0.5; return [x * cs - y * sn + 0.5, x * sn + y * cs + 0.5]; }; const corner = (sx, sy) => [p.pos[0] + X[0] * hw * sx + Y[0] * hh * sy, p.pos[1] + X[1] * hw * sx + Y[1] * hh * sy, p.pos[2] + X[2] * hw * sx + Y[2] * hh * sy]; const q = [ [corner(-1, 1), uv(0, 0)], [corner(1, 1), uv(1, 0)], [corner(1, -1), uv(1, 1)], [corner(-1, -1), uv(0, 1)], ]; for (const k of [0, 1, 2, 0, 2, 3]) { const [pp, tt] = q[k]; data[o++] = pp[0]; data[o++] = pp[1]; data[o++] = pp[2]; data[o++] = tt[0]; data[o++] = tt[1]; data[o++] = c[0] / 255; data[o++] = c[1] / 255; data[o++] = c[2] / 255; data[o++] = al; } } out.push({ texture: s.texture, blend: BLEND[s.render] || BLEND.add, depth: s.depth, mod2x: s.mod2x, data, count: s.parts.length * 6 }); } return out; } const count = (inst) => inst.systems.reduce((a, s) => a + s.parts.length, 0); return { parse, instance, update, build, count }; })(); window.Effects = Effects;