/* 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); }