orbit/js/visuals/UiCameras.js

156 lines
6.3 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)
);
}
/**
* The stored split, validated against the LIVE camera list — or null
* (and cleared) when it is stale.
*
* A scene restart (the jump cut — GameScene._finishJump) runs the
* camera manager's shutdown, which DESTROYS every camera (fx-ui
* included) and re-adds a single fresh main. The stored split would
* then point at dead cameras, and worse: a later `main.ignore()` sets
* the OLD main's id bit on the object, and the re-created main reuses
* id 1 — so an object "assigned to the UI pass" is ignored by the ONLY
* live pass and drawn by no camera at all. That is the jump-clip
* "plays in the background" bug: after leaving an effects system, the
* next system's clip + opaque backdrop rendered nowhere while the world
* stayed fully visible. A split whose cameras are no longer in the
* scene's list is invalid — drop it so the pipeline falls back to a
* single pass (depth order restores the clip's topness) and a fresh
* split can be created if/when one is needed.
*
* @param {object} scene Phaser scene
* @returns {{ main: object, ui: object } | null} the live split, or null
*/
function liveSplit(scene) {
const split = scene._fxUiCameras;
const cams = scene.cameras?.cameras;
if (
split &&
split.ui &&
split.main &&
Array.isArray(cams) &&
cams.includes(split.main) &&
cams.includes(split.ui)
) {
return split;
}
if (split) scene._fxUiCameras = null; // stale (a restart rebuilt the cameras) — forget it
return null;
}
/**
* 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 (liveSplit(scene)) 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 = liveSplit(scene);
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 = liveSplit(scene);
if (split && child) split.ui.ignore(child);
}