Polish Ramming Charge FX with fire textures and SCREEN blend

- Add canvas-drawn comet head, arrival fireball, and damage-radius ring textures in Fx.js, replacing the old single-dot streak/ring combo
- Switch all ram-related (and general glow) sprites from ADD to SCREEN blend to fix the "dark box" artifact over semi-transparent nebula backgrounds
- Introduce ember trail particles that linger ~1 s behind the hull during the dash, plus a flickering comet head that rides the ship and is released on impact
- Add `ramBlast()` impact sequence: ignition flash, blooming fireball, exact damage-radius ring, shock ripples, and debris sparks
- Wire in the heaviest camera shake beat (`ram`) and layer the data's sfxEvent under a thunder boom at impact
- Bump skill fx `ringLifeMs` to 480 so the damage-radius ring lingers long enough to read
- Add dev CDP harnesses (`cdp.mjs`, `ram-shot.mjs`, `ram-video.mjs`) and reference stills/video for the charge's three key frames
This commit is contained in:
Brian Fertig 2026-09-27 14:28:22 -06:00
parent 4aa0785b0d
commit 602eaf0c9e
14 changed files with 816 additions and 22 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -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 }
},

View File

@ -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"
}
}

87
dev/cdp.mjs Normal file
View File

@ -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(' | '));
}

152
dev/ram-shot.mjs Normal file
View File

@ -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); });

156
dev/ram-video.mjs Normal file
View File

@ -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);

BIN
dev/shots/ram-charge.mp4 Normal file

Binary file not shown.

BIN
dev/shots/ram-dash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
dev/shots/ram-impact.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

BIN
dev/shots/ram-trail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View File

@ -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);

View File

@ -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 };
}

View File

@ -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));