diff --git a/packages/examples/src/examples/billboard/ExampleBillboard.tsx b/packages/examples/src/examples/billboard/ExampleBillboard.tsx index 65e78a28a7..f9df468db3 100644 --- a/packages/examples/src/examples/billboard/ExampleBillboard.tsx +++ b/packages/examples/src/examples/billboard/ExampleBillboard.tsx @@ -183,7 +183,9 @@ const createGame = () => { // frames are rotated + trimmed (also exercising that mapping). `preload`'s // third arg is `false` so it does NOT switch to the built-in loading state // (this example builds its scene manually rather than through a Stage). - const GY = -112; // character center (feet near the floor, render space Y-down) + const GY = 0; // the characters' FEET baseline (anchored on the floor plane, render space Y-down) + const HERO_H = 225; + const TAG_GAP = 20; // gap between head-top and tag bottom const modes: Array< [number, boolean | "cylindrical" | "spherical", string, string] > = [ @@ -202,26 +204,29 @@ const createGame = () => { loader.getImage("cityscene"), ); for (const [x, mode, label, accent] of modes) { - // the character — same art + animation, one per billboard mode const guy = new Sprite3d(x, GY, { ...atlas.getAnimationSettings(WALK_FRAMES), width: 130, - height: 225, + height: HERO_H, z: 0, billboard: mode, + anchorPoint: "bottom", // feet at pos — character only }); guy.addAnimation("walk", WALK_FRAMES, 90); guy.setCurrentAnimation("walk"); app.world.addChild(guy); - // a name tag above the head — spherical so it always reads, whatever - // the camera is doing - const tag = new Sprite3d(x, GY - 150, { + // tag stays centered (default origin) — floats above the head, + // positioned relative to the character's new head-top position + const tagHeight = 42; + const tagY = GY - HERO_H - TAG_GAP - tagHeight / 2; + const tag = new Sprite3d(x, tagY, { image: bakeLabel(label, accent), width: 150, - height: 42, + height: tagHeight, z: 0, billboard: "spherical", + // no anchorPoint — the default center is correct here }); app.world.addChild(tag); } @@ -233,7 +238,9 @@ const createGame = () => { const camera = app.viewport as InstanceType; camera.fov = (55 * Math.PI) / 180; camera.setClipPlanes(8, 6000); - const TARGET = { x: 0, y: GY * 0.6, z: 0 }; + // frame mid-body — ≈ the pre-anchoring framing (was GY * 0.6 with + // GY = -112 → -67.2, before GY became the feet baseline at 0) + const TARGET = { x: 0, y: -HERO_H * 0.3, z: 0 }; let t = 0; let paused = false; diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8b16828d9d..e9a680f846 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,6 +10,7 @@ - **`shared` flag on `ShaderEffect` / `GLShader`** — set `effect.shared = true` on a post-processing shader that is reused across several renderables, so one renderable's cleanup (the `shader` setter, `removePostEffect`, `clearPostEffects`, or `destroy()`) doesn't free the GL program the others still use. Defaults to `false` (unchanged auto-destroy behavior); when set, you own the shader's lifecycle and call `destroy()` yourself. - **`ShaderEffect.setTime(seconds)`** — a convenience for the `uTime` shader-animation convention, now on the base class: call it once per frame to write a shader's `uniform float uTime` (e.g. to scroll a static texture's UVs, or pulse / wave), driven by whatever clock you choose. A no-op when the shader doesn't declare `uTime`, so it's safe on any effect. Manual by design — the engine never calls it, so animation stays opt-in. The built-in `Hologram` / `Shine` effects now inherit it (their identical `setTime` overrides were removed). - **`ShaderEffect.setTexture(name, image[, repeat])`** (closes [#1532](https://github.com/melonjs/melonJS/issues/1532)) — bind an **extra** `sampler2D` to a custom post-effect, beyond the sprite/target it processes (`uSampler`) — a noise map, mask, gradient, or flow table. Declare `uniform sampler2D ;` in your fragment and pass any engine texture (e.g. `noiseTexture.getTexture()`); the engine uploads it once, caches it, and (re)binds it to a reserved texture unit each draw while pointing the sampler uniform at it — no raw WebGL texture-unit juggling. Works on both post-effect paths (single-effect sprite shader and the multi-effect / camera FBO blit). Enables UV-distortion water, dissolve masks, flow maps, palette lookups, etc. Purely additive — effects that never call it are unchanged. See the new **Water Refraction** example (a perspective pool floor refracted through a GPU-scrolled seamless `NoiseTexture2d`). +- **Anchor-point presets + `Sprite3d` anchor support** (#1514) — `settings.anchorPoint` now accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` in addition to an `{x, y}` object, uniformly across `Sprite`, `Entity`, `Collectable`, `ImageLayer`, `Text`, `BitmapText` (and their subclasses), including as a plain-string Tiled property. `Sprite3d` gains anchoring through the same `settings.anchorPoint` key (centered default, same convention as `Sprite`): the anchor is baked into the quad's local vertices so it composes with billboarding, flips and trimmed/rotated atlas frames, never leaks into the renderer transform on either camera path, is mutable at runtime via `sprite3d.anchorPoint.set(...)` (automatic re-bake + cull-bounds update), and grows the frustum-cull bounds exactly so an anchored sprite can't pop out while still on screen. On the 2D classes invalid values (unknown preset, malformed object) keep their historical silent `(0, 0)` outcome for full backward compatibility, but now log a console warning; `Sprite3d` (a new surface) throws. See the updated billboard example (`anchorPoint: "bottom"` — feet on the ground plane). - **Shader preloading: `"shader"` asset type + `loader.getShader()` + `ShaderEffect.clone()`** — custom shaders can now be preloaded like any other asset and are **compiled at load time** (the GLSL compile cost lands in the loading screen, and a compile error fails the load — `loader.load()` rejects with an error carrying the asset's name). The source is a GLSL fragment body following the `ShaderEffect` convention (`uniform` declarations + `vec4 apply(vec4 color, vec2 uv)`), loaded from a `src` URL / data: URI or inline via the `data` field (the inline-TMX convention): `{ name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }`. `loader.getShader(name)` returns the **shared, loader-owned instance** — the *same* object on every call, with `shared = true` so renderable cleanup never auto-destroys it; everything it is assigned to shares one set of uniform values, and it is freed only by `loader.unload()` / `loader.unloadAll()` (which destroy the GL program). For per-renderable uniform values, the new `ShaderEffect.clone()` compiles an independent, caller-owned copy: it copies the *recipe* (fragment source, precision, uniform values, `setTexture` bindings — with its own GL uploads) but never the *ownership* — the clone's `shared` flag is **always reset to `false`**, so it is auto-destroyed with the renderable it is assigned to unless explicitly re-shared. `GLShader.clone()` exists likewise for raw full-program (vertex + fragment) shaders, with the same recipe-not-ownership semantics. The **Water Refraction** example now ships its fragment as a preloaded shader asset. ### Fixed diff --git a/packages/melonjs/src/renderable/anchorPoint.ts b/packages/melonjs/src/renderable/anchorPoint.ts new file mode 100644 index 0000000000..4d17c4caae --- /dev/null +++ b/packages/melonjs/src/renderable/anchorPoint.ts @@ -0,0 +1,90 @@ +/** + * Shared `settings.anchorPoint` resolution for every renderable that accepts + * it (Sprite, Entity, Collectable, ImageLayer, Text, BitmapText, Sprite3d). + * Same normalized convention as {@link Renderable#anchorPoint}: x 0=left → + * 1=right, y 0=top → 1=bottom. + */ + +/** + * Named anchor-point presets accepted anywhere `settings.anchorPoint` is — + * self-documenting shorthands for the common `{x, y}` pairs. + * @ignore + */ +export const ANCHOR_POINT_PRESETS = Object.freeze({ + center: Object.freeze({ x: 0.5, y: 0.5 }), + top: Object.freeze({ x: 0.5, y: 0 }), + bottom: Object.freeze({ x: 0.5, y: 1 }), + left: Object.freeze({ x: 0, y: 0.5 }), + right: Object.freeze({ x: 1, y: 0.5 }), + "top-left": Object.freeze({ x: 0, y: 0 }), + "top-right": Object.freeze({ x: 1, y: 0 }), + "bottom-left": Object.freeze({ x: 0, y: 1 }), + "bottom-right": Object.freeze({ x: 1, y: 1 }), +}); + +/** + * An anchor value as accepted by `settings.anchorPoint`: a preset name or + * any object with numeric `x`/`y` (plain object, Vector2d, ObservablePoint). + * @ignore + */ +export type AnchorPointValue = + | keyof typeof ANCHOR_POINT_PRESETS + | { x: number; y: number }; + +const isAnchorPair = (value: unknown): value is { x: number; y: number } => { + if (typeof value !== "object" || value === null) { + return false; + } + const pair = value as { x?: unknown; y?: unknown }; + return ( + typeof pair.x === "number" && + Number.isFinite(pair.x) && + typeof pair.y === "number" && + Number.isFinite(pair.y) + ); +}; + +/** + * Resolve a `settings.anchorPoint` value — a preset name or an `{x, y}` + * object — into a fresh `{x, y}` pair (never a shared/frozen object). + * Values are passed through unclamped: anchors outside 0..1 are legal. + * + * Invalid values (unknown preset, malformed object, non-finite numbers) are + * handled per surface: + * - with a `fallback` (the legacy 2D classes): logs a console warning and + * returns the fallback — full backward compatibility with the historical + * silent `set(undefined, undefined)` → `(0, 0)` outcome, nothing that + * constructed before can start throwing; + * - without (new surfaces, e.g. Sprite3d): throws. + * @ignore + */ +export function resolveAnchorPoint( + value: unknown, + owner = "Renderable", + fallback?: { x: number; y: number }, +): { x: number; y: number } { + if (typeof value === "string") { + const preset = + ANCHOR_POINT_PRESETS[value as keyof typeof ANCHOR_POINT_PRESETS]; + if (typeof preset !== "undefined") { + return { x: preset.x, y: preset.y }; + } + } else if (isAnchorPair(value)) { + return { x: value.x, y: value.y }; + } + if (typeof fallback !== "undefined") { + console.warn( + `${owner}: invalid anchorPoint value (expected a preset name — ${Object.keys( + ANCHOR_POINT_PRESETS, + ).join(", ")} — or an {x, y} object); falling back to (${fallback.x}, ${ + fallback.y + })`, + ); + return { x: fallback.x, y: fallback.y }; + } + throw new Error( + `${owner}: invalid anchorPoint value (expected a preset name — ${Object.keys( + ANCHOR_POINT_PRESETS, + ).join(", ")} — or an {x, y} object)`, + ); +} diff --git a/packages/melonjs/src/renderable/collectable.js b/packages/melonjs/src/renderable/collectable.js index 9cdc3bf527..14be904c83 100644 --- a/packages/melonjs/src/renderable/collectable.js +++ b/packages/melonjs/src/renderable/collectable.js @@ -1,6 +1,7 @@ import { polygonPool } from "../geometries/polygon.ts"; import { vector2dPool } from "../math/vector2d.ts"; import { collision } from "./../physics/collision.js"; +import { resolveAnchorPoint } from "./anchorPoint.ts"; import Sprite from "./sprite.js"; /** @@ -12,6 +13,7 @@ export default class Collectable extends Sprite { * @param {number} x - the x coordinates of the collectable * @param {number} y - the y coordinates of the collectable * @param {object} settings - See {@link Sprite} + * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0,y:0}] - anchor point (note the backward-compatible `(0, 0)` default, unlike Sprite's centered one). Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. */ constructor(x, y, settings) { // call the super constructor @@ -50,7 +52,11 @@ export default class Collectable extends Sprite { // Update anchorPoint if (settings.anchorPoint) { - this.anchorPoint.set(settings.anchorPoint.x, settings.anchorPoint.y); + const anchor = resolveAnchorPoint(settings.anchorPoint, "Collectable", { + x: 0, + y: 0, + }); + this.anchorPoint.set(anchor.x, anchor.y); } else { // for backward compatibility this.anchorPoint.set(0, 0); diff --git a/packages/melonjs/src/renderable/entity/entity.js b/packages/melonjs/src/renderable/entity/entity.js index 3d759a14c8..530c7f5aca 100644 --- a/packages/melonjs/src/renderable/entity/entity.js +++ b/packages/melonjs/src/renderable/entity/entity.js @@ -2,6 +2,7 @@ import { polygonPool } from "../../geometries/polygon.ts"; import { warning } from "../../lang/console.js"; import { vector2dPool } from "../../math/vector2d.ts"; import Body from "../../physics/builtin/body.js"; +import { resolveAnchorPoint } from "../anchorPoint.ts"; import Renderable from "../renderable.js"; import Sprite from "../sprite.js"; @@ -170,7 +171,7 @@ export default class Entity extends Renderable { * @param {string} [settings.name] - object entity name * @param {string} [settings.id] - object unique IDs * @param {Image|string} [settings.image] - resource name of a spritesheet to use for the entity renderable component - * @param {Vector2d} [settings.anchorPoint=0.0] - Entity anchor point + * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint=0.0] - Entity anchor point. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. * @param {number} [settings.framewidth=settings.width] - width of a single frame in the given spritesheet * @param {number} [settings.frameheight=settings.width] - height of a single frame in the given spritesheet * @param {string} [settings.type] - object type @@ -203,7 +204,11 @@ export default class Entity extends Renderable { // Update anchorPoint if (settings.anchorPoint) { - this.anchorPoint.setMuted(settings.anchorPoint.x, settings.anchorPoint.y); + const anchor = resolveAnchorPoint(settings.anchorPoint, "Entity", { + x: 0, + y: 0, + }); + this.anchorPoint.setMuted(anchor.x, anchor.y); } else { // for backward compatibility this.anchorPoint.setMuted(0, 0); diff --git a/packages/melonjs/src/renderable/imagelayer.js b/packages/melonjs/src/renderable/imagelayer.js index c4ce56b110..8b545793f5 100644 --- a/packages/melonjs/src/renderable/imagelayer.js +++ b/packages/melonjs/src/renderable/imagelayer.js @@ -8,6 +8,7 @@ import { VIEWPORT_ONRESIZE, } from "../system/event.ts"; import * as stringUtil from "./../utils/string.ts"; +import { resolveAnchorPoint } from "./anchorPoint.ts"; import Sprite from "./sprite.js"; /** @@ -29,7 +30,7 @@ export default class ImageLayer extends Sprite { * @param {number} [settings.z=0] - z-index position * @param {number|Vector2d} [settings.ratio=1.0] - Scrolling ratio to be applied. See {@link ImageLayer#ratio} * @param {"repeat"|"repeat-x"|"repeat-y"|"no-repeat"} [settings.repeat="repeat"] - define if and how an Image Layer should be repeated. See {@link ImageLayer#repeat} - * @param {number|Vector2d} [settings.anchorPoint=<0.0,0.0>] - Define how the image is anchored to the viewport bound. By default, its upper-left corner is anchored to the viewport bounds upper left corner. + * @param {number|string|Vector2d|{x:number,y:number}} [settings.anchorPoint=<0.0,0.0>] - Define how the image is anchored to the viewport bound. By default, its upper-left corner is anchored to the viewport bounds upper left corner. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` (anchored relative to the viewport, e.g. `"bottom"` = bottom-center of the viewport); a bare number anchors both axes. * @example * // create a repetitive background pattern on the X axis using the citycloud image asset * app.world.addChild(new me.ImageLayer(0, 0, { @@ -38,6 +39,16 @@ export default class ImageLayer extends Sprite { * }), 1); */ constructor(x, y, settings) { + // bare-number shorthand (anchors both axes) — normalize BEFORE super(): + // the Sprite base constructor consumes the same settings.anchorPoint, + // and the shared resolver doesn't accept bare numbers (pre-super + // settings mutation has precedent, e.g. Entity / TMXTileMap) + if (typeof settings.anchorPoint === "number") { + settings.anchorPoint = { + x: settings.anchorPoint, + y: settings.anchorPoint, + }; + } // call the constructor super(x, y, settings); @@ -74,11 +85,13 @@ export default class ImageLayer extends Sprite { if (typeof settings.anchorPoint === "undefined") { this.anchorPoint.set(0, 0); } else { - if (typeof settings.anchorPoint === "number") { - this.anchorPoint.set(settings.anchorPoint, settings.anchorPoint); - } /* vector */ else { - this.anchorPoint.set(settings.anchorPoint.x, settings.anchorPoint.y); - } + // preset name or {x, y} (bare numbers were normalized pre-super); + // invalid values warn + keep the historical silent (0, 0) outcome + const anchor = resolveAnchorPoint(settings.anchorPoint, "ImageLayer", { + x: 0, + y: 0, + }); + this.anchorPoint.set(anchor.x, anchor.y); } this.repeat = settings.repeat || "repeat"; diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 35976c83d5..466cf1853c 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -130,7 +130,10 @@ function resolveGroupMaterial(group, materials) { * mesh under a transformed parent to pivot elsewhere). Consequently, on the * `Camera3d` world-space path the mesh opts out of the anchor offset entirely * (see {@link Renderable#applyAnchorTransform}); the legacy 2D path still - * honors `anchorPoint` for backward compatibility. + * honors `anchorPoint` for backward compatibility. Subclasses that bake the + * anchor into their vertex data instead (e.g. {@link Sprite3d}, via the + * internal `_anchorBaked` flag) opt out on **both** paths — for those, the + * vertex bake is the single anchoring mechanism. * @category Game Objects */ export default class Mesh extends Renderable { @@ -574,6 +577,15 @@ export default class Mesh extends Renderable { this.anchorPoint.set(0.5, 0.5); + // Subclasses that bake the anchor offset directly into their vertex + // data (e.g. Sprite3d) set this true, so preDraw suppresses the base + // anchor translate on BOTH camera paths — the vertex bake is then the + // single anchoring mechanism (the Camera2d fallback loses its exact + // pixel-shift anchor for such subclasses; that path is documented as + // degraded for them anyway). Plain meshes keep the legacy 2D anchor. + /** @ignore */ + this._anchorBaked = false; + // skip applying the 3D transform to the 2D renderer context in preDraw // the full 3D transform is applied to vertices in draw() instead this.autoTransform = false; @@ -803,13 +815,19 @@ export default class Mesh extends Renderable { * path the mesh emits final world coordinates, so it opts out of the base * anchor-point offset ({@link Renderable#applyAnchorTransform} = `false`) — * otherwise the offset would leak into the shared mesh view matrix. The 2D - * path keeps the anchor. See the class description for the pivot rationale. + * path keeps the anchor — except for subclasses with a vertex-baked anchor + * (`_anchorBaked`, e.g. {@link Sprite3d}), which suppress it on both paths. + * See the class description for the pivot rationale. * @param {CanvasRenderer|WebGLRenderer} renderer - a renderer instance */ preDraw(renderer) { // world-space meshes pivot about their own origin, not a bounds-box - // anchor (see Mesh class doc + Renderable#applyAnchorTransform) - this.applyAnchorTransform = this._useWorldSpace !== true; + // anchor (see Mesh class doc + Renderable#applyAnchorTransform); a + // subclass with a vertex-baked anchor (`_anchorBaked`) opts out on + // both paths — recomputed here every frame, so a one-shot constructor + // assignment could never stick + this.applyAnchorTransform = + this._useWorldSpace !== true && this._anchorBaked !== true; super.preDraw(renderer); } diff --git a/packages/melonjs/src/renderable/nineslicesprite.js b/packages/melonjs/src/renderable/nineslicesprite.js index de574c5c21..1a65742c8b 100644 --- a/packages/melonjs/src/renderable/nineslicesprite.js +++ b/packages/melonjs/src/renderable/nineslicesprite.js @@ -40,7 +40,7 @@ export default class NineSliceSprite extends Sprite { * @param {string|Color} [settings.tint] - a tint to be applied to this sprite * @param {number} [settings.flipX] - flip the sprite on the horizontal axis * @param {number} [settings.flipY] - flip the sprite on the vertical axis - * @param {Vector2d} [settings.anchorPoint={x:0.5, y:0.5}] - Anchor point to draw the frame at (defaults to the center of the frame). + * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.5, y:0.5}] - Anchor point to draw the frame at (defaults to the center of the frame). Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. * @example * this.panelSprite = new me.NineSliceSprite(0, 0, { * image : game.texture, diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 5e240954ce..d2b188e371 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -57,7 +57,12 @@ export default class Renderable extends Rect { * a Renderable's anchor point defaults to (0.5,0.5), which corresponds to the center position.
*
* Note: Object created through Tiled will have their anchorPoint set to (0, 0) to match Tiled Level editor implementation. - * To specify a value through Tiled, use a json expression like `json:{"x":0.5,"y":0.5}`. + * To specify a value through Tiled, use a json expression like `json:{"x":0.5,"y":0.5}`, or (since 19.9) a plain string preset such as `bottom`. + *
+ * At construction time, `settings.anchorPoint` also accepts the named presets + * `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, + * `"bottom-left"`, `"bottom-right"` on every renderable that consumes it + * (Sprite, Entity, Collectable, ImageLayer, Text, BitmapText, Sprite3d and subclasses). * @type {ObservablePoint} * @default <0.5,0.5> */ diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js index c298d17142..2754cb9a46 100644 --- a/packages/melonjs/src/renderable/sprite.js +++ b/packages/melonjs/src/renderable/sprite.js @@ -5,6 +5,7 @@ import { vector2dPool } from "../math/vector2d.ts"; import { on } from "../system/event.ts"; import { TextureAtlas } from "./../video/texture/atlas.js"; import Texture2d from "./../video/texture/texture2d.ts"; +import { resolveAnchorPoint } from "./anchorPoint.ts"; import FrameAnimation from "./frameAnimation.js"; import Renderable from "./renderable.js"; @@ -34,7 +35,7 @@ export default class Sprite extends Renderable { * @param {string|Color} [settings.tint] - a tint to be applied to this sprite * @param {number} [settings.flipX] - flip the sprite on the horizontal axis * @param {number} [settings.flipY] - flip the sprite on the vertical axis - * @param {Vector2d} [settings.anchorPoint={x:0.5, y:0.5}] - Anchor point to draw the frame at (defaults to the center of the frame). + * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.5, y:0.5}] - Anchor point to draw the frame at (defaults to the center of the frame). Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. For spritesheet atlases the anchor also becomes the cached atlas's per-frame pivot (see {@link TextureAtlas}). * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap|Texture2d|string} [settings.normalMap] - optional normal-map texture used for per-pixel lighting (SpriteIlluminator-style). Same layout/UVs as `settings.image`. When omitted (default), the sprite renders unlit and pays no extra cost. Ignored by the Canvas renderer. Note: `HTMLVideoElement` is intentionally not supported — normal maps encode static surface directions in RGB, and the engine caches the GL texture per image reference (a video would freeze on frame 0). * @example * // create a single sprite from a standalone image, with anchor in the center @@ -69,6 +70,16 @@ export default class Sprite extends Renderable { // call the super constructor super(x, y, 0, 0); + // Resolve the anchor once, up front (preset name or {x, y} object; + // invalid values warn + keep the historical silent (0, 0) outcome — + // see resolveAnchorPoint). The resolved pair also rides into the + // texture-cache descriptor below: spritesheet atlases store it as the + // cached per-frame pivot, so presets and {x, y} objects behave + // identically on both paths. + const resolvedAnchor = settings.anchorPoint + ? resolveAnchorPoint(settings.anchorPoint, "Sprite", { x: 0, y: 0 }) + : null; + // the shared frame-animation engine — owns this sprite's animation state // (exposed via the `anim` / `current` / `animationspeed` / `animationpause` // accessors below) and calls back into `_applyFrame` on each frame change. @@ -207,7 +218,15 @@ export default class Sprite extends Renderable { this.current.height = settings.frameheight = settings.frameheight || this.image.height; - this.source = game.renderer.cache.get(this.image, settings); + this.source = game.renderer.cache.get( + this.image, + // forward the RESOLVED anchor into the atlas descriptor — + // never the caller's raw value (a preset string, or an + // aliased Vector2d, must not land in the shared cache) + resolvedAnchor + ? Object.assign({}, settings, { anchorPoint: resolvedAnchor }) + : settings, + ); this.textureAtlas = this.source.getAtlas(); } } @@ -269,9 +288,9 @@ export default class Sprite extends Renderable { this.rotate(settings.rotation); } - // update anchorPoint - if (settings.anchorPoint) { - this.anchorPoint.set(settings.anchorPoint.x, settings.anchorPoint.y); + // update anchorPoint (resolved once at the top of the constructor) + if (resolvedAnchor) { + this.anchorPoint.set(resolvedAnchor.x, resolvedAnchor.y); } if (typeof settings.tint !== "undefined") { diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 9b73c07c5f..d454508755 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -2,6 +2,7 @@ import Camera3d from "../camera/camera3d.ts"; import { getImage } from "../loader/loader.js"; import { Vector3d } from "../math/vector3d.ts"; import Texture2d from "../video/texture/texture2d.ts"; +import { resolveAnchorPoint } from "./anchorPoint.ts"; import FrameAnimation from "./frameAnimation.js"; import Mesh from "./mesh.js"; @@ -14,6 +15,38 @@ const _fwd = new Vector3d(); // module singleton, only ever copied/crossed-from, never mutated in place. const WORLD_UP = new Vector3d(0, -1, 0); +// Frustum-cull side length for an anchored quad: Camera3d derives its cull +// sphere radius as √(w²+h²)/2 from the bounds, so passing side = radius·√2 +// with radius = the farthest corner's distance from `pos` (rotation about +// `pos` under billboarding preserves corner distances) makes the sphere +// exactly enclose the quad in any orientation. Never smaller than the legacy +// centered bounds (max of the quad's sides), so the default is unchanged. +function cullSideForAnchor(hw, hh, offsetX, offsetY) { + return Math.max( + Math.max(hw, hh) * 2, + Math.SQRT2 * Math.hypot(hw + Math.abs(offsetX), hh + Math.abs(offsetY)), + ); +} + +// Write the plain (frame-less) anchored quad into `out` (12 floats): +// corners at ±hw/±hh shifted by the anchor offset, z = 0. Used for the +// constructor bake and for runtime anchor changes on the no-region path. +function bakeQuadVertices(hw, hh, offsetX, offsetY, out) { + out[0] = -hw + offsetX; + out[1] = -hh + offsetY; + out[2] = 0; + out[3] = hw + offsetX; + out[4] = -hh + offsetY; + out[5] = 0; + out[6] = hw + offsetX; + out[7] = hh + offsetY; + out[8] = 0; + out[9] = -hw + offsetX; + out[10] = hh + offsetY; + out[11] = 0; + return out; +} + // Resolve just the source image dimensions for sizing the quad, without // touching the renderer (the texture atlas itself is resolved by the Mesh // base class). Runs before `super()`, so it must not touch `this`. @@ -58,6 +91,14 @@ function imageSize(settings) { * world-space path; under a 2D `Camera2d` it falls back to the mesh's * self-projection and **billboarding has no effect** (a 2D scene has no camera * orientation to face). Use a regular {@link Sprite} for 2D. + * + * **Anchoring.** Unlike its {@link Mesh} parent — where `anchorPoint` is inert + * on the 3D path and transforms pivot at the model origin — Sprite3d's + * `anchorPoint` is live: the anchor is baked into the quad's local vertices + * (never applied as a renderer transform on either camera path) and can be + * changed at runtime via `anchorPoint.set(...)`, which re-bakes the quad and + * re-derives the cull bounds. Same key, convention and centered default as + * the 2D {@link Sprite}, including the named presets (`"bottom"`, …). * @augments Mesh * @category Game Objects * @example @@ -91,6 +132,15 @@ function imageSize(settings) { * coin.setCurrentAnimation("spin"); * coin.flipX(); // face the other way (mirrors the sprite) * app.world.addChild(coin); + * + * // a character anchored at the feet, so `pos` sits on the ground plane + * // instead of at the sprite's geometric center + * const hero = new Sprite3d(0, 0, { + * image: "hero", + * width: 48, height: 64, + * billboard: "cylindrical", + * anchorPoint: "bottom", // == { x: 0.5, y: 1 } + * }); */ export default class Sprite3d extends Mesh { /** @@ -106,6 +156,7 @@ export default class Sprite3d extends Mesh { * @param {object[]} [settings.anims] - predefined animations (same shape as {@link Sprite}) * @param {number} [settings.z=0] - 3D depth (world z); also settable later via `.depth` * @param {boolean|string} [settings.billboard=false] - billboard mode: `false` (fixed orientation), `true` / `"cylindrical"` (faces the camera but stays upright — the 2.5D default), or `"spherical"` (faces the camera on all axes). Only applies under a `Camera3d`. + * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.5,y:0.5}] - anchor of the quad relative to `pos` — same key, normalized 0..1 convention (`x`: 0 left→1 right, `y`: 0 top→1 bottom) and centered default as the 2D {@link Sprite}. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. Baked into the quad's local vertices (composes with billboarding, flips, and trimmed/rotated atlas frames) — never applied as a renderer transform on either camera path. Mutable at runtime via `this.anchorPoint.set(...)` (re-bakes the quad and re-derives the cull bounds). Invalid values throw. * @param {boolean} [settings.flipX=false] - mirror the sprite horizontally (see {@link Sprite3d#flipX}) * @param {boolean} [settings.flipY=false] - mirror the sprite vertically (see {@link Sprite3d#flipY}) * @param {boolean} [settings.lit=false] - shade through the lit mesh batcher (see {@link Mesh}) @@ -132,24 +183,31 @@ export default class Sprite3d extends Mesh { } const hw = w / 2; const hh = h / 2; + // local-space offset shifting the quad so `anchorPoint` — not the + // geometric center — lands at `pos`. Resolved PRE-super: the quad + // vertices below are built before super(), and the inherited + // ObservablePoint doesn't exist yet (it is wired up post-super). + // X follows the image convention directly (0=left…1=right). Y is + // inverted: local +Y is the TOP of the art (see the UV/trim mapping in + // `_applyFrame` and `WORLD_UP` below), while `anchorPoint.y` follows + // the image convention of 0=top…1=bottom. + const anchor = settings.anchorPoint + ? resolveAnchorPoint(settings.anchorPoint, "Sprite3d") + : { x: 0.5, y: 0.5 }; + const anchorOffsetX = (0.5 - anchor.x) * w; + const anchorOffsetY = (anchor.y - 0.5) * h; // a unit quad with the real pixel size baked in, in the XY plane, facing - // +Z. `normalize: false` + `scale: 1` keeps these coordinates as-is so the - // fixed-orientation (non-billboard) case renders at the right size, and - // the billboard path reuses the local (±hw, ±hh) offsets directly. - const vertices = new Float32Array([ - -hw, - -hh, - 0, - hw, - -hh, - 0, + // +Z, shifted by the anchor offset. `normalize: false` + `scale: 1` keeps + // these coordinates as-is so the fixed-orientation (non-billboard) case + // renders at the right size, and the billboard path reuses the local + // (±hw + offset, ±hh + offset) offsets directly. + const vertices = bakeQuadVertices( hw, hh, - 0, - -hw, - hh, - 0, - ]); + anchorOffsetX, + anchorOffsetY, + new Float32Array(12), + ); // V flipped (1→0 top to bottom) so the texture renders upright under the // Y-down render space, matching Sprite. Overwritten per-frame by // `_applyFrame` once an animation/region is selected. @@ -167,10 +225,18 @@ export default class Sprite3d extends Mesh { texture: settings.image ?? settings.texture, framewidth: settings.framewidth, frameheight: settings.frameheight, - // width/height drive the frustum-cull bounds; use the larger side so - // the cull sphere always encloses the quad whatever way it faces - width: Math.max(w, h), - height: Math.max(w, h), + // width/height drive the frustum-cull bounds. Camera3d culls with a + // sphere centered on `pos` of radius √(w²+h²)/2 = side/√2; the + // billboard corners orbit `pos` at radius hypot(hw+|offX|, hh+|offY|) + // (rotation about `pos` preserves corner distances, and trimmed + // frames stay inside the ±hw/±hh box). Passing side = radius·√2 + // makes the sphere exactly enclose the quad in ANY orientation. + // For the centered default this degrades to max(w, h) — unchanged + // legacy bounds; a non-center anchor grows them just enough that + // e.g. a bottom-anchored character can't pop out while its head is + // still on screen. + width: cullSideForAnchor(hw, hh, anchorOffsetX, anchorOffsetY), + height: cullSideForAnchor(hw, hh, anchorOffsetX, anchorOffsetY), scale: 1, normalize: false, rightHanded: true, @@ -197,6 +263,25 @@ export default class Sprite3d extends Mesh { this._halfW = hw; /** @ignore */ this._halfH = hh; + // local-space offset pinning `anchorPoint` to `pos` (0,0 = centered, + // the default). Applied in `_applyFrame` after the flip, so mirroring + // happens around the art's own center while the anchor stays fixed. + /** @ignore */ + this._anchorOffsetX = anchorOffsetX; + /** @ignore */ + this._anchorOffsetY = anchorOffsetY; + // the anchor lives in the vertex data — tell Mesh.preDraw to suppress + // the base transform-level anchor on BOTH camera paths (single + // anchoring mechanism; see Mesh#_anchorBaked) + this._anchorBaked = true; + // Repurpose the inherited anchorPoint as the LIVE anchor API (unlike + // Mesh, where it stays inert on the world path): setMuted first — the + // base updateBounds callback must not fire on a half-initialized + // object — then swap in the re-bake callback for runtime changes. + this.anchorPoint.setMuted(anchor.x, anchor.y); + this.anchorPoint.setCallback(() => { + this._onAnchorChanged(); + }); // reference logical (untrimmed) frame size the world quad maps to, // captured from the first applied frame so trimmed frames can be scaled // into the same world footprint (0 = not captured yet) @@ -306,6 +391,51 @@ export default class Sprite3d extends Mesh { } } + /** + * anchorPoint changed at runtime (the ObservablePoint callback wired in + * the constructor): recompute the baked local offset from the live anchor + * and the art size, re-bake the quad, and re-derive the frustum-cull + * bounds. Preserves the base ObservablePoint contract (updateBounds + + * isDirty, see Renderable's anchorPoint callback). + * @ignore + */ + _onAnchorChanged() { + const hw = this._halfW; + const hh = this._halfH; + this._anchorOffsetX = (0.5 - this.anchorPoint.x) * hw * 2; + this._anchorOffsetY = (this.anchorPoint.y - 0.5) * hh * 2; + if (this._region !== null) { + // re-applies the trim/rotation mapping + flips + the new offsets + this._applyFrame(this._region); + } else { + // named-atlas path where no frame region ever landed — re-bake the + // plain constructor quad + bakeQuadVertices( + hw, + hh, + this._anchorOffsetX, + this._anchorOffsetY, + this.originalVertices, + ); + } + // Resize the bounds box directly: the Rect width/height setters run + // Polygon.recalc(), which walks `this.normals` — a field Mesh + // repurposes for per-vertex lighting normals (a Float32Array), so the + // setters would throw here. The polygon edge/normal machinery is + // unused by meshes anyway; mutating the corner points + updateBounds + // is exactly the setter minus recalc. + const cullSide = cullSideForAnchor( + hw, + hh, + this._anchorOffsetX, + this._anchorOffsetY, + ); + this.points[1].x = this.points[2].x = cullSide; + this.points[2].y = this.points[3].y = cullSide; + this.updateBounds(); + this.isDirty = true; + } + /** * defined animations, keyed by id (see {@link Sprite3d#addAnimation}). * @type {object} @@ -528,8 +658,9 @@ export default class Sprite3d extends Mesh { * {@link Sprite}'s sub-texture / size / anchor swap). Rewrites both the quad's * UVs (the atlas sub-rect, with a 90° corner permutation for packer-rotated * regions) and its local vertices (the trimmed art's sub-rectangle within the - * logical frame, scaled into the quad's world footprint), so trimmed and - * rotated `TextureAtlas` regions render at full parity with the 2D `Sprite`. + * logical frame, scaled into the quad's world footprint, then shifted by the + * anchor offset), so trimmed and rotated `TextureAtlas` regions render at + * full parity with the 2D `Sprite`. * @param {object} region - the texture region object * @ignore */ @@ -617,6 +748,13 @@ export default class Sprite3d extends Mesh { yt = -yt; yb = -yb; } + // pin `anchorPoint` to `pos` (added last, after the flip, so mirroring + // still happens about the art's own center rather than the shifted + // anchor) + xl += this._anchorOffsetX; + xr += this._anchorOffsetX; + yt += this._anchorOffsetY; + yb += this._anchorOffsetY; const v = this.originalVertices; // c0 BL, c1 BR, c2 TR, c3 TL (z stays 0) v[0] = xl; @@ -719,7 +857,8 @@ export default class Sprite3d extends Mesh { } // emit the 4 corners as center ± right·localX ± up·localY (the local - // offsets are the baked ±hw / ±hh quad coordinates) + // offsets are the baked ±hw / ±hh quad coordinates, already shifted by + // the anchor offset) const out = this.vertices; const src = this.originalVertices; const rx = _right.x; diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index cbd78c3e54..1d2d358941 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -1,6 +1,7 @@ import { getBinary, getImage } from "../../loader/loader.js"; import { Color } from "../../math/color.ts"; import { vector2dPool } from "../../math/vector2d.ts"; +import { resolveAnchorPoint } from "../anchorPoint.ts"; import Renderable from "../renderable.js"; import { bitmapTextDataPool } from "./bitmaptextdata.ts"; import TextMetrics from "./textmetrics.js"; @@ -28,7 +29,7 @@ export default class BitmapText extends Renderable { * @param {string} [settings.textAlign="left"] - horizontal text alignment * @param {string} [settings.textBaseline="top"] - the text baseline * @param {number} [settings.lineHeight=1.0] - line spacing height - * @param {Vector2d} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at + * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. * @param {number} [settings.wordWrapWidth] - the maximum length in CSS pixel for a single segment of text * @param {(string|string[])} [settings.text] - a string, or an array of strings * @example @@ -137,7 +138,11 @@ export default class BitmapText extends Renderable { // update anchorPoint if provided if (typeof settings.anchorPoint !== "undefined") { - this.anchorPoint.set(settings.anchorPoint.x, settings.anchorPoint.y); + const anchor = resolveAnchorPoint(settings.anchorPoint, "BitmapText", { + x: 0, + y: 0, + }); + this.anchorPoint.set(anchor.x, anchor.y); } else { this.anchorPoint.set(0, 0); } diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index 237fc8f37f..2a29384dec 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -2,6 +2,7 @@ import { game } from "../../application/application.ts"; import { Color, colorPool } from "../../math/color.ts"; import { nextPowerOfTwo } from "../../math/math.ts"; import CanvasRenderTarget from "../../video/rendertarget/canvasrendertarget.js"; +import { resolveAnchorPoint } from "../anchorPoint.ts"; import Renderable from "../renderable.js"; import TextMetrics from "./textmetrics.js"; import setContextStyle from "./textstyle.js"; @@ -64,7 +65,7 @@ export default class Text extends Renderable { * @param {string} [settings.textAlign="left"] - horizontal text alignment ("left", "center", "right") * @param {string} [settings.textBaseline="top"] - the text baseline ("top", "hanging", "middle", "alphabetic", "ideographic", "bottom") * @param {number} [settings.lineHeight=1.0] - line spacing height - * @param {Vector2d} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at + * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. * @param {number} [settings.wordWrapWidth] - the maximum length in CSS pixels of a line before it wraps * @param {(string|string[])} [settings.text=""] - a string, or an array of strings * @example @@ -205,7 +206,11 @@ export default class Text extends Renderable { // anchor point if (typeof settings.anchorPoint !== "undefined") { - this.anchorPoint.set(settings.anchorPoint.x, settings.anchorPoint.y); + const anchor = resolveAnchorPoint(settings.anchorPoint, "Text", { + x: 0, + y: 0, + }); + this.anchorPoint.set(anchor.x, anchor.y); } else { this.anchorPoint.set(0, 0); } diff --git a/packages/melonjs/tests/anchorpoint-presets.spec.js b/packages/melonjs/tests/anchorpoint-presets.spec.js new file mode 100644 index 0000000000..c05a969a5c --- /dev/null +++ b/packages/melonjs/tests/anchorpoint-presets.spec.js @@ -0,0 +1,458 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { + BitmapText, + boot, + Collectable, + Entity, + ImageLayer, + loader, + NineSliceSprite, + pool, + Sprite, + Sprite3d, + Text, + Vector2d, + video, +} from "../src/index.js"; +import { applyTMXProperties } from "../src/level/tiled/TMXUtils.js"; + +/** + * The `settings.anchorPoint` contract, shared by every renderable that + * consumes it: named presets ("bottom", "top-left", …) resolve identically + * everywhere, `{x, y}` objects pass through untouched (full backward + * compatibility), and invalid values are handled per-surface: + * + * - 2D classes (Sprite, Entity, Collectable, ImageLayer, Text, BitmapText): + * invalid values keep their legacy outcome — anchor (0, 0), previously via + * the silent `set(undefined, undefined)` path — but now log a console + * warning instead of hiding the mistake. NOTHING that constructs today may + * start throwing. + * - Sprite3d (new API surface, nothing shipped): invalid values throw. + */ + +// a FRESH image per construction: spritesheet atlases are cached per image +// and store the first construction's anchor as the shared per-frame pivot +// (pre-existing engine behavior), so sharing one image across cases would +// leak anchors between tests. A plain DOM canvas also avoids the deprecation +// warning of video.createCanvas, which would false-positive the warn spies. +const freshImage = () => { + const c = document.createElement("canvas"); + c.width = 32; + c.height = 32; + return c; +}; + +beforeAll(async () => { + boot(); + video.init(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + // BitmapText needs a real bitmap font + await new Promise((resolve) => { + loader.preload( + [ + { name: "xolo12", type: "image", src: "/data/fnt/xolo12.png" }, + { name: "xolo12", type: "binary", src: "/data/fnt/xolo12.fnt" }, + ], + resolve, + ); + }); +}); + +// every consumer of settings.anchorPoint: factory + its no-anchor default +const CONSUMERS = [ + { + name: "Sprite", + make: (anchor) => { + const settings = { image: freshImage() }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new Sprite(0, 0, settings); + }, + defaults: { x: 0.5, y: 0.5 }, + throws: false, + }, + { + name: "Entity", + make: (anchor) => { + const settings = { + width: 32, + height: 64, + image: freshImage(), + shapes: [], + }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new Entity(0, 0, settings); + }, + defaults: { x: 0, y: 0 }, + throws: false, + }, + { + name: "Collectable", + make: (anchor) => { + const settings = { + image: freshImage(), + framewidth: 32, + frameheight: 32, + width: 32, + height: 32, + }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new Collectable(0, 0, settings); + }, + defaults: { x: 0, y: 0 }, + throws: false, + }, + { + name: "ImageLayer", + make: (anchor) => { + const settings = { image: freshImage() }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new ImageLayer(0, 0, settings); + }, + defaults: { x: 0, y: 0 }, + throws: false, + }, + { + name: "Text", + make: (anchor) => { + const settings = { font: "Arial", size: 16 }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new Text(0, 0, settings); + }, + defaults: { x: 0, y: 0 }, + throws: false, + }, + { + name: "BitmapText", + make: (anchor) => { + const settings = { font: "xolo12", size: 1, text: "A" }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new BitmapText(0, 0, settings); + }, + defaults: { x: 0, y: 0 }, + throws: false, + }, + { + name: "Sprite3d", + make: (anchor) => { + const settings = { image: freshImage(), width: 32, height: 32 }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new Sprite3d(0, 0, settings); + }, + defaults: { x: 0.5, y: 0.5 }, + throws: true, + }, +]; + +describe.each(CONSUMERS)("settings.anchorPoint on $name", ({ + make, + defaults, + throws, +}) => { + it('resolves the "bottom" preset to (0.5, 1)', () => { + const r = make("bottom"); + expect(r.anchorPoint.x).toBe(0.5); + expect(r.anchorPoint.y).toBe(1); + }); + + it('resolves the "top-left" preset to (0, 0)', () => { + const r = make("top-left"); + expect(r.anchorPoint.x).toBe(0); + expect(r.anchorPoint.y).toBe(0); + }); + + it("a preset and its equivalent {x, y} object land identically", () => { + const a = make("bottom"); + const b = make({ x: 0.5, y: 1 }); + expect(a.anchorPoint.x).toBe(b.anchorPoint.x); + expect(a.anchorPoint.y).toBe(b.anchorPoint.y); + }); + + it("still accepts a Vector2d instance (back-compat)", () => { + const r = make(new Vector2d(0.25, 0.75)); + expect(r.anchorPoint.x).toBe(0.25); + expect(r.anchorPoint.y).toBe(0.75); + }); + + it("out-of-range values pass through unclamped (back-compat)", () => { + const r = make({ x: -0.5, y: 2 }); + expect(r.anchorPoint.x).toBe(-0.5); + expect(r.anchorPoint.y).toBe(2); + }); + + it("the default is preserved when no anchorPoint is given", () => { + const r = make(undefined); + expect(r.anchorPoint.x).toBe(defaults.x); + expect(r.anchorPoint.y).toBe(defaults.y); + }); + + const garbage = [ + ["an unknown preset", "botom"], + ["a wrong-cased key object", { X: 1, Y: 1 }], + ["a string-valued object", { x: "1", y: "1" }], + ["a NaN component", { x: Number.NaN, y: 1 }], + ]; + + if (throws) { + it.each(garbage)("throws on %s (new API surface)", (_label, value) => { + expect(() => { + make(value); + }).toThrow(); + }); + } else { + it.each( + garbage, + )("warns and keeps the legacy (0, 0) outcome on %s — never throws", (_label, value) => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + let r; + expect(() => { + r = make(value); + }).not.toThrow(); + expect(r.anchorPoint.x).toBe(0); + expect(r.anchorPoint.y).toBe(0); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + } +}); + +describe("ImageLayer bare-number shorthand", () => { + it("anchorPoint: 0.3 anchors both axes and never reaches the strict resolver", () => { + const layer = new ImageLayer(0, 0, { + image: freshImage(), + anchorPoint: 0.3, + }); + expect(layer.anchorPoint.x).toBe(0.3); + expect(layer.anchorPoint.y).toBe(0.3); + }); + + it("anchorPoint: 0 (falsy) keeps the (0, 0) default", () => { + const layer = new ImageLayer(0, 0, { image: freshImage(), anchorPoint: 0 }); + expect(layer.anchorPoint.x).toBe(0); + expect(layer.anchorPoint.y).toBe(0); + }); +}); + +/** + * Sprite (2D) backward compatibility — adversarial. These pin the exact + * pre-existing behaviors a game upgrading to 19.9 depends on, including the + * warts (shared-atlas pivot inheritance), and the one deliberate + * safety improvement (no aliasing of caller objects into the shared cache). + */ +describe("Sprite backward compatibility — adversarial", () => { + const freshSheet = () => { + const c = document.createElement("canvas"); + c.width = 64; + c.height = 32; + return c; + }; + + it("spritesheet path: a preset and its {x, y} equivalent resolve identically", () => { + const a = new Sprite(0, 0, { + image: freshSheet(), + framewidth: 32, + frameheight: 32, + anchorPoint: "bottom", + }); + const b = new Sprite(0, 0, { + image: freshSheet(), + framewidth: 32, + frameheight: 32, + anchorPoint: { x: 0.5, y: 1 }, + }); + expect([a.anchorPoint.x, a.anchorPoint.y]).toEqual([0.5, 1]); + expect([b.anchorPoint.x, b.anchorPoint.y]).toEqual([0.5, 1]); + }); + + it("PIN (pre-existing): the first sprite's anchor becomes the shared atlas pivot for later sprites of the same image", () => { + // documented-wart behavior, unchanged by the presets feature: the + // spritesheet descriptor (including the anchor) is cached per image, + // so a second sprite WITHOUT an anchor inherits the first one's. + // Candidate to revisit in the #1410 TextureCache refactor. + const shared = freshSheet(); + const first = new Sprite(0, 0, { + image: shared, + framewidth: 32, + frameheight: 32, + anchorPoint: { x: 0.25, y: 0.75 }, + }); + const second = new Sprite(0, 0, { + image: shared, + framewidth: 32, + frameheight: 32, + }); + expect([first.anchorPoint.x, first.anchorPoint.y]).toEqual([0.25, 0.75]); + expect([second.anchorPoint.x, second.anchorPoint.y]).toEqual([0.25, 0.75]); + }); + + it("mutating the caller's Vector2d after construction does NOT retro-poison the sprite or the cache", () => { + const shared = freshSheet(); + const callerVector = new Vector2d(0.3, 0.4); + const a = new Sprite(0, 0, { + image: shared, + framewidth: 32, + frameheight: 32, + anchorPoint: callerVector, + }); + callerVector.set(0.9, 0.9); + expect([a.anchorPoint.x, a.anchorPoint.y]).toEqual([0.3, 0.4]); + // a later same-image sprite inherits the value AT CONSTRUCTION TIME, + // not the caller's mutated object + const b = new Sprite(0, 0, { + image: shared, + framewidth: 32, + frameheight: 32, + }); + expect([b.anchorPoint.x, b.anchorPoint.y]).toEqual([0.3, 0.4]); + }); + + it("garbage on a shared spritesheet keeps the legacy (0, 0) for BOTH sprites (master parity)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const shared = freshSheet(); + const a = new Sprite(0, 0, { + image: shared, + framewidth: 32, + frameheight: 32, + anchorPoint: { X: 1, Y: 1 }, + }); + const b = new Sprite(0, 0, { + image: shared, + framewidth: 32, + frameheight: 32, + }); + expect([a.anchorPoint.x, a.anchorPoint.y]).toEqual([0, 0]); + expect([b.anchorPoint.x, b.anchorPoint.y]).toEqual([0, 0]); + } finally { + warnSpy.mockRestore(); + } + }); + + it("the anchor survives 2D animation frame changes and flips (untrimmed frames)", () => { + const s = new Sprite(0, 0, { + image: freshSheet(), + framewidth: 32, + frameheight: 32, + anchorPoint: { x: 0.3, y: 0.8 }, + }); + s.addAnimation("walk", [0, 1], 100); + s.setCurrentAnimation("walk"); + s.setAnimationFrame(1); + expect([s.anchorPoint.x, s.anchorPoint.y]).toEqual([0.3, 0.8]); + // untrimmed frames: flipping does NOT mirror the pivot (legacy rule — + // the pivot mirror only applies to trimmed atlas frames) + s.flipX(); + expect([s.anchorPoint.x, s.anchorPoint.y]).toEqual([0.3, 0.8]); + }); + + it("Entity: the preset reaches both the entity AND its child sprite", () => { + const e = new Entity(0, 0, { + width: 32, + height: 32, + image: freshImage(), + shapes: [], + anchorPoint: "bottom", + }); + expect([e.anchorPoint.x, e.anchorPoint.y]).toEqual([0.5, 1]); + expect([e.renderable.anchorPoint.x, e.renderable.anchorPoint.y]).toEqual([ + 0.5, 1, + ]); + }); + + it("NineSliceSprite (Sprite subclass) accepts presets through the inherited path", () => { + const n = new NineSliceSprite(0, 0, { + width: 96, + height: 96, + image: freshImage(), + framewidth: 32, + frameheight: 32, + anchorPoint: "bottom", + }); + expect([n.anchorPoint.x, n.anchorPoint.y]).toEqual([0.5, 1]); + }); +}); + +describe("preset table — every name maps to its documented value", () => { + // validates the WHOLE table (a typo'd pair in one preset would otherwise + // slip through tests that only exercise "bottom"/"top-left") + const TABLE = [ + ["center", 0.5, 0.5], + ["top", 0.5, 0], + ["bottom", 0.5, 1], + ["left", 0, 0.5], + ["right", 1, 0.5], + ["top-left", 0, 0], + ["top-right", 1, 0], + ["bottom-left", 0, 1], + ["bottom-right", 1, 1], + ]; + + it.each(TABLE)('"%s" resolves to (%f, %f)', (name, x, y) => { + const s = new Sprite(0, 0, { image: freshImage(), anchorPoint: name }); + expect(s.anchorPoint.x).toBe(x); + expect(s.anchorPoint.y).toBe(y); + }); +}); + +describe("Tiled property chain", () => { + it("a Tiled string property anchorPoint = 'bottom' survives coercion as a string", () => { + const obj = {}; + applyTMXProperties(obj, { + properties: [{ name: "anchorPoint", type: "string", value: "bottom" }], + }); + expect(obj.anchorPoint).toBe("bottom"); + // ...and lands correctly through a renderable constructor + const s = new Sprite(0, 0, { + image: freshImage(), + anchorPoint: obj.anchorPoint, + }); + expect([s.anchorPoint.x, s.anchorPoint.y]).toEqual([0.5, 1]); + }); + + it("a numeric Tiled anchorPoint property still expands to {x, y} (legacy rule)", () => { + const obj = {}; + applyTMXProperties(obj, { + properties: [{ name: "anchorPoint", type: "string", value: "0.5" }], + }); + expect(obj.anchorPoint).toEqual({ x: 0.5, y: 0.5 }); + }); +}); + +describe("pool recycling", () => { + it("a pooled Text resolves presets on pull AND on recycle", () => { + const t1 = pool.pull("Text", 0, 0, { + font: "Arial", + size: 16, + anchorPoint: "bottom", + }); + expect([t1.anchorPoint.x, t1.anchorPoint.y]).toEqual([0.5, 1]); + pool.push(t1); + // recycled instance must re-resolve the NEW settings, not keep the old + const t2 = pool.pull("Text", 0, 0, { + font: "Arial", + size: 16, + anchorPoint: "top-right", + }); + expect([t2.anchorPoint.x, t2.anchorPoint.y]).toEqual([1, 0]); + pool.push(t2); + }); +}); diff --git a/packages/melonjs/tests/sprite3d.spec.js b/packages/melonjs/tests/sprite3d.spec.js index 8ccdf622f3..68be55927a 100644 --- a/packages/melonjs/tests/sprite3d.spec.js +++ b/packages/melonjs/tests/sprite3d.spec.js @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { boot, Camera3d, + Mesh, Sprite3d, Vector2d, Vector3d, @@ -641,3 +642,513 @@ describe("Sprite3d resource cleanup", () => { expect(s._frameAnim.current.offset).toBeNull(); }); }); + +describe("Sprite3d anchorPoint (vertex-baked anchor)", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + const makeTex = (w = 4, h = 4) => { + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + c.getContext("2d").fillRect(0, 0, w, h); + return c; + }; + + // 20×30 quad at (100, 50): hw = 10, hh = 15 + const mk = (anchor, extra = {}) => { + const settings = { + image: makeTex(), + width: 20, + height: 30, + z: -200, + ...extra, + }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new Sprite3d(100, 50, settings); + }; + + const localXs = (v) => { + return [v[0], v[3], v[6], v[9]]; + }; + const localYs = (v) => { + return [v[1], v[4], v[7], v[10]]; + }; + + it('"bottom" bakes the local quad shifted so the bottom edge is at the origin', () => { + const v = mk("bottom").originalVertices; + // offsetY = (1 - 0.5) * 30 = +15 → local y ∈ {0, 30} (local +Y = art top) + expect(new Set(localYs(v))).toEqual(new Set([0, 30])); + expect(new Set(localXs(v))).toEqual(new Set([-10, 10])); + }); + + it('"top-left" bakes both offsets', () => { + const v = mk("top-left").originalVertices; + // offsetX = (0.5 - 0) * 20 = +10; offsetY = (0 - 0.5) * 30 = -15 + expect(new Set(localXs(v))).toEqual(new Set([0, 20])); + expect(new Set(localYs(v))).toEqual(new Set([-30, 0])); + }); + + it('"bottom": feet land exactly at pos on the fixed-orientation path', () => { + const s = mk("bottom"); + s._projectVerticesWorld(s.pos.x, s.pos.y, s.depth); + const ys = [s.vertices[1], s.vertices[4], s.vertices[7], s.vertices[10]]; + // world Y is negated local Y: feet (local 0) at pos.y, head at pos.y - h + expect(Math.max(...ys)).toBeCloseTo(50, 5); + expect(Math.min(...ys)).toBeCloseTo(20, 5); + const xs = [s.vertices[0], s.vertices[3], s.vertices[6], s.vertices[9]]; + expect(Math.min(...xs)).toBeCloseTo(90, 5); + expect(Math.max(...xs)).toBeCloseTo(110, 5); + }); + + it('"bottom" + cylindrical billboard: feet stay planted and upright at any yaw/pitch', () => { + const cam = new Camera3d(0, 0, 64, 64); + const s = mk("bottom", { billboard: "cylindrical" }); + for (const [yaw, pitch] of [ + [0, 0], + [0.8, -0.3], + [-1.2, 0.6], + ]) { + cam.yaw = yaw; + cam.pitch = pitch; + s._billboardCam = cam; + s._projectVerticesWorld(s.pos.x, s.pos.y, s.depth); + const v = s.vertices; + const ys = [v[1], v[4], v[7], v[10]]; + // two corners at the feet (pos.y), two at the head (pos.y - h) + expect( + ys.filter((y) => { + return Math.abs(y - 50) < 1e-4; + }).length, + ).toBe(2); + expect( + ys.filter((y) => { + return Math.abs(y - 20) < 1e-4; + }).length, + ).toBe(2); + // the vertical edge (c0 → c3) is purely world-up: no x/z drift + expect(Math.abs(v[9] - v[0])).toBeLessThan(1e-4); + expect(Math.abs(v[11] - v[2])).toBeLessThan(1e-4); + } + }); + + it('"bottom-left" + flipX: anchor stays pinned, extents unchanged, toggle restores', () => { + const s = mk("bottom-left"); + const before = Array.from(s.originalVertices); + expect(new Set(localXs(s.originalVertices))).toEqual(new Set([0, 20])); + expect(new Set(localYs(s.originalVertices))).toEqual(new Set([0, 30])); + + s.flipX(); + // mirrored about the art's own center, then re-pinned: same extents + expect(new Set(localXs(s.originalVertices))).toEqual(new Set([0, 20])); + expect(new Set(localYs(s.originalVertices))).toEqual(new Set([0, 30])); + + s.flipX(false); + expect(Array.from(s.originalVertices)).toEqual(before); + }); + + it('trimmed atlas region + "bottom": trim mapping plus the anchor offset', () => { + const img = makeTex(200, 200); + const s = new Sprite3d(0, 0, { + image: img, + width: 200, + height: 200, + anchorPoint: "bottom", + }); + // same trimmed region as the un-anchored mapping test above + s.setRegion({ + offset: { x: 10, y: 20 }, + width: 120, + height: 140, + trimmed: true, + trim: { x: 40, y: 30, w: 120, h: 140 }, + sourceSize: { w: 200, h: 200 }, + angle: 0, + }); + const v = s.originalVertices; + // offsetY = +100 on top of the (-60,-70)…(60,70) trim mapping + expect([v[0], v[1]]).toEqual([-60, 30]); + expect([v[6], v[7]]).toEqual([60, 170]); + // UVs are untouched by the anchor + expect(v && s.uvs[0]).toBeCloseTo(0.05, 5); + expect(s.uvs[5]).toBeCloseTo(0.1, 5); + }); + + it("runtime anchorPoint.set() re-bakes the quad and grows the cull bounds (region path)", () => { + const s = mk(undefined); // centered default + expect(new Set(localYs(s.originalVertices))).toEqual(new Set([-15, 15])); + expect(s.width).toBe(30); // max(w, h), legacy centered bounds + + s.isDirty = false; + s.anchorPoint.set(0.5, 1); + + expect(s._anchorOffsetX).toBe(0); + expect(s._anchorOffsetY).toBe(15); + expect(new Set(localYs(s.originalVertices))).toEqual(new Set([0, 30])); + expect(s.isDirty).toBe(true); + // circumradius about pos: √2 · hypot(hw + |ox|, hh + |oy|) + expect(s.width).toBeCloseTo(Math.SQRT2 * Math.hypot(10, 30), 5); + expect(s.height).toBeCloseTo(Math.SQRT2 * Math.hypot(10, 30), 5); + }); + + it("runtime anchorPoint.set() re-bakes the plain quad on the no-region path", () => { + const s = mk("bottom"); + // force the named-atlas path where no frame region ever landed + s._region = null; + s.anchorPoint.set(0.5, 0.5); + expect(new Set(localYs(s.originalVertices))).toEqual(new Set([-15, 15])); + expect(new Set(localXs(s.originalVertices))).toEqual(new Set([-10, 10])); + }); + + it("cull bounds: grown exactly for the bottom anchor, unchanged for the centered default", () => { + const centered = new Sprite3d(0, 0, { + image: makeTex(), + width: 130, + height: 225, + }); + expect(centered.width).toBe(225); + + const anchored = new Sprite3d(0, 0, { + image: makeTex(), + width: 130, + height: 225, + anchorPoint: "bottom", + }); + // √2 · hypot(65, 112.5 + 112.5) ≈ 331.22 — the sphere derived from + // this (radius = side/√2) exactly encloses the billboard sweep + expect(anchored.width).toBeCloseTo(Math.SQRT2 * Math.hypot(65, 225), 4); + expect(anchored.width).toBeGreaterThan(225); + }); + + it("the anchor is never applied as a renderer transform, on either camera path", () => { + const noop = () => {}; + const fakeRenderer = { + save: noop, + restore: noop, + resetTransform: noop, + translate: noop, + transform: noop, + setColor: noop, + setBlendMode: noop, + getBlendMode: () => { + return "normal"; + }, + setTint: noop, + clearTint: noop, + setDepth: noop, + setGlobalAlpha: noop, + globalAlpha: () => { + return 1; + }, + getGlobalAlpha: () => { + return 1; + }, + setCustomShader: noop, + clearCustomShader: noop, + beginPostEffect: noop, + endPostEffect: noop, + currentTransform: { + isIdentity: () => { + return true; + }, + }, + }; + + for (const sprite of [mk("bottom"), mk(undefined)]) { + sprite._useWorldSpace = true; + sprite.preDraw(fakeRenderer); + expect(sprite.applyAnchorTransform).toBe(false); + sprite._useWorldSpace = false; + sprite.preDraw(fakeRenderer); + expect(sprite.applyAnchorTransform).toBe(false); + } + + // a plain Mesh keeps the legacy 2D anchor behavior + const mesh = new Mesh(0, 0, { + vertices: [0, 0, 0, 10, 0, 0, 10, 10, 0], + uvs: [0, 0, 1, 0, 1, 1], + indices: [0, 1, 2], + width: 10, + normalize: false, + }); + mesh._useWorldSpace = false; + mesh.preDraw(fakeRenderer); + expect(mesh.applyAnchorTransform).toBe(true); + }); +}); + +describe("Sprite3d anchorPoint — adversarial", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + const makeTex = (w = 4, h = 4) => { + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + c.getContext("2d").fillRect(0, 0, w, h); + return c; + }; + + const mk = (anchor, extra = {}) => { + const settings = { + image: makeTex(), + width: 20, + height: 30, + z: -200, + ...extra, + }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + return new Sprite3d(100, 50, settings); + }; + + // 64×32 sheet → two 32×32 frames, sprite 1:1 + const makeAnimated = (anchor) => { + const sheet = document.createElement("canvas"); + sheet.width = 64; + sheet.height = 32; + sheet.getContext("2d").fillRect(0, 0, 64, 32); + const settings = { + image: sheet, + framewidth: 32, + frameheight: 32, + width: 32, + height: 32, + }; + if (anchor !== undefined) { + settings.anchorPoint = anchor; + } + const s = new Sprite3d(0, 0, settings); + s.addAnimation("walk", [0, 1], 100); + s.setCurrentAnimation("walk"); + return s; + }; + + const xs = (v) => { + return [v[0], v[3], v[6], v[9]]; + }; + const ys = (v) => { + return [v[1], v[4], v[7], v[10]]; + }; + + it("the anchor survives animation frame changes", () => { + const s = makeAnimated("bottom"); // 32×32 → offsetY = +16 + const before = Array.from(s.originalVertices); + s.setAnimationFrame(1); + // same geometry (untrimmed equal-size frames), only UVs move + expect(Array.from(s.originalVertices)).toEqual(before); + expect(new Set(ys(s.originalVertices))).toEqual(new Set([0, 32])); + }); + + it("combined torture: anchor + flipX + frame change + runtime re-anchor", () => { + const s = makeAnimated("bottom"); + s.flipX(); + s.setAnimationFrame(1); + // bottom anchor, flipped: x mirrored about art center, feet pinned + expect(new Set(xs(s.originalVertices))).toEqual(new Set([-16, 16])); + expect(new Set(ys(s.originalVertices))).toEqual(new Set([0, 32])); + + // re-anchor at runtime to top-right while flipped mid-animation + s.anchorPoint.set(1, 0); + // offsets: ox = (0.5-1)*32 = -16, oy = (0-0.5)*32 = -16 + expect(new Set(xs(s.originalVertices))).toEqual(new Set([-32, 0])); + expect(new Set(ys(s.originalVertices))).toEqual(new Set([-32, 0])); + expect(s.isFlippedX()).toBe(true); + + // unflip: geometry extents identical (mirror about art center), UVs revert + s.flipX(false); + expect(new Set(xs(s.originalVertices))).toEqual(new Set([-32, 0])); + expect(new Set(ys(s.originalVertices))).toEqual(new Set([-32, 0])); + }); + + it("anchor on a BOTH rotated and trimmed region (offsets on top of the mapping)", () => { + const img = makeTex(256, 256); + const s = new Sprite3d(0, 0, { + image: img, + width: 60, + height: 100, + anchorPoint: "bottom", // offsetY = +50 + }); + s._refLw = 0; + s._refLh = 0; + s.setRegion({ + offset: { x: 5, y: 7 }, + width: 40, + height: 80, + trimmed: true, + trim: { x: 10, y: 8, w: 40, h: 80 }, + sourceSize: { w: 60, h: 100 }, + angle: -Math.PI / 2, + }); + const v = s.originalVertices; + // base mapping (see the un-anchored rotated+trimmed test) + (0, +50) + expect([v[0], v[1]]).toEqual([-20, 12]); + expect([v[3], v[4]]).toEqual([20, 12]); + expect([v[6], v[7]]).toEqual([20, 92]); + expect([v[9], v[10]]).toEqual([-20, 92]); + }); + + it("round-tripping anchors restores the centered geometry and bounds exactly", () => { + const s = mk("bottom"); + s.anchorPoint.set(0.25, 0.75); + s.anchorPoint.set(2, -1); // out of range on purpose + s.anchorPoint.set(0.5, 0.5); // back to center + const fresh = mk(undefined); + expect(Array.from(s.originalVertices)).toEqual( + Array.from(fresh.originalVertices), + ); + expect(s.width).toBe(fresh.width); + expect(s.height).toBe(fresh.height); + expect(s.getBounds().width).toBeCloseTo(fresh.getBounds().width, 5); + }); + + it("single-axis anchorPoint.y = 1 writes re-bake too", () => { + const s = mk(undefined); + s.anchorPoint.y = 1; + expect(s._anchorOffsetY).toBe(15); + expect(new Set(ys(s.originalVertices))).toEqual(new Set([0, 30])); + }); + + it("out-of-range anchors: the cull sphere still encloses every projected corner", () => { + const cam = new Camera3d(0, 0, 64, 64); + for (const anchor of ["bottom", "top-left", { x: 2, y: -1 }]) { + const s = mk(anchor, { billboard: "cylindrical" }); + // Camera3d derives the cull radius from the bounds like this: + const radius = Math.hypot(s.width, s.height) / 2; + for (const [yaw, pitch] of [ + [0, 0], + [0.8, -0.3], + [-1.2, 0.6], + ]) { + cam.yaw = yaw; + cam.pitch = pitch; + s._billboardCam = cam; + s._projectVerticesWorld(s.pos.x, s.pos.y, s.depth); + const v = s.vertices; + for (let i = 0; i < 4; i++) { + const d = Math.hypot( + v[i * 3] - s.pos.x, + v[i * 3 + 1] - s.pos.y, + v[i * 3 + 2] - s.depth, + ); + expect(d).toBeLessThanOrEqual(radius + 1e-3); + } + } + } + }); + + it("spherical billboard + anchor: corner distances from pos are rotation-invariant", () => { + const cam = new Camera3d(0, 0, 64, 64); + const s = mk("bottom", { billboard: "spherical" }); + // local corner distances: (±10, 0) → 10, (±10, 30) → hypot(10, 30) + const expected = [10, 10, Math.hypot(10, 30), Math.hypot(10, 30)].sort( + (a, b) => { + return a - b; + }, + ); + for (const [yaw, pitch] of [ + [0, 0], + [0.8, -0.3], + [-1.2, 0.6], + ]) { + cam.yaw = yaw; + cam.pitch = pitch; + s._billboardCam = cam; + s._projectVerticesWorld(s.pos.x, s.pos.y, s.depth); + const v = s.vertices; + const dists = [0, 1, 2, 3] + .map((i) => { + return Math.hypot( + v[i * 3] - s.pos.x, + v[i * 3 + 1] - s.pos.y, + v[i * 3 + 2] - s.depth, + ); + }) + .sort((a, b) => { + return a - b; + }); + for (let i = 0; i < 4; i++) { + expect(dists[i]).toBeCloseTo(expected[i], 4); + } + } + }); + + it("frame-derived size (no explicit width): offsets computed from the frame size", () => { + const sheet = document.createElement("canvas"); + sheet.width = 64; + sheet.height = 32; + sheet.getContext("2d").fillRect(0, 0, 64, 32); + const s = new Sprite3d(0, 0, { + image: sheet, + framewidth: 32, + frameheight: 32, + anchorPoint: "bottom", + }); + // w = h = 32 from the frame grid → offsetY = +16 + expect(s._anchorOffsetY).toBe(16); + expect(new Set(ys(s.originalVertices))).toEqual(new Set([0, 32])); + }); +}); + +describe("Sprite3d anchorPoint — remaining gap coverage", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + const makeTex = () => { + const c = document.createElement("canvas"); + c.width = 4; + c.height = 4; + c.getContext("2d").fillRect(0, 0, 4, 4); + return c; + }; + + const localYs = (v) => { + return [v[1], v[4], v[7], v[10]]; + }; + + it('flipY keeps a "bottom" anchor pinned (art mirrors inside the box)', () => { + const s = new Sprite3d(100, 50, { + image: makeTex(), + width: 20, + height: 30, + anchorPoint: "bottom", + }); + const before = Array.from(s.originalVertices); + s.flipY(); + // mirrored about the art's own center, then re-pinned: same extents + expect(new Set(localYs(s.originalVertices))).toEqual(new Set([0, 30])); + s.flipY(false); + expect(Array.from(s.originalVertices)).toEqual(before); + }); + + it("REGRESSION GUARD: the legacy centered bounds would NOT enclose a bottom-anchored quad", () => { + // the review scenario: a 130×225 bottom-anchored character. The old + // bounds side max(w, h) = 225 gives a cull radius of √(225²+225²)/2 ≈ + // 159.1, while the head corners sit hypot(65, 225) ≈ 234.2 from pos — + // the sprite culled while a third of it was still on screen. This + // pins that the shipped bounds DO enclose it and the old ones don't. + const w = 130; + const h = 225; + const farthestCorner = Math.hypot(w / 2, h); + const legacyRadius = Math.hypot(Math.max(w, h), Math.max(w, h)) / 2; + expect(farthestCorner).toBeGreaterThan(legacyRadius); // the old bug + + const s = new Sprite3d(0, 0, { + image: makeTex(), + width: w, + height: h, + anchorPoint: "bottom", + }); + const shippedRadius = Math.hypot(s.width, s.height) / 2; + expect(shippedRadius).toBeGreaterThanOrEqual(farthestCorner - 1e-6); + }); +});