156 lines
6.3 KiB
JavaScript
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);
|
|
}
|