From 9ec2d6d27cedc202729eb0be46d0bb2242bbd3b7 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 31 Aug 2026 19:15:51 +0800 Subject: [PATCH] Container: order floating children by depth alone, not by screen position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Container.draw` gives a `floating` child `resetTransform()` and the camera's screen projection, so its `pos.x/y` are canvas pixels rather than a place in the world. `_sortDepth` fed those to a world-space distance and subtracted the camera position on top of that, with two consequences under a `Camera3d`: - a HUD's layering depended on where it sat on the SCREEN. A score in a corner scored 20² + 16² and floated above the scene; the same text centred scored 512² + 200² and sank behind it. - it drifted as the camera travelled, via the (z - camZ)² term, so a HUD that was correct at the start of a level was buried by the end. A floating child is now ordered by |pos.z| alone. Magnitude is what the screen-space idioms already encode — the flight demo's HUD sits at -150, and the glTF, Billboard, Night City and Instanced Forest examples park a floating sky at -10000 or 100000 — and the old key squared the distance, so the sign never carried meaning. Reading it as meaningful would put those four skies in front of their own scenes; the new key ignores it. World children are untouched. Tests cover both idioms at extreme camera positions, and are checked against four mutations: the pre-fix key (10 fail), floating forced to the front (11), a signed key (5), and keeping the screen coordinates (6). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/skills/melonjs-3d/SKILL.md | 16 + .../skills/melonjs-ui-and-text/SKILL.md | 144 +------ packages/melonjs/src/renderable/container.js | 44 ++- .../melonjs/tests/floating-depth-sort.spec.js | 352 ++++++++++++++++++ 5 files changed, 423 insertions(+), 134 deletions(-) create mode 100644 packages/melonjs/tests/floating-depth-sort.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f33443441..dc333dfb9 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Mesh: `alpha = 0` painted the mesh **opaque black** instead of hiding it, on both GPU backends. `CanvasRenderer.drawMesh` has always skipped when the global alpha falls below `1/255` — the same guard eight other Canvas draw methods use — but neither GPU renderer had it, and the mesh path disables blending (`MeshBatcher.bind`), so the alpha never reached the blend stage: the shader multiplied the colour by zero and wrote the result opaque. Hiding a mesh with `alpha = 0` left a black silhouette of it, and the same property behaved differently per backend. Both GPU renderers now skip at the same threshold - Color: `toUint32()` returned a **negative** number for any colour with alpha at or above 0.5. The packing used `|`, which yields a signed int32, so a method named `toUint32` — documented as returning "a Uint32 ARGB representation" — handed back e.g. `-16711936` for green. Every consumer inside the engine writes it into a `Uint32Array` or a shader attribute where the bit pattern is identical, so nothing rendered wrong; what broke was reading the value back, comparing it, or printing it. The four unit tests covering this had the correct expectations commented out and the signed values asserted instead +- Container: a `floating` child in a depth-sorted world was ordered by its **screen** position. `Container.draw` gives a floating child `resetTransform()` and the camera's screen projection, so its `pos.x/y` are canvas pixels — but `_sortDepth` fed those to a world-space distance and subtracted the camera position on top. Two consequences, both visible under a `Camera3d`: a HUD's layering depended on where it sat on the screen (a score in a corner scored `20² + 16²` and floated above the scene, while the same text centred scored `512² + 200²` and sank behind it), and it drifted as the camera travelled, so a HUD that was correct at the start of a level was buried by the end of it. A floating child is now ordered by `|pos.z|` alone — a small depth draws in front of the world, a large one behind it — which is the convention screen-space content already used (a HUD at -150, a sky backdrop at -10000 or 100000), now holding at any camera position and from anywhere on the screen rather than by luck of the numbers ## [20.3.0] (melonJS 2) - _2026-08-31_ diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 038ebdf95..b417dcd41 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -97,6 +97,21 @@ index** — a real world depth in a 3D scene, and never the one you wanted. Pass it (`world.addChild(mesh, z)`), or set `mesh.depth` afterwards. The glTF importer turns `autoDepth` off on the container it loads into for this reason. +**`floating` does not opt out of this sort.** It skips the camera *transform*, +not the depth *order*. A floating child is ordered by `|pos.z|` alone — its +`pos.x/y` are screen pixels, and the camera does not move relative to it — so +the magnitude is the distance and the sign is ignored: + +```js +world.addChild(hud, -150); // small -> nearer than anything -> on top +world.addChild(skybox, -10000); // large -> farther -> behind everything +world.addChild(skybox, 100000); // equally far: sign does not matter +``` + +Both hold at any camera position. A HUD given the huge z that would put it on +top in 2D lands at the far end of the level instead, with the scenery drawing +over it. + ## Meshes ```js @@ -296,6 +311,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | distant surfaces z-fight | `near` too small for the scene scale | | black canvas under `Camera3d` | Canvas renderer (no depth buffer) — check the `console.warn` | | everything flat and unlit | `lit: true` with no `Light3d` in the world (falls back to fullbright), or a mesh under a 2D camera | +| a `floating` HUD draws behind the scenery | a large \|z\| is *far* under `Camera3d` — use a small depth | | a mesh sits at the wrong depth after being added | `autoDepth` overwrote `pos.z` with the child index — pass `addChild(mesh, z)` | | a mesh sits half its size off | `anchorPoint` — only on the 2D-camera path; a `Camera3d` mesh pivots on its model origin | | a billboard tips over when the camera looks down | `"spherical"`, or a mistyped mode string falling through to it — use `true` / `"cylindrical"` | diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 26c355327..e4020c5a0 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -39,137 +39,27 @@ Three things matter here: real accessor is `depth` (an alias for `pos.z`); `addChild(child, z)` sets it for you. -Relayout on resize by listening for `event.CANVAS_ONRESIZE`. +### In a 3D scene, a HUD needs a SMALL depth -## Buttons and interactive elements +`floating` opts a renderable out of the camera transform. It does **not** opt it +out of the depth sort, and the two sorts read z differently: -Use the built-ins rather than hand-rolling — they set `isKinematic = false` for -you, which is the trap that stops hand-rolled buttons receiving clicks at all. +| container `sortOn` | ordered by | on top | +| --- | --- | --- | +| `"z"` (default, 2D) | `pos.z` | **highest** z | +| `"depth"` (what `Camera3d` sets) | distance from the camera | **nearest** the camera | -```js -class PlayButton extends UISpriteElement { - constructor(x, y) { - super(x, y, { image: atlas, region: "play.png" }); - } - onClick() { state.change(state.PLAY); return false; } - onOver() { this.setOpacity(1.0); } - onOut() { this.setOpacity(0.8); } - onRelease() { return false; } -} -``` - -| class | for | -|---|---| -| `UIBaseElement` | a `Container` base — clickable, optionally draggable and holdable | -| `UISpriteElement` | a `Sprite`-backed button with hover/press callbacks | -| `UITextButton` | a `BitmapText` label on a `RoundRect` background — it needs a **bitmap** font, not a `fontface` | -| `Draggable` / `DropTarget` | drag-and-drop; `dropTarget.setCheckMethod(dropTarget.CHECKMETHOD_CONTAINS)` to require containment instead of overlap | - -The callbacks to override are `onClick`, `onOver`, `onOut`, `onRelease` and -`onHold`; `UIBaseElement` adds `onMove` while dragging. Returning `false` from -`onClick` / `onRelease` stops the event propagating further. - -## Text - -```js -new Text(x, y, { - font: "Arial", - size: 32, - fillStyle: "#FFFFFF", - textAlign: "center", - textBaseline: "middle", - text: "Score: 0", - wordWrapWidth: 400, // enables wrapping -}); -``` - -`text` accepts a string or an array of lines. Update with `setText()`. - -**Custom web fonts must be preloaded** with the `"fontface"` asset type: - -```js -{ name: "PressStart2P", type: "fontface", src: "data/font/PressStart2P.ttf" } -``` - -Drawing before the font has loaded silently renders in a fallback font — the -layout looks subtly wrong rather than failing. - -## `BitmapText` - -For pixel-perfect text that scales without antialiasing, and for text drawn in -volume: every `BitmapText` sharing a font draws glyph quads from that one page -image, so they batch together. Each `Text` instead owns a private canvas -texture (re-rasterised whenever it changes), so a screenful of them is a -screenful of distinct textures. +Under `"depth"` a floating child is ordered by `|pos.z|` alone — its `pos.x/y` +are screen pixels, not a place in the world, and the camera does not move +relative to it. So the *magnitude* is the distance, and the sign is ignored: ```js -await loader.preload([ - { name: "PressStart2P", type: "image", src: "data/font/font.png" }, - { name: "PressStart2P", type: "binary", src: "data/font/font.fnt" }, -]); - -new BitmapText(x, y, { - font: "PressStart2P", - size: 2, // a scale ratio, not a pixel size - text: "GAME OVER", -}); -``` - -It needs **both** assets, and by default they must share the **same asset -name** — `settings.font` resolves the image *and*, unless you pass -`settings.fontData`, the descriptor. Registering the descriptor under a -different name (`"…-fnt"`) is the usual mistake; either use one name for both, -or pass `fontData: "PressStart2P-fnt"` explicitly. - -The descriptor is AngelCode BMFont in either flavour — the text `.fnt` form or -the XML form — auto-detected, so an `.xml` export loads as-is. - -## Never draw text through the raw context - -```js -// ✗ works on Canvas only — getContext() returns the GL/GPU context on the -// GPU backends, and neither has fillText -app.renderer.getContext().fillText("hi", 10, 10); - -// ✓ -world.addChild(new Text(10, 10, { text: "hi", /* … */ })); -``` - -`getContext()` hands back the *backend's* context — a -`CanvasRenderingContext2D` only under the Canvas renderer. On WebGL or WebGPU -the call throws `TypeError: … .fillText is not a function`, so this fails loudly -— but only on the machines that picked a GPU backend, which under `video.AUTO` -is most of them and probably not yours. - -## Panels - -`NineSliceSprite` stretches a panel without distorting its corners — the right -tool for dialogue boxes and windows: - -```js -new NineSliceSprite(x, y, { - image: "panel", width: 300, height: 120, insetx: 12, insety: 12, -}); +world.addChild(hud, -150); // small -> in front of the whole scene +world.addChild(backdrop, -10000); // large -> behind the whole scene +world.addChild(backdrop, 100000); // equally far: sign does not matter ``` -The inset keys are lowercase `insetx` / `insety`; `insetX` is silently ignored -and the corners fall back to a quarter of the frame. `width` and `height` are -mandatory — the constructor throws without them. - -## Symptom → cause - -| symptom | cause | -|---|---| -| HUD scrolls away with the camera | missing `floating = true` on the container | -| HUD drawn under the game | `this.z = …` instead of `addChild(hud, z)` | -| hand-rolled button never responds | `isKinematic` left `true` — use `UISpriteElement` | -| text renders in the wrong font | web font not preloaded as `"fontface"` | -| `BitmapText` renders nothing | the `.fnt` / image pair was loaded under two different asset names | -| `TypeError: … .fillText is not a function` | drawn via `getContext()` under a GPU backend | -| `UIContainer is not defined` | no such class — use `Container` or `UIBaseElement` | -| UI misplaced after a window resize | no `CANVAS_ONRESIZE` relayout | - -## Related skills - -- `melonjs-input` — the `isKinematic` requirement in full -- `melonjs-renderables` — `floating`, draw order, containers +Give a HUD the huge z that would put it on top in 2D and it lands at the far end +of the level instead, with the scenery drawing over it. Both shipped idioms are +the same rule: afterBurner's HUD sits at `-150`, and the glTF, Billboard, Night +City and Instanced Forest examples park a floating sky at `-10000` or `100000`. diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index af442fd39..697c42d51 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -66,6 +66,42 @@ function captureDepthCamera() { } } +/** + * Painter-sort key: squared distance from the camera, so a smaller key + * is nearer. `draw` walks children backwards, so index 0 is drawn last + * and the nearest child lands on top. + * + * A `floating` child is measured differently, because its `pos` is not + * a world position: `draw` resets the transform and swaps in the + * camera's screen projection for it, making `pos.x/y` pixels on the + * canvas. Feeding those to a world-space distance makes a HUD's + * layering depend on where it sits on the screen — a centred banner + * scores `512² + 200²` against the scene and sinks behind it while the + * same text in a corner floats on top — and subtracting the camera + * position makes it drift as the camera travels. Neither is meaningful + * for something pinned to the screen, so only `pos.z` orders it. + * + * What remains is the magnitude: `|pos.z|` is how far in front of the + * scene the overlay sits, so a small depth draws on top of the world and + * a large one draws behind it. That is the convention the screen-space + * idioms already use — a HUD at -150, a sky backdrop at -10000 or + * 100000 — and dropping the screen coordinates and the camera position + * makes it hold at any camera position instead of by luck of the + * numbers. The sign is not meaningful here and is ignored: a backdrop + * parked at a large negative depth is as far away as one parked at the + * same positive depth. + * @ignore + */ +function depthKey(r) { + if (r.floating === true) { + return r.pos.z * r.pos.z; + } + const x = r.pos.x + _depthOffsetX - _depthCamX; + const y = r.pos.y + _depthOffsetY - _depthCamY; + const z = r.pos.z + _depthOffsetZ - _depthCamZ; + return x * x + y * y + z * z; +} + /** * Capture the world-space position of the given container into the * module-level `_depthOffset*` triple. Called once per sort by `sort` / @@ -1073,13 +1109,7 @@ export default class Container extends Renderable { // between "particles all sort identically because their // local d²=0" and "particles sort by their actual world // distance from the camera". - const ax = a.pos.x + _depthOffsetX - _depthCamX; - const ay = a.pos.y + _depthOffsetY - _depthCamY; - const az = a.pos.z + _depthOffsetZ - _depthCamZ; - const bx = b.pos.x + _depthOffsetX - _depthCamX; - const by = b.pos.y + _depthOffsetY - _depthCamY; - const bz = b.pos.z + _depthOffsetZ - _depthCamZ; - return ax * ax + ay * ay + az * az - (bx * bx + by * by + bz * bz); + return depthKey(a) - depthKey(b); } /** diff --git a/packages/melonjs/tests/floating-depth-sort.spec.js b/packages/melonjs/tests/floating-depth-sort.spec.js new file mode 100644 index 000000000..b62499b3c --- /dev/null +++ b/packages/melonjs/tests/floating-depth-sort.spec.js @@ -0,0 +1,352 @@ +/** + * How a `floating` child is ordered by the `"depth"` sort. + * + * `Container.draw` gives a floating child `resetTransform()` plus the camera's + * screen projection, so its `pos.x/y` are pixels on the canvas — not a place in + * the world. `_sortDepth` nevertheless fed those pixels to a world-space + * distance and subtracted the camera position, with two visible consequences: + * + * - a HUD's layering depended on where it sat on the SCREEN. A corner score + * at (20, 16) scored 656 and floated on top; the same text centred at + * (512, 200) scored 302144 and sank behind the scenery. + * - it drifted as the camera travelled, because of the `(z - camZ)²` term. + * A HUD correct at the start of a level was buried by the end of it. + * + * Both screen-space idioms have to survive: an OVERLAY at a negative depth + * stays in front of the world, and a BACKDROP at a large positive depth stays + * behind it (the Instanced Forest example parks a floating sky at z = 100000). + * A fix that simply forces floating children to the front breaks the backdrop — + * that mistake blanked three shipped examples, so it is pinned here too. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Camera3d, + Renderable, + video, +} from "../src/index.js"; + +describe("floating children in a depth-sorted container", () => { + let app; + let world; + let camera; + + beforeAll(async () => { + boot(); + app = new Application(1024, 576, { + parent: "screen", + scale: "auto", + // no GPU needed: this exercises the comparator, not rasterization + renderer: video.CANVAS, + cameraClass: Camera3d, + }); + await app.init(); + world = app.world; + camera = app.viewport; + world.sortOn = "depth"; + // keep the explicit depths we pass; `autoDepth` would overwrite + // `pos.z` with the child index + world.autoDepth = false; + }); + + afterAll(() => { + app?.destroy(); + }); + + /** a plain world-space child */ + const solid = (name, x, y, z) => { + const r = new Renderable(x, y, 8, 8); + r.name = name; + r.pos.z = z; + return r; + }; + + /** a screen-space child: `x`/`y` are canvas pixels */ + const overlay = (name, x, y, z) => { + const r = solid(name, x, y, z); + r.floating = true; + return r; + }; + + /** + * Draw order, topmost first. + * + * `sortNow` leaves the array ascending by distance (nearest at index 0) + * and `draw` walks it BACKWARDS, so index 0 is drawn last — on top. + * `sort()` defers, which would read back in insertion order and make + * every assertion below vacuous. + */ + const order = (...children) => { + for (const c of world.getChildren().slice()) { + world.removeChildNow(c); + } + for (const c of children) { + world.addChild(c, c.pos.z); + } + world.sortNow(); + return world.getChildren().map((c) => { + return c.name; + }); + }; + + const at = (x, y, z) => { + camera.pos.set(x, y, z); + }; + + describe("the reported bug: screen position decided the layering", () => { + it("keeps a centred HUD on top, where a corner HUD already was", () => { + at(0, 0, 0); + // identical depth, different screen position. The old key gave the + // centred one 512² + 200² = 302144 against the scene's 40000, so + // the terrain drew straight over the game-over banner while the + // score in the corner stayed visible. + expect( + order( + solid("terrain", 0, 0, 200), + overlay("banner", 512, 200, -150), + overlay("score", 20, 16, -150), + ), + ).toEqual(["banner", "score", "terrain"]); + }); + + it("orders two overlays by depth alone, whatever their screen position", () => { + at(0, 0, 0); + // opposite corners of the screen, so under the old key the corner + // one won regardless of depth. Only the depth counts now, and the + // shallower one is nearer. + expect( + order(overlay("deep", 4, 4, -300), overlay("shallow", 1000, 560, -10)), + ).toEqual(["shallow", "deep"]); + }); + }); + + describe("an overlay stays in front of the world", () => { + it("at the origin", () => { + at(0, 0, 0); + expect( + order(solid("world", 0, 0, 300), overlay("hud", 20, 16, -150))[0], + ).toBe("hud"); + }); + + it("after the camera has travelled far down the level", () => { + // the drift: the old key grew as (z - camZ)², so a HUD that was on + // top at the start of a run was buried by the end of it + at(0, 0, 8000); + expect( + order(solid("world", 0, 0, 8300), overlay("hud", 20, 16, -150))[0], + ).toBe("hud"); + }); + + it("from a camera at a large offset in x and y as well as z", () => { + // the old key subtracted the camera position from screen pixels, + // so a camera far off the origin in ANY axis pushed the HUD back + at(-4000, 2500, 8000); + expect( + order( + solid("world", -4000, 2500, 8300), + overlay("hud", 20, 16, -150), + )[0], + ).toBe("hud"); + }); + }); + + describe("a backdrop stays behind the world", () => { + it("at the origin", () => { + at(0, 0, 0); + const names = order( + overlay("sky", 0, 0, 100000), + solid("tree", 0, 0, 400), + ); + expect(names[names.length - 1]).toBe("sky"); + }); + + it("when the camera has travelled onto the backdrop's own depth", () => { + // the case that would break a naive "measure floating from the + // camera too" fix: the old key scored the sky at (100000-100000)² = + // 0, the nearest thing in the world, and it covered the scene + at(0, 0, 100000); + const names = order( + overlay("sky", 0, 0, 100000), + solid("tree", 0, 0, 100400), + ); + expect(names[names.length - 1]).toBe("sky"); + }); + + it("behind a world child that is itself extremely far away", () => { + at(0, 0, 0); + const names = order( + overlay("sky", 0, 0, 100000), + solid("distant", 0, 0, 90000), + ); + expect(names[names.length - 1]).toBe("sky"); + }); + }); + + describe("it is the magnitude of the depth that places a floating child", () => { + it("keeps a backdrop parked at a large NEGATIVE depth behind the world", () => { + // the glTF Scene, glTF Animated Model, Billboard and Night City + // examples all add their floating sky at z = -10000. The old key + // squared the difference from the camera, so the sign never + // mattered and -10000 read as "far". A key that treats a negative + // depth as "nearest" pulls all four skies over their own scene and + // leaves nothing but a gradient — which is exactly what happened. + at(0, 0, 0); + const names = order( + overlay("sky", 0, 0, -10000), + solid("model", 0, 0, 400), + ); + expect(names[names.length - 1]).toBe("sky"); + }); + + it("treats equal magnitudes of either sign as the same distance", () => { + at(0, 0, 0); + const negative = overlay("negative", 0, 0, -10000); + const positive = overlay("positive", 700, 400, 10000); + expect(world._sortDepth(negative, positive)).toBe(0); + }); + + it("puts a shallow overlay in front of the world and a deep one behind it", () => { + // both negative, and only the magnitude separates them: this is + // the afterBurner HUD (-150) and the glTF sky (-10000) in one world + at(0, 0, 0); + expect( + order( + overlay("sky", 0, 0, -10000), + solid("model", 0, 0, 400), + overlay("hud", 20, 16, -150), + ), + ).toEqual(["hud", "model", "sky"]); + }); + }); + + it("layers an overlay above the world above a backdrop, in one pass", () => { + at(0, 0, 0); + expect( + order( + solid("mid", 0, 0, 500), + overlay("sky", 0, 0, 100000), + overlay("hud", 512, 300, -150), + ), + ).toEqual(["hud", "mid", "sky"]); + }); + + describe("camera independence", () => { + it("gives floating children the same order from every camera position", () => { + const run = (x, y, z) => { + at(x, y, z); + return order( + overlay("sky", 0, 0, 100000), + overlay("hud", 512, 300, -150), + overlay("subtitle", 512, 500, -50), + ); + }; + const baseline = run(0, 0, 0); + // ordered by |depth|: the subtitle at -50 sits in front of the HUD + // at -150, and the sky at 100000 behind both + expect(baseline).toEqual(["subtitle", "hud", "sky"]); + for (const [x, y, z] of [ + [0, 0, 5000], + [-3000, 900, 12000], + [0, 0, 100000], + [1e6, -1e6, -4000], + ]) { + expect(run(x, y, z)).toEqual(baseline); + } + }); + }); + + describe("the world path is untouched", () => { + it("still orders world children by true distance from the camera", () => { + at(0, 0, 0); + expect( + order( + solid("far", 0, 0, 300), + solid("near", 0, 0, 100), + solid("mid", 0, 0, 200), + ), + ).toEqual(["near", "mid", "far"]); + }); + + it("still reorders world children as the camera moves past them", () => { + at(0, 0, 400); + expect(order(solid("a", 0, 0, 100), solid("b", 0, 0, 300))[0]).toBe("b"); + }); + + it("still counts x and y for world children", () => { + at(0, 0, 0); + expect( + order(solid("offAxis", 900, 0, 10), solid("onAxis", 0, 0, 50))[0], + ).toBe("onAxis"); + }); + }); + + describe("adversarial", () => { + it("treats a floating child at depth 0 as nearer than any world child", () => { + at(0, 0, 0); + expect( + order(solid("world", 0, 0, 1), overlay("flat", 700, 400, 0))[0], + ).toBe("flat"); + }); + + it("does not let a container's world offset leak into a floating key", () => { + // a floating child is in screen space, so the ancestor offset the + // world path applies must not apply to it + at(0, 0, 0); + const names = order( + overlay("hud", 20, 16, -150), + solid("tree", 0, 0, 400), + ); + expect(names[0]).toBe("hud"); + world.pos.z = 7000; + try { + world.sortNow(); + expect( + world.getChildren().map((c) => { + return c.name; + }), + ).toEqual(names); + } finally { + world.pos.z = 0; + } + }); + + it("treats a child with no floating flag as a world child", () => { + at(0, 0, 0); + // identical coordinates, and ONLY the flag differs. The world + // child scores 512² + 200² + 150²; the screen-space one scores + // -(150²), so it must sort nearer. + const plain = solid("plain", 512, 200, -150); + const floats = overlay("floats", 512, 200, -150); + expect(plain.floating).not.toBe(true); + expect(world._sortDepth(plain, floats)).toBeGreaterThan(0); + }); + + it("produces a finite ordering for extreme depths", () => { + at(0, 0, 0); + const ref = solid("ref", 0, 0, 100); + for (const z of [-1e7, -1, 0, 1, 1e7]) { + expect( + Number.isFinite(world._sortDepth(overlay("x", 0, 0, z), ref)), + ).toBe(true); + } + }); + + it("is a consistent comparator: aa, and equals tie", () => { + at(0, 0, 3000); + const hud = overlay("hud", 20, 16, -150); + const sky = overlay("sky", 0, 0, 100000); + const tree = solid("tree", 0, 0, 3200); + for (const [p, q] of [ + [hud, sky], + [hud, tree], + [tree, sky], + ]) { + expect(Math.sign(world._sortDepth(p, q))).toBe( + -Math.sign(world._sortDepth(q, p)), + ); + } + expect(world._sortDepth(hud, hud)).toBe(0); + }); + }); +});