diff --git a/assets/images/originals/toxic-planet-01.png b/assets/images/originals/toxic-planet-01.png new file mode 100644 index 0000000..2509f17 Binary files /dev/null and b/assets/images/originals/toxic-planet-01.png differ diff --git a/assets/images/originals/toxic-planet-02.png b/assets/images/originals/toxic-planet-02.png new file mode 100644 index 0000000..abb12ad Binary files /dev/null and b/assets/images/originals/toxic-planet-02.png differ diff --git a/data/combat.json b/data/combat.json index 50573c9..d154d37 100644 --- a/data/combat.json +++ b/data/combat.json @@ -490,8 +490,8 @@ "respawnDelay": 2.0 }, "fx": { - "_comment": "The LOOK of combat (both contexts — js/combat/Fx.js + js/combat/CamShake.js). shake = the camera kick on the WORLD pass (the separate UI pass keeps the bar + feed still): each value is Phaser's shake intensity (a FRACTION of the viewport in px) and ms = the duration. hit = you take a hit, kill = an enemy goes down, break = the shield-collapse beat. shield = the shield-sphere impact read: radius px = the sphere's world radius around the ship (the impact point sits this far off the ship), arcSpan rad = the bright arc's angular width (the highlighted hit region), ripple px = the shockwave that grows from the impact point, decayMs = how long the sphere flash lingers.", - "shake": { "hit": 0.0045, "kill": 0.007, "break": 0.011, "ms": 230 }, + "_comment": "The LOOK of combat (both contexts — js/combat/Fx.js + js/combat/CamShake.js). shake = the camera kick on the WORLD pass (the separate UI pass keeps the bar + feed still): each value is Phaser's shake intensity (a FRACTION of the viewport in px) and ms = the duration. hit = you take a hit, kill = an enemy goes down, break = the shield-collapse beat, ram = the Ramming Charge's impact (the heaviest beat). shield = the shield-sphere impact read: radius px = the sphere's world radius around the ship (the impact point sits this far off the ship), arcSpan rad = the bright arc's angular width (the highlighted hit region), ripple px = the shockwave that grows from the impact point, decayMs = how long the sphere flash lingers.", + "shake": { "hit": 0.0045, "kill": 0.007, "break": 0.011, "ram": 0.014, "ms": 230 }, "shield": { "radius": 40, "arcSpan": 1.9, "ripple": 90, "decayMs": 520 } }, diff --git a/data/skills.json b/data/skills.json index b336c9a..1d426b7 100644 --- a/data/skills.json +++ b/data/skills.json @@ -84,11 +84,11 @@ "damageMul": 4, "damageMulPerRank": 2, "fx": { - "_comment": "The charge's own dressing (js/skills/SkillEffects.js reads these; every key has a fallback): the streak drawn along the dash path, and the arrival shock ring. sfxEvent names a COMBAT_SFX EVENT (js/combat/CombatSfx.js) — 'hull' is the heavy armor-crack voice, the same one a hull hit plays.", + "_comment": "The charge's own dressing (js/combat/Fx.js reads these; every key has a fallback): trailColor = the comet head's shed embers AND the arrival's ember ring, ringColor = the DAMAGE-RADIUS ring + the shock ripples, ringLifeMs = how long that radius ring lingers, sfxEvent = a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played with the thunder boom ('kill') under the impact — 'hull' is the heavy armor-crack voice. The comet head itself, the lingering ~1 s ember trail and the fireball are Fx's fixed craft; these colors skin them.", "trailColor": "#ff3a4d", "trailAlpha": 0.55, "ringColor": "#ffd84d", - "ringLifeMs": 320, + "ringLifeMs": 480, "sfxEvent": "hull" } } diff --git a/dev/cdp.mjs b/dev/cdp.mjs new file mode 100644 index 0000000..0cbced9 --- /dev/null +++ b/dev/cdp.mjs @@ -0,0 +1,87 @@ +// Minimal CDP helper with HARD timeouts so scripts can never hang. +// Usage: +// import { connectTab, screenshot, close } from './cdp.mjs'; +// const c = await connectTab(); // finds a booted game tab on :8080 +// const v = await c.eval('1+1'); +// await c.screenshot('/tmp/x.png'); +// c.close(); +const CDP = '127.0.0.1:9333'; +const GLOBAL_MS = 45000; +const watchdog = setTimeout(() => { + console.error('[cdp] GLOBAL TIMEOUT — force exiting'); + process.exit(130); +}, GLOBAL_MS); + +function toTimeout(promise, ms, label) { + return Promise.race([ + promise, + new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout: ${label} (${ms}ms)`)), ms)), + ]); +} + +async function listTargets() { + const r = await toTimeout(fetch(`http://${CDP}/json`), 5000, 'fetch /json'); + return r.json(); +} + +export async function connectTab() { + const list = await listTargets(); + const tabs = list.filter(x => x.type === 'page' && x.url.includes(':8080')); + if (!tabs.length) throw new Error('no :8080 page target'); + for (const cand of tabs) { + let ws = null; + try { + ws = new WebSocket(cand.webSocketDebuggerUrl); + await toTimeout(new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = () => rej(new Error('ws open error')); + }), 3000, 'ws open'); + + let seq = 0; + const pending = new Map(); + ws.onmessage = m => { + const q = JSON.parse(m.data); + if (q.id && pending.has(q.id)) { + const o = pending.get(q.id); pending.delete(q.id); + q.error ? o.rej(new Error(q.error.message)) : o.res(q.result); + } + }; + const send = (method, params = {}) => + toTimeout(new Promise((res, rej) => { + const id = ++seq; + pending.set(id, { res, rej }); + ws.send(JSON.stringify({ id, method, params })); + setTimeout(() => { + if (pending.has(id)) { pending.delete(id); rej(new Error('send timeout ' + method)); } + }, 8000); + }), 12000, 'send ' + method); + + await send('Runtime.enable'); + const probe = await send('Runtime.evaluate', { + expression: 'Boolean(window.__ORBIT_COMBAT__ && window.game?.scene?.getScene("GameScene")?.ship)', + returnByValue: true, + }); + if (!probe.result?.value) { ws.close(); continue; } + + return { + ws, + async eval(expression) { + const r = await send('Runtime.evaluate', { expression, returnByValue: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval error'); + return r.result?.value; + }, + async screenshot(path) { + const r = await send('Page.captureScreenshot', { format: 'png' }); + const fs = await import('node:fs'); + fs.writeFileSync(path, Buffer.from(r.data, 'base64')); + return path; + }, + close() { try { ws.close(); } catch {} }, + }; + } catch (e) { + try { ws?.close(); } catch {} + continue; + } + } + throw new Error('no booted game tab found: ' + tabs.map(t => t.url).join(' | ')); +} diff --git a/dev/ram-shot.mjs b/dev/ram-shot.mjs new file mode 100644 index 0000000..00fd5c7 --- /dev/null +++ b/dev/ram-shot.mjs @@ -0,0 +1,152 @@ +/** + * Dev-only CDP harness for the RAMMING CHARGE's FX set (js/combat/Fx.js → + * cometStart / trailEmbers / ramBlast, driven through the production + * CombatSystem in the MAIN game): boots a fresh Vanguard run with the + * dev door (?skill=max — every skill at its max), spawns a hostile ahead + * of the ship, casts seat 1, and captures the three money frames — + * the comet head + ember wake MID-CHARGE, the fireball + damage-radius + * ring AT IMPACT, and the trail LINGERING ~1 s after. + * + * node dev/ram-shot.mjs [outDir] + * + * Needs the dev server (http://127.0.0.1:8080) + a headless CDP browser + * on 127.0.0.1:9333 (same idiom as dev/combat-shot.mjs). + */ +import fs from 'node:fs'; + +const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; +const BASE = process.env.BASE ?? 'http://127.0.0.1:8080'; +const OUT = process.argv[2] ?? 'dev/shots'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + fs.mkdirSync(OUT, { recursive: true }); + + async function openPage(url) { + let target; + try { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); + } catch { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); + } + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const send = (method, params = {}) => new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); + }); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); }); + ws.onmessage = (m) => { + const x = JSON.parse(m.data); + if (x.id && pending.has(x.id)) { + const { res, rej } = pending.get(x.id); + pending.delete(x.id); + x.error ? rej(new Error(x.error.message)) : res(x.result); + } + }; + await send('Runtime.enable'); + await send('Page.enable'); + await send('Network.enable'); + await send('Network.setCacheDisabled', { cacheDisabled: true }); + await send('Page.navigate', { url }); + const ev = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); + return r.result?.value; + }; + const waitTrue = async (expr, timeoutMs = 45000) => { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + const v = await ev(expr).catch(() => null); + if (v) return v; + await sleep(250); + } + throw new Error('timed out waiting for: ' + expr.slice(0, 60)); + }; + const shot = async (name, delayMs = 110) => { + await sleep(delayMs); // let the last paint land + const r = await send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(`${OUT}/${name}.png`, Buffer.from(r.data, 'base64')); + console.log(` shot: ${OUT}/${name}.png`); + }; + return { ev, waitTrue, shot, target }; + } + + const p = await openPage(`${BASE}/index.html?combat&skill=max`); + try { + await p.waitTrue('Boolean(window.game && window.game.scene.getScene("MenuScene") && window.game.scene.getScene("MenuScene").seedValue)'); + await p.ev('window.game.scene.getScene("MenuScene").startNewGame()'); + await p.waitTrue('Boolean(window.__ORBIT_COMBAT__)'); + + // The new-run tutorial comms own the screen and pause the sim — + // end the active one, drop the queue, un-pause (same path combat-shot uses). + await p.ev(`(() => { + const g = window.game.scene.getScene('GameScene'); + const h = g.comms; + if (!h) return true; + if (h.current) { try { h._finish(); } catch (e) {} } + if (Array.isArray(h.queue)) h.queue.length = 0; + h._setPaused(false); + try { h.box?.close(); } catch (e) {} + h.box = null; + return true; + })()`); + + // The camera follows the ship smoothly — wait until it is centered. + await p.waitTrue(`(() => { + const g = window.game.scene.getScene('GameScene'); + const cam = g.cameras.main; + const w = g.scale.width, h = g.scale.height; + const cx = (cam.worldView?.x ?? cam.scrollX ?? cam.x) + w / 2; + const cy = (cam.worldView?.y ?? cam.scrollY ?? cam.y) + h / 2; + return Math.abs(cx - g.ship.x) < 24 && Math.abs(cy - g.ship.y) < 24; + })()`, 8000).catch(() => null); + await sleep(600); // the pack settles, the sim breathes + + // One hostile ~420 px ahead of the ship + a FULL energy orb, then the + // cast through the production seam (seat 1 = Ramming Charge 5/5). + const setup = await p.ev(`(() => { + const oc = window.__ORBIT_COMBAT__; + const sys = oc.system; + const s = sys.ship; + sys.spawnEnemy(s.x + 420, s.y - 140, 991); + sys.energy = sys.maxEnergy; + const res = oc.cast(0); + return { ok: !!res?.ok, reason: res?.reason ?? null, id: res?.id ?? null, rank: res?.rank ?? 0 }; + })()`); + console.log('cast:', JSON.stringify(setup)); + if (!setup?.ok) throw new Error(`cast refused: ${JSON.stringify(setup)}`); + + // EVENT-DRIVEN CAPTURE — poll the production dash state (oc.system.dash) + // instead of guessing ms: the impact lands wherever arrival or the + // clock puts it, and the frames must sit INSIDE each phase. + const dashing = () => p.ev('Boolean(window.__ORBIT_COMBAT__?.system?.dash)'); + + // Phase 1 — MID-CHARGE: wait for the dash to be live, capture early in + // it (the comet head over the hull + the ember wake streaming behind). + const t0 = Date.now(); + while (Date.now() - t0 < 4000 && !(await dashing().catch(() => false))) await sleep(30); + await sleep(90); // a few frames in — the wake has length, the head is up + await p.shot('ram-dash', 60); + + // Phase 2 — AT IMPACT: the dash ends the instant the blast fires — + // catch the fireball + the damage-radius ring near their peak. + const t1 = Date.now(); + while (Date.now() - t1 < 6000 && (await dashing().catch(() => false))) await sleep(25); + await p.shot('ram-impact', 120); + + // Phase 3 — the wake LINGERING: ~0.8 s after impact the late embers + // (the trail must outlive the blast — that's the point of it) are still + // cooling where the charge flew. + await p.shot('ram-trail', 800); + } finally { + await fetch(`http://${CDP}/json/close/${p.target.id}`).catch(() => {}); + } + + console.log('ram shot: done'); +} + +main().catch((err) => { console.error('ram-shot failed:', err.message); process.exit(1); }); diff --git a/dev/ram-video.mjs b/dev/ram-video.mjs new file mode 100644 index 0000000..dc9bac1 --- /dev/null +++ b/dev/ram-video.mjs @@ -0,0 +1,156 @@ +// Record the RAMMING CHARGE (Vanguard) as a video. +// +// This headless box renders ~1–2 fps, so real-time capture (screencast or a +// screenshot loop) can't hold a 350 ms dash. Instead we drive the +// simulation DETERMINISTICALLY: +// +// 1. boot the ?combat&skill=max game, ack the tutorial comms, +// 2. spawn one hostile 520 px ahead on the ship's heading, +// 3. FREEZE the world (comms pause — GameScene.update then skips +// ship.update / combat.update but keeps RENDERING), +// 4. cast RAMMING CHARGE, +// 5. hand-step the exact unfrozen block (ship.update + combat.update + +// combatHud) in 16.67 ms slices, one screenshot per slice, +// 6. ffmpeg → mp4 (25 fps playback ≈ 4 s of slow-mo). +// +// Usage: node dev/ram-video.mjs (dev server :8080, headless browser :9333) +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; + +const CDP = '127.0.0.1:9333'; +const BASE = 'http://127.0.0.1:8080'; +const SHOTS = new URL('./shots/', import.meta.url).pathname; +const FRAMES = SHOTS + 'ram-charge.frames'; +const MP4 = SHOTS + 'ram-charge.mp4'; +const DT = 40; // ms per simulated slice (25 Hz — coarse enough to keep the headless GPU light) +const STEPS = 60; // 2.4 s of game time: the full 1 s dash + impact + ember tail + +fs.rmSync(FRAMES, { recursive: true, force: true }); +fs.mkdirSync(FRAMES, { recursive: true }); + +const target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(`${BASE}/index.html?combat&skill=max`)}`, { method: 'PUT' })).json(); +const ws = new WebSocket(target.webSocketDebuggerUrl); +let id = 0; +const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { + const mid = ++id; + const to = setTimeout(() => { pending.delete(mid); rej(new Error(`TIMEOUT ${method}`)); }, 25000); + pending.set(mid, { res: (v) => { clearTimeout(to); res(v); }, rej: (e) => { clearTimeout(to); rej(e); } }); + ws.send(JSON.stringify({ id: mid, method, params })); +}); +ws.onmessage = (m) => { + const x = JSON.parse(m.data); + if (x.id && pending.has(x.id)) { + const { res, rej } = pending.get(x.id); + pending.delete(x.id); + x.error ? rej(new Error(x.error.message)) : res(x.result); + } +}; +await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); }); +await send('Page.enable'); +await send('Runtime.enable'); + +const ev = async (expression) => { + const r = await send('Runtime.evaluate', { expression, returnByValue: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? 'eval error'); + return r.result?.value; +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const shot = async (name) => { + const r = await send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(`${FRAMES}/${name}.png`, Buffer.from(r.data, 'base64')); +}; + +// ---- 1. boot --------------------------------------------------------------- +let ready = false; +for (let i = 0; i < 90 && !ready; i++) { + ready = await ev('Boolean(window.__ORBIT_COMBAT__)').catch(() => false); + if (!ready) await ev(`(() => { const m = window.game?.scene?.getScene('MenuScene'); if (m?.startNewGame) m.startNewGame(); return true; })()`) + .catch(() => {}); + await sleep(500); +} +if (!ready) throw new Error('combat never came up'); +await sleep(2500); // intro settle + +// ---- 2. stage --------------------------------------------------------------- +const stage = await ev(`(() => { + const gs = window.game.scene.getScene('GameScene'); + const sys = gs.combat; + if (gs.comms?.paused) { try { gs.comms._ack(); } catch {} } // clear the tutorial hold + gs.comms.queue.length = 0; // keep the clip clean — no comms mid-shot + const s = sys.ship; + const target = sys.spawnEnemy(s.x + 520, s.y, 991); + return { + ship: [Math.round(s.x), Math.round(s.y)], + target: target ? [Math.round(target.x), Math.round(target.y)] : null, + paused: gs.comms?.paused, + }; +})()`); +console.log('stage:', JSON.stringify(stage)); +await sleep(700); + +// ---- 3. pre-cast beat, then freeze + cast ----------------------------------- +await shot('0000'); +await sleep(400); +await shot('0001'); +const cast = await ev(`(() => { + const gs = window.game.scene.getScene('GameScene'); + gs.comms.queue.length = 0; + gs.comms._setPaused(true); // freeze: RAF now skips ship/combat stepping + const oc = window.__ORBIT_COMBAT__; + oc.cast(0); + const d = oc.system.dash; + return { + frozen: gs.comms?.paused === true, + dash: d ? { toX: Math.round(d.toX), toY: Math.round(d.toY), durationMs: d.durationMs } : null, + }; +})()`); +console.log('cast:', JSON.stringify(cast)); + +// ---- 4. hand-step + capture ------------------------------------------------- +let impactStep = -1; +for (let i = 0; i < STEPS; i++) { + const st = await ev(`(() => { + const gs = window.game.scene.getScene('GameScene'); + const dt = ${DT}; + // our own deterministic clock (RAF's is real-time and jumps ~1 s here) + window.__ramT = (window.__ramT ?? gs.time.now) + dt; + const t0 = window.__ramT; + gs.ship.update(t0, dt); + gs.combat.update(t0, dt); + gs.combatBar?.setStats(gs.combat); + gs.combatHud?.update(t0); + const d = gs.combat.dash; + return { + dashAlive: !!d, + shipX: Math.round(gs.ship.x), + nFx: gs.combat.fx.items.length, + }; + })()`); + if (i > 0 && st.dashAlive === false && impactStep === -1) impactStep = i; + await shot(String(i + 2).padStart(4, '0')); + if (i % 10 === 0) console.log(`step ${i}: dash=${st.dashAlive} fx=${st.nFx} x=${st.shipX}`); +} +console.log('impact at step ~', impactStep); + +// ---- 5. encode ---------------------------------------------------------------- +execFileSync('ffmpeg', [ + '-y', '-framerate', '25', + '-i', `${FRAMES}/%04d.png`, + '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', + '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', + MP4, +], { stdio: 'inherit' }); +console.log('video:', MP4); + +// ---- 6. key stills ------------------------------------------------------------- +const still = (stepIdx, name) => { + const src = `${FRAMES}/${String(stepIdx).padStart(4, '0')}.png`; + if (fs.existsSync(src)) { fs.copyFileSync(src, SHOTS + name); console.log('still:', SHOTS + name); } +}; +still(Math.min(STEPS + 1, Math.round(0.20 * 60)), 'ram-dash.png'); // mid-charge +still(Math.max(2, impactStep + 1), 'ram-impact.png'); // the burst +still(Math.min(STEPS + 1, Math.round(1.2 * 60) + 2), 'ram-trail.png'); // ember tail +fs.rmSync(FRAMES, { recursive: true, force: true }); +ws.close(); +process.exit(0); diff --git a/dev/shots/ram-charge.mp4 b/dev/shots/ram-charge.mp4 new file mode 100644 index 0000000..a7b9e52 Binary files /dev/null and b/dev/shots/ram-charge.mp4 differ diff --git a/dev/shots/ram-dash.png b/dev/shots/ram-dash.png new file mode 100644 index 0000000..61ccab6 Binary files /dev/null and b/dev/shots/ram-dash.png differ diff --git a/dev/shots/ram-impact.png b/dev/shots/ram-impact.png new file mode 100644 index 0000000..42a80fa Binary files /dev/null and b/dev/shots/ram-impact.png differ diff --git a/dev/shots/ram-trail.png b/dev/shots/ram-trail.png new file mode 100644 index 0000000..db2b787 Binary files /dev/null and b/dev/shots/ram-trail.png differ diff --git a/js/combat/CamShake.js b/js/combat/CamShake.js index 08a915a..fb59d1a 100644 --- a/js/combat/CamShake.js +++ b/js/combat/CamShake.js @@ -18,19 +18,20 @@ export { ensureUiCameras, assignWorld, assignUi }; * Tuning: data/combat.json → combat.fx.shake — `hit` / `kill` / `break` * are the Phaser shake intensities (a small FRACTION of the viewport, * the same idiom as the tether contact's 0.003–0.007 in GameScene), and - * `ms` is the duration. + * `ms` is the duration. `ram` is the Ramming Charge's impact — the + * heaviest beat in the game (stronger than the shield-collapse). */ /** * @param {object} scene the combat scene - * @param {string} kind 'hit' | 'kill' | 'break' — the fx.shake.* intensity - * to use (the beat's weight: a player hit < an enemy kill < the - * shield-collapse) + * @param {string} kind 'hit' | 'kill' | 'break' | 'ram' — the fx.shake.* + * intensity to use (the beat's weight: a player hit < an enemy kill < + * the shield-collapse < the ram's impact) */ export function combatShake(scene, kind = 'hit') { const cam = scene?.cameras?.main; if (!cam || typeof cam.shake !== 'function') return; - const fallback = { hit: 0.0045, kill: 0.007, break: 0.011 }[kind] ?? 0.005; + const fallback = { hit: 0.0045, kill: 0.007, break: 0.011, ram: 0.014 }[kind] ?? 0.005; const intensity = Number(config.get(`combat.fx.shake.${kind}`, fallback)); if (!Number.isFinite(intensity) || intensity <= 0) return; const ms = Math.max(60, Number(config.get('combat.fx.shake.ms', 230)) || 230); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 86053fc..9009330 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -201,6 +201,11 @@ export class CombatSystem { // null. While it is set the player's steering is held off — the skill owns // the hull for its duration and lands the blast where it stops. this.dash = null; + // The charge's comet head (a LIVE FX handle — js/combat/Fx.js), kept + // OUT of the `dash` record on purpose: that record is spread into + // cast()'s public result (and console-serialized), so it stays a plain + // data bag. The handle lives here, released in _endDash. + this._dashComet = null; this.fx = new Fx(scene); // The whiteness-purge roster: the purge (Fx.purgeCraftTints, every @@ -1704,6 +1709,10 @@ export class CombatSystem { fromX: ship.x, fromY: ship.y, }; + // THE COMET HEAD (js/combat/Fx.js → cometStart) — the fireball that + // rides over the hull for the whole charge (flicker + follow are Fx's + // per-frame business); _endDash releases it the instant the ram lands. + this._dashComet = this.fx.cometStart(ship, aim.angle, Math.max(300, this.dash.durationMs)); // THE CONTROLS ARE LOCKED for the charge: whatever the player was flying at // (the click-to-fly target / the throttle lock) is dropped, every frame of // the dash (a click made MID-charge is not queued up to yank the ship later). @@ -1754,19 +1763,22 @@ export class CombatSystem { ship.y += (dy / dist) * step; } if (ship.body) ship.body.velocity.set((dx / (dist || 1)) * d.speed, (dy / (dist || 1)) * d.speed); - // The streak: a spark every frame along the heading reads as a ram's wake. - this.fx.sparks(ship.x, ship.y, hexToInt(d.fx.trailColor, 0xff3a4d), 2, 60, 220, Math.atan2(dy, dx) + Math.PI, 0.6); + // The WAKE (js/combat/Fx.js → trailEmbers): a white-hot lead, a hot + // core and shed strays that linger ~1 s behind the hull and cool — + // the comet's trail (the data's trailColor, with fallbacks in Fx). + this.fx.trailEmbers(ship.x, ship.y, Math.atan2(dy, dx), hexToInt(d.fx.trailColor, 0xff3a4d)); d.elapsedMs += dt * 1000; if (arrived || d.elapsedMs >= d.durationMs) return this._endDash(); return null; } - /** Land the charge: stop dead (the ram is over) and set the world off. */ + /** Land the charge: stop dead (the ram is over), release the fireball, set the world off. */ _endDash() { const d = this.dash; this.dash = null; if (!d) return null; const ship = this.ship; + if (this._dashComet) { this.fx.cometStop(this._dashComet); this._dashComet = null; } // the impact supersedes the head if (ship?.body) { ship.body.velocity.set(0, 0); ship.body.acceleration?.set?.(0, 0); @@ -1796,10 +1808,17 @@ export class CombatSystem { this.killEnemy(e, this._now()); } } - const ring = hexToInt(fx.ringColor, 0xffd84d); - this.fx.ring(x, y, ring, Math.max(24, (Number(radius) || 0) * 2), Number(fx.ringLifeMs) || 320); - this.fx.flash(x, y, ring, Math.max(16, Number(radius) || 0), 180); + // THE ARRIVAL (js/combat/Fx.js → ramBlast): the white-hot ignition, + // the colored fireball, the ring that settles EXACTLY on the damage + // radius, the shock ripples and the debris — every number from the + // data's fx block (with fallbacks). + this.fx.ramBlast(x, y, radius, fx); + // The voice: the data's sfxEvent (the heavy armor-crack) + the thunder + // boom underneath (the COMBAT_SFX 'kill' voice — the big impact). combatSfx(this.scene, String(fx.sfxEvent || 'hull')); + combatSfx(this.scene, 'kill', { vol: 0.8 }); + // The world kick — the RAM's beat (stronger than a kill, js/combat/CamShake.js). + combatShake(this.scene, 'ram'); return { hits: hit.length, kills, damage }; } diff --git a/js/combat/Fx.js b/js/combat/Fx.js index ccb252d..42edf0d 100644 --- a/js/combat/Fx.js +++ b/js/combat/Fx.js @@ -8,6 +8,23 @@ import { Bullet } from './Bullet.js'; const DOT_KEY = '__combat_dot'; const DOT_SIZE = 64; +const COMET_KEY = '__combat_comet'; +const BLAST_KEY = '__combat_blast'; +const BLAST_RING_KEY = '__combat_blast_ring'; +const BLAST_RING_R = 114; // the rim's radius in the blast-ring texture (256 px) + +// _RAM_BLEND — why the ramming-charge images (comet head, ember trail, +// blast fireball, blast ring) use SCREEN instead of ADD. In this Phaser +// build the WebGL blend functions are (measured live off the renderer): +// ADD = gl.blendFunc(ONE, DST_COLOR) → out = src + dst*dstColor +// SCREEN = gl.blendFunc(ONE, ONE_MINUS_SRC_COLOR) → out = src + dst*(1-src) +// The world (nebula/starfield) is SEMI-TRANSPARENT, so under ADD the whole +// texture quad multiplies the background by its own sub-1 color — a visible +// "dark box" behind every large fire sprite. SCREEN scales the background by +// (1 - srcColor), which is 1 wherever the sprite is transparent, so the world +// shows through untouched at the sprite's edges and the bright core still +// glows. Pair it with COLOR+ALPHA textures (alpha → 0 at the rim), NOT opaque +// fills, so the transparent rim is a true identity. /** * The combat feedback palette (both contexts, one place): @@ -120,6 +137,144 @@ function drawShield(ctx, w, h, span) { * use): a bright arc segment only, centered on angle 0. Rotated to the * laser's incoming bearing it sits on the enemy's shield where it was hit. */ +/** + * THE RAMMING CHARGE'S COMET HEAD (colored fire — drawn in, NOT tinted; + * additive blending does the glow): a white-hot core, an amber bloom, and + * a flame tongue trailing toward −x. Rotating the texture by the charge's + * heading puts the flame BEHIND the hull. 256×256, head at center — the + * head (≈96 px of hot core) fully swallows a ~14 px hull. + */ +function drawComet(ctx, w, h) { + const c = Math.min(w, h) / 2; + const L = c; + const hx = c; + const hy = c; + // WHY COLOR + ALPHA (see the _RAM_BLEND note below the keys): + // The ram images are composited with SCREEN — in this build that is + // gl.blendFunc(ONE, ONE_MINUS_SRC_COLOR), i.e. `out = src + dst*(1-src)`. + // A pixel's SRC COLOR is what controls how much of the world (dst) shows + // through, so the non-fire regions must be TRANSPARENT (alpha 0 ⇒ + // premultiplied src 0 ⇒ dst fully preserved). An OPAQUE fill (even a + // black one) would still carry color/alpha that tints or covers the + // world — that is the "dark box" bug. So every stop below fades the + // alpha to 0 at the outer edge / tail tip, and the core is near-opaque. + // (ADD — gl.blendFunc(ONE, DST_COLOR) — was wrong: its dst term scales the + // nebula by its own color across the WHOLE texture quad, dimming the + // world behind the sprite into a visible square.) + // 1) The wide outer heat wash (hot center → dark red, alpha → 0). + let g = ctx.createRadialGradient(hx, hy, 0, hx, hy, c); + g.addColorStop(0.00, 'rgba(255,225,165,0.95)'); + g.addColorStop(0.28, 'rgba(255,160,70,0.85)'); + g.addColorStop(0.55, 'rgba(255,95,45,0.60)'); + g.addColorStop(0.78, 'rgba(170,45,22,0.32)'); + g.addColorStop(0.92, 'rgba(90,20,10,0.14)'); + g.addColorStop(1.00, 'rgba(60,14,8,0.0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + // 2) The flame tongue — a fat amber body + a hot narrow core, each + // thinning in BOTH width and alpha toward a fully-transparent point. + const tail = (halfW, stops) => { + const tg = ctx.createLinearGradient(hx + 12, hy, hx - L, hy); + for (const [o, col] of stops) tg.addColorStop(o, col); + ctx.fillStyle = tg; + ctx.beginPath(); + ctx.moveTo(hx + 12, hy - halfW); + ctx.quadraticCurveTo(hx - L * 0.35, hy - halfW * 1.3, hx - L, hy); + ctx.quadraticCurveTo(hx - L * 0.35, hy + halfW * 1.3, hx + 12, hy + halfW); + ctx.closePath(); + ctx.fill(); + }; + tail(26, [[0, 'rgba(255,195,105,0.90)'], [0.45, 'rgba(255,120,55,0.60)'], [0.78, 'rgba(150,40,20,0.28)'], [1, 'rgba(80,20,10,0.0)']]); + tail(12, [[0, 'rgba(255,245,215,0.95)'], [0.45, 'rgba(255,210,130,0.70)'], [0.78, 'rgba(190,70,30,0.30)'], [1, 'rgba(100,30,14,0.0)']]); + // 3) The white-hot core (the star at the head's center). + g = ctx.createRadialGradient(hx, hy, 0, hx, hy, c * 0.5); + g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); + g.addColorStop(0.5, 'rgba(255,246,216,0.95)'); + g.addColorStop(0.85, 'rgba(255,210,150,0.55)'); + g.addColorStop(1.0, 'rgba(255,180,110,0.0)'); + ctx.fillStyle = g; + ctx.fillRect(hx - c * 0.5, hy - c * 0.5, c, c); +} + +/** + * THE RAM'S ARRIVAL FIREBALL (colored fire, like the comet — drawn in, + * not tinted): a white-hot center, an amber mid, a red outer wash, and + * eight irregular flame spikes so the burst reads as EXPLOSION, not + * circle. 256×256, ~180 px of visible fire. + */ +function drawBlast(ctx, w, h) { + const c = Math.min(w, h) / 2; + // COLOR + ALPHA (see drawComet). Non-fire regions are alpha 0 → no box. + // The fire wash (white → amber → red → transparent). + let g = ctx.createRadialGradient(c, c, 0, c, c, c); + g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); + g.addColorStop(0.14, 'rgba(255,242,185,0.95)'); + g.addColorStop(0.34, 'rgba(255,170,70,0.78)'); + g.addColorStop(0.58, 'rgba(255,100,48,0.50)'); + g.addColorStop(0.78, 'rgba(180,50,26,0.28)'); + g.addColorStop(0.92, 'rgba(95,22,12,0.12)'); + g.addColorStop(1.0, 'rgba(60,14,8,0.0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + // The flame spikes (each a tapered tongue fading to a transparent point). + ctx.save(); + ctx.translate(c, c); + for (let i = 0; i < 8; i++) { + ctx.rotate((Math.PI / 4) * i + (i % 2 ? 0.16 : -0.11)); + const len = c * (0.82 + 0.14 * (i % 3)); + const sg = ctx.createLinearGradient(0, 0, len, 0); + sg.addColorStop(0.0, 'rgba(255,235,165,0.90)'); + sg.addColorStop(0.5, 'rgba(255,150,65,0.55)'); + sg.addColorStop(0.82, 'rgba(160,45,24,0.26)'); + sg.addColorStop(1.0, 'rgba(90,22,12,0.0)'); + ctx.fillStyle = sg; + ctx.beginPath(); + ctx.moveTo(0, -c * 0.10); + ctx.quadraticCurveTo(len * 0.55, -c * 0.07, len, 0); + ctx.quadraticCurveTo(len * 0.55, c * 0.07, 0, c * 0.10); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); + // The searing center (re-asserted on top of the spikes). + g = ctx.createRadialGradient(c, c, 0, c, c, c * 0.4); + g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); + g.addColorStop(0.6, 'rgba(255,246,215,0.90)'); + g.addColorStop(1.0, 'rgba(255,205,140,0.0)'); + ctx.fillStyle = g; + ctx.fillRect(c - c * 0.4, c - c * 0.4, c * 0.8, c * 0.8); +} + +/** + * THE DAMAGE-RADIUS RING (white alpha, tinted at use): a bright rim with + * a soft inner fill — the one circle that says "everything inside here + * took the hit". The rim sits at radius BLAST_RING_R so the caller can + * scale it to land EXACTLY on the blast radius (skillBlast's radius). + */ +function drawBlastRing(ctx, w, h) { + const c = Math.min(w, h) / 2; + const R = BLAST_RING_R; + // The faint inner fill (the blast AREA, readable at a glance). + const g = ctx.createRadialGradient(c, c, 0, c, c, R); + g.addColorStop(0.0, 'rgba(255,255,255,0.30)'); + g.addColorStop(0.80, 'rgba(255,255,255,0.22)'); + g.addColorStop(0.92, 'rgba(255,255,255,0.85)'); + g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + // The bright rim (a wide soft pass under a thin hot one). + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 16; + ctx.beginPath(); + ctx.arc(c, c, R, 0, Math.PI * 2); + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,0.95)'; + ctx.lineWidth = 5; + ctx.beginPath(); + ctx.arc(c, c, R, 0, Math.PI * 2); + ctx.stroke(); +} + const ARC_KEY = '__combat_arc'; function drawArc(ctx, w, h) { @@ -204,6 +359,27 @@ export class Fx { return Bullet.ensureTexture(scene); } + /** The comet-head texture (colored fire — never tinted). @returns {string} the key */ + static ensureComet(scene) { + if (scene.textures.exists(COMET_KEY)) return COMET_KEY; + canvasTexture(scene, COMET_KEY, 256, 256, (ctx, w, h) => drawComet(ctx, w, h)); + return COMET_KEY; + } + + /** The arrival-fireball texture (colored fire — never tinted). @returns {string} the key */ + static ensureBlast(scene) { + if (scene.textures.exists(BLAST_KEY)) return BLAST_KEY; + canvasTexture(scene, BLAST_KEY, 256, 256, (ctx, w, h) => drawBlast(ctx, w, h)); + return BLAST_KEY; + } + + /** The damage-radius ring texture (white alpha, tinted at use). @returns {string} the key */ + static ensureBlastRing(scene) { + if (scene.textures.exists(BLAST_RING_KEY)) return BLAST_RING_KEY; + canvasTexture(scene, BLAST_RING_KEY, 256, 256, (ctx, w, h) => drawBlastRing(ctx, w, h)); + return BLAST_RING_KEY; + } + /** The craft-shield arc texture (shared). @returns {string} the key */ static ensureArc(scene) { if (scene.textures.exists(ARC_KEY)) return ARC_KEY; @@ -213,23 +389,26 @@ export class Fx { // ----------------------------------------------------------- the basics - /** An additive glow: `size` px across at full strength, fading over `life` ms. */ + /** A glow: `size` px across at full strength, fading over `life` ms. SCREEN + * (see the _RAM_BLEND note) so the glow never dims the world behind it. */ flash(x, y, color, size = 40, life = 220) { const img = this._world(this.scene .add.image(x, y, DOT_KEY) .setTint(toColor(color, 0xffffff)) - .setBlendMode(Phaser.BlendModes.ADD) + .setBlendMode(Phaser.BlendModes.SCREEN) .setDepth(20)); this.items.push({ img, t0: 0, life, kind: 'flash', grow: size / DOT_SIZE }); return img; } - /** A growing, fading disc — the shockwave read (a stand-in for a stroke ring). */ + /** A growing, fading disc — the shockwave read (a stand-in for a stroke ring). + * SCREEN (see _RAM_BLEND): this disc grows large, so ADD would dim the whole + * quad into a visible dark box over the nebula. */ ring(x, y, color, size = 180, life = 450) { const img = this._world(this.scene .add.image(x, y, DOT_KEY) .setTint(toColor(color, 0xffffff)) - .setBlendMode(Phaser.BlendModes.ADD) + .setBlendMode(Phaser.BlendModes.SCREEN) .setDepth(19) .setScale(6 / DOT_SIZE) .setAlpha(0.8)); @@ -252,7 +431,7 @@ export class Fx { const img = this._world(this.scene .add.image(x, y, DOT_KEY) .setTint(toColor(color, 0xffffff)) - .setBlendMode(Phaser.BlendModes.ADD) + .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note .setDepth(21) .setScale(s / DOT_SIZE)); this.items.push({ @@ -330,6 +509,107 @@ export class Fx { return flare; } + // ------------------------------------------------------- the ram's fire + + /** + * A lingering EMBER (the comet trail's grain): a small additive dot that + * drifts slowly and cools — it grows a touch as it fades (a fireball + * expanding as it cools), with a SLOW quadratic fade so the wake lingers + * for its whole ~1 s life instead of popping out. Depth 11 (just above + * the hull at 10) so the trail sits ON the wake's path, not under the + * blast's rings. + */ + ember(x, y, color, size = 12, life = 1000, drift = 26, alpha0 = 0.9) { + const a = Math.random() * Math.PI * 2; + const sp = drift * (0.3 + Math.random() * 0.7); + const s0 = Math.max(2, size) / DOT_SIZE; + const img = this._world(this.scene + .add.image(x, y, DOT_KEY) + .setTint(toColor(color, 0xffffff)) + .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note + .setDepth(11) + .setScale(s0)); + this.items.push({ + img, + t0: 0, + life: life * (0.75 + Math.random() * 0.5), + kind: 'ember', + vx: Math.cos(a) * sp, + vy: Math.sin(a) * sp, + s0, + alpha0, + }); + return img; + } + + /** + * One frame of the charge's WAKE (called every dash frame from + * js/combat/CombatSystem.js → _stepDash): a white-hot lead dot on the + * hull's path, the hot core a hair behind it, and two strays thrown a + * little to the side — so the trail reads as a voluminous fireball + * shedding embers, not a single streak. + */ + trailEmbers(x, y, angle, color) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + this.ember(x, y, 0xffffff, 11, 1050); + this.ember(x - cos * 6, y - sin * 6, color, 16, 1150); + for (let i = 0; i < 2; i++) { + const off = (Math.random() * 2 - 1) * 11; + this.ember(x - sin * off, y + cos * off, i === 0 ? color : 0xffffff, 7 + Math.random() * 7, 950); + } + } + + /** + * THE COMET HEAD — the fireball that rides over the hull for the whole + * charge. One additive image (drawComet's colored fire) that FLOWING + * follows `target` every frame, holds `angle` (the charge heading — the + * flame tongue trails behind), and FLICKERS (two incommensurate sines + * on the scale) so it breathes like a flame, then fades out its last + * 140 ms. `cometStop()` kills it early (the ram landing). Returns a + * handle for that — Fx items are otherwise owned by the update loop. + * + * @param {object} ship the hull to ride (needs .x/.y/.radius) + * @param {number} angle the charge heading (rad) + * @param {number} lifeMs how long it burns (the charge's duration) + * @returns {{img: object}} the handle (pass to cometStop) + */ + cometStart(ship, angle, lifeMs = 800) { + const r = Math.max(10, Number(ship?.radius) || 14); + const img = this._world(this.scene + .add.image(ship?.x ?? 0, ship?.y ?? 0, Fx.ensureComet(this.scene)) + .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note + .setDepth(11) // just above the hull (10) — the fire sits ON the ship + .setAngle(angle * 180 / Math.PI)); + // The head's hot core (~96 px in texture space at the 256 canvas) must + // fully swallow the hull: target head radius ≈ 1.8× the hull's — a + // COMET, not a spark. The tail (128 px) then streams ~half a screen + // of flame behind the ship at this scale. + const s0 = Math.max(44, r * 4) / 128; + img.setScale(s0); + this.items.push({ + img, + t0: 0, + life: Math.max(1, Number(lifeMs) || 300), + kind: 'comet', + target: ship, + s0, + }); + return { img }; + } + + /** Kill a live comet NOW (the impact supersedes it). Safe twice. */ + cometStop(handle) { + if (!handle) return; + for (let i = this.items.length - 1; i >= 0; i--) { + const it = this.items[i]; + if (it.kind === 'comet' && it.img === handle.img) { + it.img.destroy(); + this.items.splice(i, 1); + } + } + } + // ----------------------------------------------------------- the impacts /** @@ -612,8 +892,9 @@ export class Fx { } /** - * A ring that waits `delay` ms before it starts (the kill burst's - * second shockwave). Spawned invisible until its delay runs out. + * A LINGERING SHOCK RING (the kill burst's second shockwave's cousin): + * like ring() but with a `delay` before it starts. Spawned invisible + * until its delay runs out. */ delayedRing(x, y, color, size, life, delay) { const img = this._world(this.scene @@ -627,6 +908,74 @@ export class Fx { return img; } + /** + * THE DAMAGE-RADIUS RING (the ram's arrival signature): a bright rim + * with a soft fill that expands fast and eases out to land EXACTLY on + * `radius` (the blast's damage reach — the one honest circle in the + * scene). Tinted `color`; eases with a decaying velocity (a shockwave + * outrunning its own energy) over `life` ms. + */ + blastRing(x, y, color, radius, life = 480) { + const img = this._world(this.scene + .add.image(x, y, Fx.ensureBlastRing(this.scene)) + .setTint(toColor(color, 0xffffff)) + .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note + .setDepth(19) + .setScale(6 / 256) + .setAlpha(0)); + this.items.push({ + img, + t0: 0, + life, + kind: 'blastRing', + from: 6 / 256, + to: Math.max(6, Number(radius) || 6) / BLAST_RING_R, + }); + return img; + } + + /** + * THE RAM'S ARRIVAL (the impact the whole charge is building to): + * 1. a white-hot core igniting at the stop point, + * 2. the FIREBALL — colored fire (drawBlast) blooming out and cooling + * over ~half a second, + * 3. the DAMAGE-RADIUS ring settling exactly on the blast reach + * (the visible damage area, tinted the data's ringColor), + * 4. a wide ripple + a delayed ember-colored ring (the shock passing + * beyond the kill zone), + * 5. debris — fast sparks in the ring's color, a white-hot flash + * burst, and slow embers that drift and cool for ~1 s. + * Pure visuals (the voice is the caller's — skillBlast plays the data's + * sfxEvent + the thunder boom). `fx` = the skill's fx block from + * data/skills.json (every key has a fallback). + */ + ramBlast(x, y, radius, fx = {}) { + const R = Math.max(24, Number(radius) || 0); + const ringColor = toColor(fx.ringColor, 0xffd84d); + const fireColor = toColor(fx.trailColor, 0xff3a4d); + const ringLife = Math.max(300, Number(fx.ringLifeMs) || 520); + + // 1) The ignition — the white-hot stop point. + this.flash(x, y, 0xffffff, R * 1.4, 170); + // 2) The fireball (colored, additive — the impact's body). + const fb = this._world(this.scene + .add.image(x, y, Fx.ensureBlast(this.scene)) + .setBlendMode(Phaser.BlendModes.SCREEN) // see _RAM_BLEND note + .setDepth(20)); + const s0 = R / 80; // the ~180 px fire wash covers ≈2× the blast radius + fb.setScale(s0 * 0.55); + this.items.push({ img: fb, t0: 0, life: 520, kind: 'fireball', s0 }); + // 3) The honest circle — the damage radius itself. + this.blastRing(x, y, ringColor, R, ringLife); + // 4) The shock outrunning the kill zone. + this.ring(x, y, ringColor, R * 2.1, Math.round(ringLife * 1.15)); + this.delayedRing(x, y, fireColor, R * 2.8, 640, 130); + // 5) Debris — fast + white-hot + slow-cooling. + this.sparks(x, y, ringColor, 24, 430, 620); + this.sparks(x, y, 0xffffff, 8, 300, 380); + this.sparks(x, y, fireColor, 16, 150, 950); + } + /** * The player-DOWN cue: the death burst in hot red + a big DOWN hold * (the "the hull broke" beat before the reform clock). @@ -694,6 +1043,36 @@ export class Fx { it.vx *= decay; it.vy *= decay; it.img.setAlpha(1 - t); + } else if (it.kind === 'ember') { + // The wake's grain — drift, grow as it cools, slow quadratic fade. + it.img.x += it.vx * dt; + it.img.y += it.vy * dt; + const decay = Math.max(0, 1 - 1.6 * dt); + it.vx *= decay; + it.vy *= decay; + it.img.setAlpha(Math.pow(1 - t, 1.7) * (it.alpha0 ?? 0.9)); + it.img.setScale(it.s0 * (1 + 0.5 * t)); + } else if (it.kind === 'comet') { + // The fireball riding the hull — follow, hold the heading, flicker. + const tg = it.target; + if (tg && !tg.destroyed) { + it.img.x = tg.x; + it.img.y = tg.y; + } + const ts = eff / 1000; + const flick = 1 + 0.1 * Math.sin(ts * 46) + 0.06 * Math.sin(ts * 83 + 1.7); + const endFade = Math.max(0, Math.min(1, (it.life - eff) / 140)); + it.img.setScale(it.s0 * flick); + it.img.setAlpha(0.95 * endFade); + } else if (it.kind === 'fireball') { + // The arrival fire — bloom out fast, cool (shrink the glow), fade. + it.img.setScale(it.s0 * (0.55 + 1.15 * Math.pow(t, 0.8))); + it.img.setAlpha(Math.pow(1 - t, 1.25)); + } else if (it.kind === 'blastRing') { + // The damage-radius ring — fast expansion, decaying velocity. + const e = 1 - Math.pow(1 - t, 2.2); + it.img.setScale(it.from + (it.to - it.from) * e); + it.img.setAlpha(0.95 * Math.pow(1 - t, 1.4)); } else if (it.kind === 'muzzle') { it.img.setAlpha(it.alpha0 * Math.pow(1 - t, 1.4)); it.img.setScale(it.scale0 * (1 + 1.1 * t));