runecaster / static /effects.js
cetusian's picture
Deploy runecaster
28b3905 verified
Raw History Blame Contribute Delete
17.7 kB
/* Runecaster: one spectacle per element, built from fx.js's primitives. Each element has its own geometry,
motion, timing and light, so a cast reads at a glance:
fire flames licking up from the palm, embers, smoke, a flickering glow
water a coherent stream arcing from the hand, splashing into droplets and ripple rings
lightning jagged branching bolts that strobe, a white flash and a violet afterglow
earth lit rock shards bursting out and bouncing, a dust cloud, a floor shockwave
nature vines growing across the frame, sprouting leaves and flowers, drifting pollen
ice faceted crystals growing from the hand, frost creeping over the frame, cold mist
air a spiral vortex of streaks and leaves around the hand (shown as Wind)
shadow a void that sucks light in, rimmed in violet, then bursts
Combos run both (fire + air is a fire tornado, water + lightning a charged storm); unstable sputters.
ctx = { x, y: the palm; aims: [angles]; W, H; power 0..1 }. */
const rnd = (a, b) => a + Math.random() * (b - a);
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const pickOf = (list) => list[(Math.random() * list.length) | 0];
const Q = (fx) => fx.limit / 40000; // the quality tier: 1 on a laptop, less on a weak device
/* A jagged bolt from (x1, y1) to (x2, y2) by midpoint displacement, with branches: [x1, y1, x2, y2, width] segments. */
export function boltPath(x1, y1, x2, y2, width, depth = 6, branches = 3) {
let pts = [[x1, y1], [x2, y2]];
let amp = Math.hypot(x2 - x1, y2 - y1) * 0.22;
for (let d = 0; d < depth; d++) {
const out = [pts[0]];
for (let i = 1; i < pts.length; i++) {
const [ax, ay] = pts[i - 1], [bx, by] = pts[i], dx = bx - ax, dy = by - ay, l = Math.hypot(dx, dy) || 1;
const o = (Math.random() - 0.5) * amp;
out.push([(ax + bx) / 2 - (dy / l) * o, (ay + by) / 2 + (dx / l) * o], pts[i]);
}
pts = out; amp *= 0.55;
}
const segs = [];
for (let i = 1; i < pts.length; i++) segs.push([pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1], width]);
for (let b = 0; b < branches && width > 1.5; b++) {
const [sx, sy] = pts[(Math.random() * pts.length * 0.8) | 0], a = Math.atan2(y2 - y1, x2 - x1) + rnd(-0.9, 0.9);
const l = Math.hypot(x2 - x1, y2 - y1) * rnd(0.2, 0.45);
segs.push(...boltPath(sx, sy, sx + Math.cos(a) * l, sy + Math.sin(a) * l, width * 0.45, depth - 2, 1));
}
return segs;
}
const FIRE = { hot: rgb("#fff6c2"), mid: rgb("#ff8a00"), red: rgb("#ff2200"), ember: rgb("#ffb347"), smoke: rgb("#2b2522") };
function fire(fx, c, k = 1, vortex = false) {
const { x, y, S, H } = c;
fx.flash(0.08);
fx.ring({ x, y, r0: S * 0.02, r1: S * 0.3, life: 0.5, color: FIRE.mid });
fx.after(0, 2.0, (dt, t) => {
const rate = 1500 * Q(fx) * k * (t < 0.3 ? 1.6 : 1);
for (let i = 0; i < rate * dt; i++) {
const spread = S * 0.06;
if (vortex) { // fire tornado: flames ride a rising spiral
fx.glow.spawn({ x, y, life: rnd(0.6, 1.1), size: rnd(12, 24), c0: FIRE.hot, c1: FIRE.red, stretch: 2.2, alpha: 0.3,
vortex: [x, y, rnd(S * 0.02, S * 0.08), rnd(0, 6.28), rnd(7, 10), S * 0.25, S * rnd(0.5, 0.9), 0.35] });
} else {
fx.glow.spawn({ x: x + rnd(-spread, spread), y: y + rnd(-spread * 0.5, spread * 0.5), vx: rnd(-30, 30), vy: -S * rnd(0.7, 1.3),
gravity: -S * 1.2, curl: S * 1.6, drag: 1.0, life: rnd(0.55, 1.0), size: rnd(14, 28), stretch: 1.6,
c0: FIRE.hot, c1: FIRE.red, alpha: 0.3 });
}
}
for (let i = 0; i < 40 * k * dt; i++) { // embers
fx.glow.spawn({ x: x + rnd(-S * 0.05, S * 0.05), y, vx: rnd(-60, 60), vy: -S * rnd(0.3, 0.8), gravity: -S * 0.2, curl: S * 1.5,
life: rnd(1.4, 2.6), size: rnd(2.5, 4.5), stretch: 2.5, c0: FIRE.ember, c1: FIRE.red, flicker: 0.6 });
}
for (let i = 0; i < 26 * k * dt; i++) { // smoke above the flames
fx.soft.spawn({ x: x + rnd(-S * 0.04, S * 0.04), y: y - S * rnd(0.25, 0.4), vy: -S * rnd(0.2, 0.35), curl: S * 0.6, life: rnd(1.4, 2.2),
size: rnd(26, 40), grow: 2.5, alpha: 0.28, c0: FIRE.smoke });
}
if (Math.random() < 0.5) fx.glow.spawn({ x, y: y - S * 0.12, life: 0.08, size: S * 0.6, alpha: rnd(0.03, 0.08), c0: FIRE.mid }); // the flicker
});
void H;
}
const WATER = { foam: rgb("#e8f8ff"), light: rgb("#7fd3ff"), deep: rgb("#0a6cff"), bubble: rgb("#cfefff") };
function water(fx, c, k = 1, onImpact = null) {
const { x, y, S, H, aims } = c;
const floor = H * 0.93;
const aim = aims[0];
const dir = Math.cos(aim) >= 0 ? 1 : -1; // arc sideways, over the top
for (let i = 0; i < 3; i++) fx.ring({ x, y, r0: S * 0.03, r1: S * (0.16 + i * 0.07), life: 0.9, delay: i * 0.15, color: WATER.light, sq: 0.45 });
let hits = 0;
const splash = (sx, sy) => {
hits++;
for (let j = 0; j < 3; j++) {
fx.glow.spawn({ x: sx, y: sy - 2, vx: rnd(-S * 0.35, S * 0.35), vy: -S * rnd(0.3, 0.9), gravity: S * 2.6, life: rnd(0.4, 0.8),
size: rnd(4, 8), c0: WATER.foam, c1: WATER.light });
}
if (hits % 18 === 1) fx.ring({ x: sx, y: sy, r0: S * 0.02, r1: S * rnd(0.12, 0.22), life: 0.9, color: WATER.light, sq: 0.25 });
if (onImpact && hits % 30 === 1) onImpact(sx, sy);
};
fx.after(0.05, 1.3, (dt, t) => {
const wobble = Math.sin(t * 7) * 0.05;
for (let i = 0; i < 1400 * Q(fx) * k * dt; i++) {
const a = -Math.PI / 2 + dir * (0.55 + wobble) + rnd(-0.04, 0.04), v = S * rnd(1.75, 1.95);
fx.glow.spawn({ x: x + rnd(-4, 4), y: y + rnd(-4, 4), vx: Math.cos(a) * v, vy: Math.sin(a) * v, gravity: S * 2.1, life: 2.2,
size: rnd(7, 11), stretch: 2.6, c0: WATER.light, c1: WATER.deep, alpha: 0.4, floor, onFloor: splash });
}
for (let i = 0; i < 18 * dt; i++) { // bubbles
fx.glow.spawn({ x: x + rnd(-S * 0.08, S * 0.08), y: y + rnd(-S * 0.05, S * 0.05), vy: -S * rnd(0.1, 0.3), curl: S * 0.4, life: rnd(0.8, 1.5),
size: rnd(5, 10), c0: WATER.bubble, alpha: 0.6 });
}
});
}
const BOLT = { core: rgb("#f2eeff"), violet: rgb("#9d7bff"), spark: rgb("#ffffff") };
function lightning(fx, c, k = 1, target = null) {
const { x, y, S, W, H, aims } = c;
const strikes = [0, 0.07, 0.18, 0.33, 0.52, 0.8, 1.1];
for (const [n, t0] of strikes.entries()) {
fx.after(t0, 0.01, () => {
const aim = aims[n % aims.length] + rnd(-0.35, 0.35);
let tx, ty;
if (target) [tx, ty] = target();
else { const reach = Math.hypot(W, H) * rnd(0.45, 0.75); tx = x + Math.cos(aim) * reach; ty = y + Math.sin(aim) * reach; }
const segs = boltPath(x, y, tx, ty, rnd(2.2, 3.4) * (0.7 + 0.3 * k), 7, 4);
fx.bolt(segs, rnd(0.18, 0.3));
if (n % 2 === 0) fx.flash(0.55 * k);
for (let i = 0; i < segs.length; i += 3) { // the afterglow clinging to the path
const [sx, sy] = segs[i];
fx.glow.spawn({ x: sx, y: sy, vx: rnd(-30, 30), vy: rnd(-30, 30), drag: 2, life: rnd(0.5, 0.9), size: rnd(5, 9), c0: BOLT.violet, alpha: 0.5 });
}
fx.ring({ x, y, r0: S * 0.02, r1: S * 0.14, life: 0.25, color: BOLT.violet, bright: 1.5 });
});
}
fx.after(0, 1.4, (dt) => { // crackle at the palm
for (let i = 0; i < 160 * dt; i++) {
const a = rnd(0, 6.28), v = S * rnd(0.8, 1.6);
fx.glow.spawn({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, drag: 6, life: rnd(0.08, 0.2), size: rnd(2, 3.5), stretch: 4, c0: BOLT.spark });
}
});
}
const EARTH = { rocks: [rgb("#6d5a4a"), rgb("#8a7560"), rgb("#5a5550"), rgb("#9c8466")], dust: rgb("#8b7a66"), glow: rgb("#ffb56b") };
function earth(fx, c, k = 1) {
const { x, y, S, H, aims } = c;
const floor = H * 0.9;
const aim = aims[0];
const dust = (dx, dy, n, big = 1) => {
for (let i = 0; i < n; i++) {
fx.soft.spawn({ x: dx + rnd(-10, 10), y: dy + rnd(-6, 6), vx: rnd(-S * 0.3, S * 0.3), vy: -S * rnd(0.02, 0.15), drag: 1.8,
life: rnd(0.9, 1.8), size: rnd(18, 34) * big, grow: 2.2, alpha: 0.42, c0: EARTH.dust });
}
};
dust(x, y, 60 * k, 1.3);
fx.ring({ x, y: floor, r0: S * 0.05, r1: S * 0.8, life: 0.8, color: EARTH.glow, sq: 0.18, bright: 0.8 });
const n = Math.round(55 * k);
for (let i = 0; i < n; i++) {
const a = aim + rnd(-0.9, 0.9), v = S * rnd(1.1, 2.3);
fx.rock({ x: x + rnd(-8, 8), y, vx: Math.cos(a) * v * 0.7, vy: Math.min(-S * 0.6, Math.sin(a) * v), g: S * 3.2, floor: floor + rnd(-S * 0.04, S * 0.04),
s: S * rnd(0.012, 0.038) * (i < 6 ? 1.6 : 1), life: rnd(2.2, 2.8), color: pickOf(EARTH.rocks),
onLand: (lx, ly) => dust(lx, ly, 4, 0.7) });
}
for (let i = 0; i < 90 * k; i++) { // grit
const a = aim + rnd(-1.2, 1.2), v = S * rnd(0.6, 1.8);
fx.glow.spawn({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, gravity: S * 3, life: rnd(0.5, 1), size: rnd(2, 4), c0: EARTH.glow, c1: EARTH.dust, alpha: 0.6 });
}
}
const NATURE = { vine: [rgb("#1f6b2a"), rgb("#2e7d32"), rgb("#3f8f3a")], leaf: [rgb("#43a047"), rgb("#7cb342"), rgb("#2e7d32"), rgb("#9ccc65")],
petal: [rgb("#ff80ab"), rgb("#ffd54f"), rgb("#ffffff"), rgb("#ce93d8")], pollen: rgb("#fff59d") };
function nature(fx, c, k = 1) {
const { x, y, S, aims } = c;
const count = Math.max(3, Math.round(6 * k));
for (let v = 0; v < count; v++) {
let a = aims[0] + ((v / (count - 1)) - 0.5) * 2.4 + rnd(-0.15, 0.15);
const pts = [[x, y]], len = S * rnd(0.7, 1.15), steps = 70, curl = rnd(-1, 1) * 2.2, phase = rnd(0, 6);
for (let i = 1; i <= steps; i++) {
a += (curl / steps) + Math.sin(i * 0.18 + phase) * 0.05;
const [px, py] = pts[i - 1];
pts.push([px + Math.cos(a) * len / steps, py + Math.sin(a) * len / steps]);
}
const grow = rnd(0.9, 1.3), life = 3.0;
fx.vine({ pts, w: rnd(4, 7), grow, life, color: pickOf(NATURE.vine) });
for (let i = 8; i < steps; i += 6) { // leaves pop out as the vine passes them
const [lx, ly] = pts[i], [nx, ny] = pts[i + 1], side = (i / 6) % 2 ? 1 : -1;
const ang = Math.atan2(ny - ly, nx - lx) - Math.PI / 2 + side * 1.0;
fx.leaf({ x: lx, y: ly, a: -ang, s: S * rnd(0.028, 0.045), wide: 1.1, delay: grow * i / steps, life: life, color: pickOf(NATURE.leaf) });
}
const [tx, ty] = pts[steps]; // a flower at the tip
const color = pickOf(NATURE.petal);
for (let p = 0; p < 5; p++) fx.leaf({ x: tx, y: ty, a: p * 1.2566, s: S * 0.04, wide: 1.4, delay: grow, life: life - 0.1, color });
fx.after(grow, 0.1, () => fx.glow.spawn({ x: tx, y: ty, life: 1.8, size: S * 0.025, c0: NATURE.pollen, alpha: 0.6 }));
}
fx.after(0.3, 2.4, (dt) => { // pollen
for (let i = 0; i < 50 * dt; i++) {
fx.glow.spawn({ x: x + rnd(-S * 0.6, S * 0.6), y: y - rnd(0, S * 0.7), vx: rnd(-20, 20), vy: -rnd(10, 40), curl: S * 0.3, life: rnd(1, 2),
size: rnd(2.5, 4.5), c0: NATURE.pollen, flicker: 0.5 });
}
});
}
const ICE = { glint: rgb("#ffffff"), cyan: rgb("#9eeaff"), mist: rgb("#e6f7ff") };
function ice(fx, c, k = 1) {
const { x, y, S, aims } = c;
fx.frost(1);
fx.after(2.0, 0.01, () => fx.frost(0));
fx.flash(0.12);
const n = Math.round(22 * k) + 6;
for (let i = 0; i < n; i++) {
const a = aims[0] + rnd(-1.8, 1.8), len = S * rnd(0.14, 0.42) * (i < 5 ? 1.4 : 1);
fx.shard({ x, y, a, len, w: len * rnd(0.12, 0.2), delay: rnd(0, 0.35), life: 2.3,
onEnd: (s) => { // shatter into glitter
for (let j = 0; j < 14; j++) {
const t = rnd(0, 1);
fx.glow.spawn({ x: s.x + Math.cos(s.a) * s.len * t, y: s.y + Math.sin(s.a) * s.len * t, vx: rnd(-S * 0.3, S * 0.3),
vy: rnd(-S * 0.3, S * 0.1), gravity: S * 1.4, life: rnd(0.5, 1), size: rnd(2, 4), c0: ICE.glint, c1: ICE.cyan });
}
} });
}
fx.ring({ x, y, r0: S * 0.02, r1: S * 0.45, life: 0.7, color: ICE.cyan, bright: 0.8 });
fx.after(0, 2.2, (dt) => {
for (let i = 0; i < 40 * k * dt; i++) { // cold mist, sinking
fx.soft.spawn({ x: x + rnd(-S * 0.25, S * 0.25), y: y + rnd(-S * 0.1, S * 0.1), vx: rnd(-S * 0.1, S * 0.1), vy: S * rnd(0.02, 0.1),
life: rnd(1.4, 2.2), size: rnd(30, 50), grow: 1.8, alpha: 0.22, c0: ICE.mist });
}
for (let i = 0; i < 60 * dt; i++) { // glints
fx.glow.spawn({ x: x + rnd(-S * 0.35, S * 0.35), y: y + rnd(-S * 0.35, S * 0.2), life: rnd(0.15, 0.35), size: rnd(3, 7), c0: ICE.glint, flicker: 0.7 });
}
});
}
const WIND = { streak: [rgb("#ffffff"), rgb("#d9f3ff"), rgb("#bfe6ff")], leaf: [rgb("#9ccc65"), rgb("#c0a060"), rgb("#7cb342")] };
function air(fx, c, k = 1) {
const { x, y, S } = c;
fx.after(0, 2.3, (dt, t) => {
const rate = 1100 * Q(fx) * k * (t < 1.6 ? 1 : 0.3);
for (let i = 0; i < rate * dt; i++) {
const r = rnd(S * 0.05, S * 0.3);
fx.glow.spawn({ x, y, life: rnd(0.6, 1.0), size: rnd(3, 4.5), stretch: 8, c0: pickOf(WIND.streak), alpha: 0.5,
vortex: [x, y + S * 0.05, r, rnd(0, 6.28), rnd(8, 12) * (S * 0.1 / r) ** 0.35, S * rnd(0.15, 0.4), S * rnd(0.25, 0.6), 0.32] });
}
});
for (let i = 0; i < 26 * k; i++) { // leaves caught in it
fx.leaf({ x, y, s: S * rnd(0.02, 0.03), spin: rnd(-9, 9), delay: rnd(0, 0.8), life: 2.4, color: pickOf(WIND.leaf),
vortex: { cx: x, cy: y, rad: S * rnd(0.08, 0.2), ang: rnd(0, 6.28), w: rnd(6, 9), vr: S * 0.15, rise: S * rnd(0.2, 0.4), off: 0, sq: 0.32 } });
}
fx.ring({ x, y: y + S * 0.05, r0: S * 0.05, r1: S * 0.5, life: 0.9, color: WIND.streak[1], sq: 0.3, bright: 0.6 });
}
const VOID = { dark: rgb("#050008"), rim: rgb("#b388ff"), violet: rgb("#7c4dff"), magenta: rgb("#e040fb") };
function shadow(fx, c, k = 1) {
const { x, S } = c, y = c.y - S * 0.12;
fx.dim(0.25);
fx.after(0, 1.6, (dt, t) => {
const r = S * 0.2 * Math.min(1, t / 0.4);
fx.soft.spawn({ x, y, life: 0.12, size: r * 2.2, alpha: 0.9, c0: VOID.dark }); // the void itself
for (let i = 0; i < 2500 * Q(fx) * k * dt; i++) { // light pulled in from all around
const a = rnd(0, 6.28), d = S * rnd(0.35, 0.7);
fx.glow.spawn({ x: x + Math.cos(a) * d, y: y + Math.sin(a) * d, life: 0.7, size: rnd(3, 6), stretch: 3,
c0: pickOf([VOID.violet, VOID.magenta, VOID.rim]), pull: [x, y, 9] });
}
if (Math.random() < dt * 5) fx.ring({ x, y, r0: r * 2.4, r1: r * 0.9, life: 0.5, color: VOID.rim, bright: 0.9 });
});
fx.after(1.6, 0.01, () => { // and it bursts
fx.flash(0.2);
fx.ring({ x, y, r0: S * 0.05, r1: S * 0.9, life: 0.7, color: VOID.magenta, bright: 1.2 });
for (let i = 0; i < 500 * k; i++) {
const a = rnd(0, 6.28), v = S * rnd(0.6, 1.8);
fx.glow.spawn({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, drag: 1.5, life: rnd(0.5, 1.1), size: rnd(3, 6), stretch: 3,
c0: VOID.rim, c1: VOID.violet });
}
});
fx.after(2.2, 0.01, () => fx.dim(0.5));
}
const ARCANE = [rgb("#e040fb"), rgb("#7c4dff"), rgb("#ff4081"), rgb("#69f0ae")];
function arcane(fx, c, k = 1) {
const { x, y, S } = c;
for (let i = 0; i < 3; i++) fx.ring({ x, y, r0: S * 0.05, r1: S * (0.3 + i * 0.15), life: 0.8, delay: i * 0.12, color: ARCANE[i] });
for (let i = 0; i < 700 * k; i++) {
const a = rnd(0, 6.28), v = S * rnd(0.4, 1.4);
fx.glow.spawn({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, drag: 1.4, life: rnd(0.6, 1.2), size: rnd(4, 8), c0: pickOf(ARCANE), c1: ARCANE[1] });
}
}
/* Unstable: the spell spits, flickers and dies in a puff of smoke. */
function sputter(fx, c) {
const { x, y, S } = c;
const pops = [0, 0.12, 0.3, 0.38, 0.6, 0.85, 0.95, 1.2];
for (const t0 of pops) {
fx.after(t0, 0.01, () => {
const a0 = rnd(0, 6.28), n = Math.round(rnd(15, 45));
for (let i = 0; i < n; i++) {
const a = a0 + rnd(-0.6, 0.6), v = S * rnd(0.3, 1.1);
fx.glow.spawn({ x: x + rnd(-10, 10), y: y + rnd(-10, 10), vx: Math.cos(a) * v, vy: Math.sin(a) * v, gravity: S * 1.8, drag: 1,
life: rnd(0.15, 0.45), size: rnd(3, 6), stretch: 2, c0: pickOf(ARCANE), flicker: 0.8 });
}
fx.ring({ x, y, r0: S * 0.02, r1: S * rnd(0.05, 0.12), life: 0.2, color: pickOf(ARCANE) });
});
}
fx.after(1.35, 0.01, () => {
for (let i = 0; i < 40; i++) {
fx.soft.spawn({ x: x + rnd(-15, 15), y: y + rnd(-10, 10), vx: rnd(-40, 40), vy: -S * rnd(0.05, 0.2), life: rnd(1, 1.6), size: rnd(20, 34),
grow: 2, alpha: 0.35, c0: rgb("#4a4458") });
}
});
}
export const SPECTACLES = { fire, water, lightning, earth, nature, ice, air, shadow, arcane };
/* Play a spell. Combos run both elements at a little under full strength, with two hand-made twists. */
export function cast(fx, spell, ctx) {
const c = { ...ctx, S: Math.min(ctx.W, ctx.H) };
const k = spell.kind === "offline" ? 0.7 : Math.min(1, 0.55 + (spell.power || 0.6) * 0.5);
if (spell.kind === "unstable") return sputter(fx, c);
if (spell.kind !== "combo") return (SPECTACLES[spell.elements[0]] || arcane)(fx, c, k);
const pair = [...spell.elements].sort().join("+");
if (pair === "air+fire") { fire(fx, c, 0.6, true); air(fx, c, 0.5); return; } // fire tornado
if (pair === "lightning+water") { // a charged storm: bolts strike where the water lands
const hits = [];
water(fx, c, 0.8, (sx, sy) => hits.push([sx, sy]));
lightning(fx, c, 0.7, () => (hits.length ? hits[hits.length - 1] : [c.x + c.S * 0.5, c.H * 0.9]));
return;
}
for (const e of spell.elements) (SPECTACLES[e] || arcane)(fx, c, 0.72);
}