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:
parent
4aa0785b0d
commit
602eaf0c9e
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
|
|
@ -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 }
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(' | '));
|
||||
}
|
||||
|
|
@ -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); });
|
||||
|
|
@ -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);
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
393
js/combat/Fx.js
393
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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue