118 lines
4.9 KiB
JavaScript
118 lines
4.9 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|