/** * The UI camera split — the seam that lets a system effect (a camera * filter on the WORLD camera) warp the world while the HUD stays crisp. * * How Phaser 4 draws cameras: each camera is one render pass, in * `scene.cameras` array order (the array's FIRST camera draws first, so * later cameras draw ON TOP). Every pass can composite through its own * `filters.internal` list (WebGL). `camera.ignore(targets)` marks the * targets (and their container descendants) with the camera's id bit — * `object.willRender(camera)` then fails for that camera — so a target * simply does not exist for that pass. There is no un-ignore; the * partition is set once. * * The split this module owns: * - `cameras[0]` = main — the WORLD camera (scrolls with the ship). * The system effect's filter is attached here. * - `cameras[1]` = `fx-ui` — the UI camera (fixed viewport). It is * force-composited (rendered to a framebuffer, then blitted over * the main pass) so it draws on top WITHOUT clearing the main * camera's output — a second non-composited pass would clear the * canvas and erase the world. * * Partition rule (heuristic, deliberate): a top-level scene object is * UI iff it is screen-pinned (scrollFactor 0 on either axis). That is * exactly this game's screen/world divide — the HUD, deck, panels and * toasts are scrollFactor 0; the starfield, worlds, gates, tethers, * the ship, even the world-anchored pop-ups (comms, mining) are not. * Screen-pinned objects are ignored by MAIN (they exist only for the UI * pass); world objects are ignored by the UI pass (they exist only for * the world pass, and are what the effect displaces — starfield * included, for free). * * Consequences to remember (audited, deliberate): * - The UI no longer shakes with the world camera (shake is a * camera effect — the UI camera is untouched). * - Input is camera-aware: the interaction manager hit-tests each * camera under the pointer, in REVERSE array order — the UI pass * first — and an object is only a candidate where `willRender` * holds. UI-over-world clicks therefore keep working, with the UI * winning (the same topness the single-camera depth order gave). * * Lazily created objects (toasts, the jump clip, world markers, the * mining beam) are assigned explicitly at their creation sites via * assignUi()/assignWorld() below. */ /** * @param {object} scene Phaser scene * @returns {boolean} true if the object is screen-pinned (UI) */ export function isScreenPinned(obj) { return ( typeof obj?.scrollFactorX === 'number' && (obj.scrollFactorX === 0 || obj.scrollFactorY === 0) ); } /** * Create (once) the fixed UI camera and partition the scene's * top-level objects between the two passes. Idempotent — subsequent * calls return the existing split. * * @param {object} scene Phaser scene * @returns {{ main: object, ui: object } | null} the split, or null if * the scene has no camera plugin (defensive). */ export function ensureUiCameras(scene) { if (scene._fxUiCameras) return scene._fxUiCameras; const cameras = scene.cameras; if (!cameras?.cameras || cameras.cameras.length === 0) return null; const main = cameras.main; const ui = cameras.add(0, 0, scene.scale.width, scene.scale.height, false, 'fx-ui'); // The UI pass must composite (FBO + blit over the main pass) — a // second DIRECT pass would clear() the canvas and erase the world. if (typeof ui.setForceComposite === 'function') ui.setForceComposite(true); const uiRoots = []; const worldRoots = []; const children = scene.sys.displayList?.getChildren?.() ?? []; for (const child of children) { if (isScreenPinned(child)) uiRoots.push(child); else worldRoots.push(child); } if (uiRoots.length) main.ignore(uiRoots); // UI: world pass never sees it if (worldRoots.length) ui.ignore(worldRoots); // world: UI pass never sees it scene._fxUiCameras = { main, ui, uiRoots: uiRoots.length, worldRoots: worldRoots.length }; return scene._fxUiCameras; } /** * Assign a lazily created screen-pinned object to the UI pass (main * ignores it). No-op while the split does not exist — the single-camera * pipeline renders it exactly as before. * * @param {object} scene Phaser scene * @param {object} child object to attach to the UI pass */ export function assignUi(scene, child) { const split = scene._fxUiCameras; if (split && child) split.main.ignore(child); } /** * Assign a lazily created world object to the world pass (the UI camera * ignores it, so it is not drawn twice — semi-transparent world objects * would otherwise additively double). No-op while the split does not * exist. * * @param {object} scene Phaser scene * @param {object} child object to keep on the world pass */ export function assignWorld(scene, child) { const split = scene._fxUiCameras; if (split && child) split.ui.ignore(child); }