Files

455 lines
18 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";
// 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;