diff --git a/packages/examples/src/examples/billboard/ExampleBillboard.tsx b/packages/examples/src/examples/billboard/ExampleBillboard.tsx new file mode 100644 index 000000000..65e78a28a --- /dev/null +++ b/packages/examples/src/examples/billboard/ExampleBillboard.tsx @@ -0,0 +1,308 @@ +/** + * melonJS — Sprite3d billboard showcase. + * The SAME frame-animated character is shown three times side by side, one per + * billboard mode, under a Camera3d that continuously orbits, pitches and dollies + * — so the difference is obvious in motion: + * - FIXED → a flat quad; goes edge-on as the camera orbits. + * - UPRIGHT → cylindrical: turns to face the camera but stays vertical. + * - FACE → spherical: always fully faces the camera. + * A spherical name tag floats above each so its mode always reads. The character + * uses the shared cityscene atlas (CC0, also used by the TexturePacker example), + * whose walk frames are rotated AND trimmed — so this also shows Sprite3d + * mapping rotated/trimmed atlas regions onto the quad. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + Application, + Camera3d as Camera3dClass, + type CanvasRenderer, + loader, + Mesh, + plugin, + Renderable, + Sprite3d, + TextureAtlas, + video, + type WebGLRenderer, +} from "melonjs"; +import { createExampleComponent } from "../utils"; + +// the cityscene atlas (shared with the TexturePacker example) — its capguy/walk +// frames are packer-rotated AND trimmed, so the animated character also exercises +// Sprite3d's rotated/trimmed atlas-region mapping +const ATLAS_BASE = `${import.meta.env.BASE_URL}assets/texturePacker/img/`; +const WALK_FRAMES = [ + "capguy/walk/0001", + "capguy/walk/0002", + "capguy/walk/0003", + "capguy/walk/0004", + "capguy/walk/0005", + "capguy/walk/0006", + "capguy/walk/0007", + "capguy/walk/0008", +]; + +// a small label "tag" texture (rounded dark pill + accent border + title) shown +// floating above each character to name its billboard mode +function bakeLabel(title: string, accent: string) { + const w = 256; + const h = 72; + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + const ctx = c.getContext("2d"); + if (ctx) { + const r = 16; + ctx.beginPath(); + ctx.moveTo(r, 2); + ctx.arcTo(w - 2, 2, w - 2, h - 2, r); + ctx.arcTo(w - 2, h - 2, 2, h - 2, r); + ctx.arcTo(2, h - 2, 2, 2, r); + ctx.arcTo(2, 2, w - 2, 2, r); + ctx.closePath(); + ctx.fillStyle = "#12141d"; + ctx.fill(); + ctx.lineWidth = 6; + ctx.strokeStyle = accent; + ctx.stroke(); + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 38px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(title, w / 2, h / 2 + 2); + } + return c; +} + +// a power-of-two grid texture for the floor (so REPEAT tiling reads the motion) +function bakeGrid() { + const c = document.createElement("canvas"); + c.width = 64; + c.height = 64; + const ctx = c.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#161a24"; + ctx.fillRect(0, 0, 64, 64); + ctx.strokeStyle = "#2c3550"; + ctx.lineWidth = 2; + ctx.strokeRect(0, 0, 64, 64); + } + return c; +} + +function bakeSky() { + const c = document.createElement("canvas"); + c.width = 1; + c.height = 256; + const ctx = c.getContext("2d"); + if (ctx) { + const g = ctx.createLinearGradient(0, 0, 0, 256); + g.addColorStop(0, "#10131f"); + g.addColorStop(1, "#27314a"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 1, 256); + } + return c; +} + +class SkyBackdrop extends Renderable { + private sky = bakeSky(); + constructor() { + super(0, 0, 1, 1); + this.floating = true; + this.anchorPoint.set(0, 0); + } + override draw(renderer: CanvasRenderer | WebGLRenderer) { + renderer.drawImage( + this.sky, + 0, + 0, + 1, + 256, + 0, + 0, + renderer.width, + renderer.height, + ); + } +} + +const createGame = () => { + let app: Application; + try { + app = new Application(1024, 768, { + parent: "screen", + renderer: video.WEBGL, + scale: "auto", + cameraClass: Camera3dClass, + antiAlias: true, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + globalThis.alert( + "This example needs WebGL.\n\n" + + "Sprite3d / Camera3d rendering requires a WebGL-capable browser/GPU.\n\n" + + `Details: ${reason}`, + ); + throw err; + } + plugin.register(DebugPanelPlugin, "debugPanel"); + + let domCleanup: (() => void) | null = null; + + app.world.addChild(new SkyBackdrop(), -10000); + + // ── floor grid (a horizontal quad, for spatial reference) ─────────────── + const G = 600; // floor half-size + const T = 12; // grid tiles across + const floor = new Mesh(0, 0, { + // quad in the XZ plane (y = 0) + vertices: new Float32Array([-G, 0, -G, G, 0, -G, G, 0, G, -G, 0, G]), + uvs: new Float32Array([0, 0, T, 0, T, T, 0, T]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + normals: new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]), + texture: bakeGrid(), + width: G * 2 * Math.SQRT2, + height: G * 2 * Math.SQRT2, + scale: 1, + normalize: false, + rightHanded: true, + textureRepeat: "repeat", + cullBackFaces: false, + }); + floor.pos.set(0, 0); + floor.depth = 0; + app.world.addChild(floor); + + // ── the SAME animated character, shown in all three billboard modes ────── + // side by side, with a name tag floating above each — so the difference is + // obvious: as the camera orbits, FIXED goes edge-on while UPRIGHT/FACE keep + // turning toward you. The character is loaded from a packed atlas whose walk + // 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 modes: Array< + [number, boolean | "cylindrical" | "spherical", string, string] + > = [ + [-280, false, "FIXED", "#c061d6"], + [0, "cylindrical", "UPRIGHT", "#5fd0ff"], + [280, "spherical", "FACE", "#8fe060"], + ]; + loader.preload( + [ + { name: "cityscene", type: "json", src: `${ATLAS_BASE}cityscene.json` }, + { name: "cityscene", type: "image", src: `${ATLAS_BASE}cityscene.png` }, + ], + () => { + const atlas = new TextureAtlas( + loader.getJSON("cityscene"), + 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, + z: 0, + billboard: mode, + }); + 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, { + image: bakeLabel(label, accent), + width: 150, + height: 42, + z: 0, + billboard: "spherical", + }); + app.world.addChild(tag); + } + }, + false, + ); + + // ── auto-orbiting / pitching / dollying camera ────────────────────────── + 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 }; + + let t = 0; + let paused = false; + const place = () => { + const yaw = t * 0.55; // continuous orbit + // elevation sweep — kept positive so the camera always stays above the + // floor plane (y = 0) and never clips below it + const pitch = 0.35 + 0.3 * Math.sin(t * 0.5); + const dist = 620 + 220 * Math.sin(t * 0.37); // dolly in/out + // render space: up = -Y + camera.pos.set( + TARGET.x + Math.sin(yaw) * Math.cos(pitch) * dist, + TARGET.y - Math.sin(pitch) * dist, + TARGET.z + Math.cos(yaw) * Math.cos(pitch) * dist, + ); + camera.lookAt(TARGET.x, TARGET.y, TARGET.z); + }; + place(); + + class FlyDriver extends Renderable { + constructor() { + super(0, 0, 1, 1); + this.alwaysUpdate = true; + } + override update(dt: number) { + if (!paused) { + t += dt / 1000; + place(); + } + return true; + } + override draw() {} + } + app.world.addChild(new FlyDriver()); + + // ── on-screen controls ────────────────────────────────────────────────── + const parent = app.renderer.getCanvas().parentElement; + const btn = document.createElement("button"); + btn.textContent = "⏸ Pause"; + btn.style.cssText = + "position:absolute;top:16px;left:16px;z-index:1000;padding:8px 14px;" + + "background:#11131c;color:#cfe0ff;border:1px solid #38406a;border-radius:6px;" + + "cursor:pointer;font-family:sans-serif;font-size:14px;font-weight:600;"; + btn.addEventListener("click", () => { + paused = !paused; + btn.textContent = paused ? "▶ Resume" : "⏸ Pause"; + }); + const hint = document.createElement("div"); + hint.textContent = + "Same animated character in three Sprite3d billboard modes — FIXED (flat, goes edge-on) · UPRIGHT (cylindrical) · FACE (spherical). Tags always face you. Camera auto-orbits / pitches / zooms."; + hint.style.cssText = + "position:absolute;top:58px;left:16px;right:16px;color:#9fb6e8;" + + "font-family:sans-serif;font-size:12px;z-index:1000;" + + "text-shadow:0 1px 2px rgba(0,0,0,0.7);"; + if (parent) { + parent.style.position = "relative"; + parent.appendChild(btn); + parent.appendChild(hint); + } + domCleanup = () => { + btn.remove(); + hint.remove(); + }; + + return () => { + if (domCleanup) { + domCleanup(); + } + }; +}; + +export const ExampleBillboard = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 39569d89d..f547012f5 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -123,6 +123,11 @@ const ExampleNightCity = lazy(() => default: m.ExampleNightCity, })), ); +const ExampleBillboard = lazy(() => + import("./examples/billboard/ExampleBillboard").then((m) => ({ + default: m.ExampleBillboard, + })), +); const ExampleMesh3d = lazy(() => import("./examples/mesh3d/ExampleMesh3d").then((m) => ({ default: m.ExampleMesh3d, @@ -399,6 +404,14 @@ const examples: { description: "A low-poly procedural downtown using emissive geometry to build a night-city view, with a looping Camera3d flythrough.", }, + { + component: , + label: "Billboard Sprites", + path: "billboard", + sourceDir: "billboard", + description: + "Sprite3d billboard modes (fixed / cylindrical / spherical) under an auto-orbiting, pitching and zooming Camera3d — the 2.5D building block: textured sprites that face the camera in a 3D scene.", + }, { component: , label: "3D Material", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index b79052094..018b36d17 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -25,6 +25,8 @@ - **Mesh `lit` / `normals` settings** and per-vertex world-space normal projection for the Camera3d lighting path. - **`Renderable.applyAnchorTransform`** (default `true`) — new flag controlling whether `preDraw` applies the `anchorPoint` offset to the renderer transform. Defaults to the existing behavior; `Mesh` sets it `false` on the `Camera3d` world-space path (a 3D mesh is positioned by its transform and has no anchor box, so the normalized offset must not leak into the shared mesh view matrix). - **`Mesh` supports meshes with more than 65,535 vertices** — a `Uint32Array` index buffer is preserved as-is instead of being coerced to `Uint16Array`, so high-poly meshes (e.g. large glTF nodes) no longer have their indices silently truncated. +- **`Sprite3d` — sprites in a 3D scene** — the 3D counterpart of `Sprite`: a textured quad rendered under a `Camera3d`, for the 2.5D workflow (characters, pickups, foliage, signs, particles). Its headline feature is **billboarding** via `billboard: false` (fixed orientation), `true` / `"cylindrical"` (faces the camera but stays upright — the 2.5D default), or `"spherical"` (faces the camera on all axes). As a thin `Mesh` subclass it rides the same world-space pipeline (depth testing, frustum culling) and material features (`lit`, `emissive`, `alphaCutoff`). **Frame animation** works through the exact same API as `Sprite` (`framewidth`/`frameheight` spritesheets or a packed `TextureAtlas`; `addAnimation` / `setCurrentAnimation` / `play` / `pause` / `stop`), mapping the current frame onto the quad — including packer **rotated** and **trimmed** atlas regions, at full parity with the 2D `Sprite`. Since the mesh pass is opaque (no alpha blending), `alphaCutoff` defaults to `0.5` so a sprite's transparent background cuts out cleanly (correct depth, no sorting); pass `alphaCutoff: 0` for a fully-opaque quad. `flipX()` / `flipY()` (and `flipX`/`flipY` settings) mirror the sprite — handy for facing a character left/right — and work across all billboard modes and rotated/trimmed atlas regions. Billboarding follows the same up/forward convention as a loaded glTF scene, so the two compose without flipping. New **Billboard Sprites** example (no external assets). Billboarding has no effect under a 2D `Camera2d` (use `Sprite`). +- **`FrameAnimation` — shared frame-animation engine** — the sprite-sheet animation logic (definitions, frame timing, looping, chaining) is now a standalone engine driving both `Sprite` (2D) and `Sprite3d` (3D) through one implementation, so the two never diverge. Each host applies the resolved frame to its own geometry (`Sprite` swaps its sub-texture / anchor; `Sprite3d` remaps UVs). The public `Sprite` API (`anim`, `current`, `animationspeed`, `animationpause`, every animation method) is unchanged — now surfaced as accessors onto the engine. ### Fixed - **glTF/3D meshes rendered at the wrong position under `Camera3d`** — props appeared sunk into / overlapping the surfaces they rested on, even though their parsed placement was numerically identical to the authoring tool. `Renderable.preDraw` was baking each mesh's normalized anchor-point offset (`width/2`, `height/2`) into the shared mesh batcher view matrix; since scene meshes size their bounds box per node, every mesh shifted by a different amount and lost their relative placement. The world-space mesh path now opts out of the anchor offset (see `applyAnchorTransform`), so meshes land exactly where the authoring tool put them. diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 9736cb789..02a81da3e 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -20,6 +20,11 @@ const _viewMatrix = new Matrix3d(); const _viewProjection = new Matrix3d(); // scratch point for worldToScreen, reused to avoid per-call allocation const _wsPoint = new Vector3d(); +// scratch reused by the orientation-basis accessors (getBasis / getRight / …), +// to avoid per-call allocation on the billboard draw path. +const _basis = new Matrix3d(); +const _bScratchA = new Vector3d(); +const _bScratchB = new Vector3d(); /** * A perspective camera that extends {@link Camera2d} with a view @@ -238,6 +243,63 @@ export default class Camera3d extends Camera2d { return this; } + /** + * Write the camera's world-space orientation basis into the given vectors: + * `right` (camera local +X), `up` (+Y), and `forward` (+Z — the direction the + * camera looks). Derived from `yaw` / `pitch` (the inverse of the view + * rotation), so they update as the camera turns. Handy for orienting + * camera-facing geometry — e.g. {@link Sprite3d} billboards. + * @param right - receives the right axis (unit) + * @param up - receives the up axis (unit) + * @param forward - receives the forward / look axis (unit) + * @returns this camera, for chaining + */ + getBasis(right: Vector3d, up: Vector3d, forward: Vector3d): this { + // camera world orientation R = inverse of the view rotation. The view is + // R(-pitch, X) ∘ R(-yaw, Y) (see _applyContainerViewTransform), so + // R = R(yaw, Y) ∘ R(pitch, X); the columns of R (column-major `val`) are + // the camera's right / up / forward axes in world space. + _basis.identity(); + _basis.rotate(this.yaw, AXIS_Y); + _basis.rotate(this.pitch, AXIS_X); + const v = _basis.val; + right.set(v[0], v[1], v[2]); + up.set(v[4], v[5], v[6]); + forward.set(v[8], v[9], v[10]); + return this; + } + + /** + * The camera's world-space right axis (unit). See {@link Camera3d#getBasis}. + * @param out - vector to write into (returned) + * @returns `out` + */ + getRight(out: Vector3d): Vector3d { + this.getBasis(out, _bScratchA, _bScratchB); + return out; + } + + /** + * The camera's world-space up axis (unit). See {@link Camera3d#getBasis}. + * @param out - vector to write into (returned) + * @returns `out` + */ + getUp(out: Vector3d): Vector3d { + this.getBasis(_bScratchA, out, _bScratchB); + return out; + } + + /** + * The camera's world-space forward / look axis (unit). See + * {@link Camera3d#getBasis}. + * @param out - vector to write into (returned) + * @returns `out` + */ + getForward(out: Vector3d): Vector3d { + this.getBasis(_bScratchA, _bScratchB, out); + return out; + } + /** * Rebuild the projection matrix from the frustum. Called by the * base `Camera2d` constructor and by `resize()`. Camera3d's diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index c74a6c32b..29ada8ed5 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -38,11 +38,13 @@ import Container from "./renderable/container.js"; import { Draggable } from "./renderable/draggable.js"; import { DropTarget } from "./renderable/dragndrop.js"; import Entity from "./renderable/entity/entity.js"; +import FrameAnimation from "./renderable/frameAnimation.js"; import ImageLayer from "./renderable/imagelayer.js"; import Mesh from "./renderable/mesh.js"; import NineSliceSprite from "./renderable/nineslicesprite.js"; import Renderable from "./renderable/renderable.js"; import Sprite from "./renderable/sprite.js"; +import Sprite3d from "./renderable/sprite3d.js"; import BitmapText from "./renderable/text/bitmaptext.js"; import BitmapTextData from "./renderable/text/bitmaptextdata.ts"; import Text from "./renderable/text/text.js"; @@ -174,6 +176,7 @@ export { Entity, // eslint-disable-line @typescript-eslint/no-deprecated FadeEffect, FlashEffect, + FrameAnimation, Frustum, GLShader, GLTFModel, @@ -206,6 +209,7 @@ export { ShakeEffect, ShineEffect, Sprite, + Sprite3d, Stage, save, state, diff --git a/packages/melonjs/src/renderable/frameAnimation.js b/packages/melonjs/src/renderable/frameAnimation.js new file mode 100644 index 000000000..e103da53f --- /dev/null +++ b/packages/melonjs/src/renderable/frameAnimation.js @@ -0,0 +1,403 @@ +import { vector2dPool } from "../math/vector2d.ts"; +import { parseAnimationOptions } from "./animation.ts"; + +/** + * additional import for TypeScript + * @import { TextureAtlas } from "../video/texture/atlas.js"; + * @import { Vector2d } from "../math/vector2d.js"; + */ + +/** + * The shared **frame-animation engine** behind {@link Sprite} (2D) and + * {@link Sprite3d} (3D billboards). It *owns* the animation state — definitions, + * the current frame, timing, looping and chaining — everything independent of + * how a frame is ultimately *drawn*, and drives its host renderable through a + * small contract: + * + * - it reads the host's resolved texture (`host.source`, `host.textureAtlas`, + * `host.atlasIndices`) to turn a frame index/name into a region; + * - it calls the `applyFrame(region)` callback passed to the constructor whenever + * the frame changes — the host applies it to its own geometry ({@link Sprite} + * swaps its source sub-texture, size and anchor; {@link Sprite3d} maps the + * region onto its quad's UVs + vertices) **and marks itself dirty there**; + * - it fires `host.onended()` at each cycle end. + * + * The engine owns no dirty flag: a frame change flows through `applyFrame`, where + * the host sets its own `isDirty`. Callers read dirtiness from the host (the + * host's `update()` returns `super.update()` → `isDirty`). + * + * Hosts expose the public-facing state (`anim`, `current`, `animationspeed`, + * `animationpause`, …) as thin accessors onto this engine, so 2D and 3D frame + * animation share one implementation with no behavioral fork. + * @category Animation + */ +export default class FrameAnimation { + /** + * @param {object} host - the renderable this engine drives. Read for the + * texture it animates (`source` / `textureAtlas` / `atlasIndices`) and the + * mutable cycle-end callback (`onended`). These are read lazily (the texture is + * usually resolved after the engine is constructed), which is why they live on + * the host rather than being passed in. The engine does not touch `isDirty` — + * the host marks itself dirty inside `applyFrame`. + * @param {(region: object) => void} applyFrame - called whenever the frame + * changes, with the selected texture region; the host applies it to its own + * geometry (a {@link Sprite} swaps its sub-texture / size / anchor, a + * {@link Sprite3d} remaps its quad's UVs + vertices). + */ + constructor(host, applyFrame) { + /** + * the host renderable this engine draws through. + * @type {object} + * @ignore + */ + this.host = host; + + /** + * geometry hook invoked on every frame change (see constructor). + * @type {(region: object) => void} + * @ignore + */ + this._applyFrame = applyFrame; + + /** defined animations, keyed by id @type {object} */ + this.anim = {}; + + /** animation to chain to / completion callback on cycle end @ignore */ + this.resetAnim = undefined; + + /** current frame info @type {object} */ + this.current = { + // the current animation name + name: undefined, + // length of the current animation + length: 0, + // current frame texture offset + offset: vector2dPool.get(0, 0), + // current frame size + width: 0, + height: 0, + // source rotation angle for pre-rotating the source image + angle: 0, + // current frame index + idx: 0, + // trim offset for trimmed sprites + trim: null, + }; + + /** elapsed time within the current frame, in ms @type {number} */ + this.dt = 0; + + /** default frame cycling speed (ms between frames) @type {number} */ + this.animationspeed = 100; + + /** pause flag — freezes the current frame @type {boolean} */ + this.animationpause = false; + + /** per-play speed multiplier (1 = authored speed) @ignore */ + this._animSpeed = 1; + + /** set once a `loop:false` animation has finished its single cycle @ignore */ + this._animDone = false; + } + + /** + * add an animation definition (see {@link Sprite#addAnimation}). + * @param {string} name - animation id + * @param {number[]|string[]|object[]} index - frame indices / names / objects + * @param {number} [animationspeed] - cycling speed in ms + * @returns {number} number of frames added (0 if no texture atlas) + */ + addAnimation(name, index, animationspeed) { + const host = this.host; + this.anim[name] = { + name: name, + frames: [], + idx: 0, + length: 0, + }; + + // # of frames + let counter = 0; + + if (typeof host.textureAtlas !== "object") { + return 0; + } + + if (index == null) { + index = []; + // create a default animation with all frame + Object.keys(host.textureAtlas).forEach((v, i) => { + index[i] = i; + }); + } + + // set each frame configuration (offset, size, etc..) + for (let i = 0, len = index.length; i < len; i++) { + const frame = index[i]; + let frameObject; + if (typeof frame === "number" || typeof frame === "string") { + frameObject = { + name: frame, + delay: animationspeed || this.animationspeed, + }; + } else { + frameObject = frame; + } + const frameObjectName = frameObject.name; + if (typeof frameObjectName === "number") { + if (typeof host.textureAtlas[frameObjectName] !== "undefined") { + // see https://github.com/melonjs/melonJS/issues/1281 + this.anim[name].frames[i] = Object.assign( + {}, + host.textureAtlas[frameObjectName], + frameObject, + ); + counter++; + } + } else { + // string + if (host.source.getFormat().includes("Spritesheet")) { + throw new Error( + "string parameters for addAnimation are not allowed for standard spritesheet based Texture", + ); + } else { + this.anim[name].frames[i] = Object.assign( + {}, + host.textureAtlas[host.atlasIndices[frameObjectName]], + frameObject, + ); + counter++; + } + } + } + this.anim[name].length = counter; + + return counter; + } + + /** + * select the active animation (see {@link Sprite#setCurrentAnimation}). + * @param {string} name - animation id + * @param {string|Function|object} [resetAnim] - loop / chain / completion behavior + * @param {boolean} [preserve_dt=false] - keep the elapsed-frame timer + * @returns {object} the host (for method chaining) + */ + setCurrentAnimation(name, resetAnim, preserve_dt = false) { + if (typeof this.anim[name] !== "undefined") { + if (!this.isCurrentAnimation(name)) { + this.current.name = name; + this.current.length = this.anim[this.current.name].length; + const opts = parseAnimationOptions(resetAnim); + this._animSpeed = opts.speed; + this._animDone = false; + const onComplete = opts.onComplete; + if (opts.legacyFn) { + // legacy bare-function callback: invoked at each loop end, + // return `false` to hold the last frame (contract unchanged) + this.resetAnim = onComplete; + } else if (typeof opts.next === "string") { + // chain to another animation when this one ends (the legacy + // string form and the options `next` field), firing + // `onComplete` first when provided + const next = opts.next; + this.resetAnim = () => { + if (typeof onComplete === "function") { + onComplete(); + } + this.setCurrentAnimation(next, null, true); + }; + } else if (opts.loop === false) { + // play once: fire onComplete, hold the last frame, and stop + // advancing (without touching `animationpause`) + this.resetAnim = () => { + if (typeof onComplete === "function") { + onComplete(); + } + this._animDone = true; + return false; + }; + } else if (typeof onComplete === "function") { + // loop forever, firing onComplete at each cycle + this.resetAnim = () => { + onComplete(); + }; + } else { + this.resetAnim = undefined; + } + // `setAnimationFrame(0)` applies frame 0 → `_applyFrame`, which is + // where the host marks itself dirty (the engine never touches it). + this.setAnimationFrame(0); + if (!preserve_dt) { + this.dt = 0; + } + } + } else { + throw new Error("animation id '" + name + "' not defined"); + } + return this.host; + } + + /** + * reverse the given (or current) animation in place (see {@link Sprite#reverseAnimation}). + * The host marks itself dirty (this path doesn't re-apply a frame, so it's the + * one place a host's `reverseAnimation` wrapper sets `isDirty`). + * @param {string} [name] - animation id + */ + reverseAnimation(name) { + if (typeof name !== "undefined" && typeof this.anim[name] !== "undefined") { + this.anim[name].frames.reverse(); + } else { + this.anim[this.current.name].frames.reverse(); + } + } + + /** + * @param {string} name - animation id + * @returns {boolean} true if `name` is the current animation + */ + isCurrentAnimation(name) { + return this.current.name === name; + } + + /** + * @returns {string[]} the names of every defined animation + */ + getAnimationNames() { + return Object.keys(this.anim); + } + + /** + * apply a texture region as the current frame: store its geometry into + * `current` and hand it to the host to draw (see {@link Sprite#setRegion}). + * @param {object} region - the texture region object + * @returns {object} the host (for method chaining) + */ + setRegion(region) { + const current = this.current; + // set the frame offset within the texture + current.offset.setV(region.offset); + // set angle if defined + current.angle = typeof region.angle === "number" ? region.angle : 0; + // update the current frame size (trimmed dimensions, used for drawing) + current.width = region.width; + current.height = region.height; + // cache trim offset for drawing + current.trim = region.trim || null; + // hand the region to the host to apply to its own geometry; the host + // marks itself dirty inside `_applyFrame` (the engine owns no dirty flag) + this._applyFrame(region); + return this.host; + } + + /** + * force the current animation frame index (see {@link Sprite#setAnimationFrame}). + * @param {number} [index=0] - animation frame index + * @returns {object} the host (for method chaining) + */ + setAnimationFrame(index = 0) { + this.current.idx = index % this.current.length; + return this.setRegion( + this.getAnimationFrameObjectByIndex(this.current.idx), + ); + } + + /** + * @returns {number} the current animation frame index + */ + getCurrentAnimationFrame() { + return this.current.idx; + } + + /** + * the frame object for the given index within the current animation. + * @param {number} id - the frame id + * @returns {object} the frame data + */ + getAnimationFrameObjectByIndex(id) { + return this.anim[this.current.name].frames[id]; + } + + /** + * clear the frame timer and the play-once "done" hold, without changing the + * current frame (the timer half of a stop, used by the video path which has + * no frame to rewind to). + */ + resetTimer() { + this._animDone = false; + this.dt = 0; + } + + /** + * reset the current animation to its first frame (the frame-animation half of + * {@link Sprite#stop} — the host still handles any video / pause concerns). + */ + rewind() { + this.resetTimer(); + if (this.current.name !== undefined && this.current.length > 0) { + this.setAnimationFrame(0); + } + } + + /** + * advance the frame animation by `dt` milliseconds, stepping frames, looping + * and chaining as configured (see {@link Sprite#update}). On a frame change the + * host marks itself dirty via `_applyFrame` (the engine owns no dirty flag); + * this returns whether a frame actually changed this tick — a finer-grained + * signal than the host's `isDirty` (which means "needs redraw for any reason"). + * @param {number} dt - elapsed time since the last update, in milliseconds + * @returns {boolean} true if a frame changed this tick + */ + update(dt) { + let changed = false; + if (!this.animationpause && !this._animDone && this.current.length > 1) { + let duration = this.getAnimationFrameObjectByIndex( + this.current.idx, + ).delay; + // `_animSpeed` (per-play multiplier) scales how fast the frame + // delay is consumed — 2 = twice as fast, 0.5 = half speed + this.dt += dt * this._animSpeed; + while (this.dt >= duration) { + changed = true; + this.dt -= duration; + + const nextFrame = + this.current.length > 1 ? this.current.idx + 1 : this.current.idx; + this.setAnimationFrame(nextFrame); + + // Switch animation if we reach the end of the strip and a callback is defined + if (this.current.idx === 0) { + if (typeof this.host.onended === "function") { + this.host.onended(); + } + if (typeof this.resetAnim === "function") { + // Otherwise is must be callable + if (this.resetAnim() === false) { + // Reset to last frame + this.setAnimationFrame(this.current.length - 1); + + // Bail early without skipping any more frames. + this.dt %= duration; + break; + } + } + } + // Get next frame duration + duration = this.getAnimationFrameObjectByIndex(this.current.idx).delay; + } + } + return changed; + } + + /** + * release engine-held resources — returns the pooled `current.offset` + * Vector2d to the pool. Call from the host's `destroy()`. + */ + destroy() { + if (this.current.offset !== null) { + vector2dPool.release(this.current.offset); + this.current.offset = null; + } + this.anim = {}; + this.resetAnim = undefined; + } +} diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index cae317ea5..e1b3160f2 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -27,7 +27,10 @@ const _combinedMatrix = new Matrix3d(); // Resolve any acceptable texture input (TextureAtlas, image / canvas // object, or asset name) to a cached `TextureAtlas`. Throws if nothing // resolves — Mesh requires a texture binding for its GL pipeline. -function resolveTextureAtlas(src) { +// `framewidth`/`frameheight` define the spritesheet cell size (defaulting +// to the whole image); a subclass like Sprite3d passes them so the atlas +// carries an animation frame grid. +function resolveTextureAtlas(src, framewidth, frameheight) { if (src instanceof TextureAtlas) { return src; } @@ -36,8 +39,8 @@ function resolveTextureAtlas(src) { throw new Error("Mesh: '" + src + "' image/texture not found!"); } return game.renderer.cache.get(image, { - framewidth: image.width, - frameheight: image.height, + framewidth: framewidth || image.width, + frameheight: frameheight || image.height, }); } @@ -490,7 +493,11 @@ export default class Mesh extends Renderable { if (!textureSource) { textureSource = Renderer.getWhitePixel(); } - this.texture = resolveTextureAtlas(textureSource); + this.texture = resolveTextureAtlas( + textureSource, + settings.framewidth, + settings.frameheight, + ); // Optional texture wrap mode. Some assets author UVs outside the // `[0, 1]` range and rely on the sampler repeating the texture (this is diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js index cffc72d79..3dad2f630 100644 --- a/packages/melonjs/src/renderable/sprite.js +++ b/packages/melonjs/src/renderable/sprite.js @@ -4,7 +4,7 @@ import { Color } from "../math/color.ts"; import { vector2dPool } from "../math/vector2d.ts"; import { on } from "../system/event.ts"; import { TextureAtlas } from "./../video/texture/atlas.js"; -import { parseAnimationOptions } from "./animation.ts"; +import FrameAnimation from "./frameAnimation.js"; import Renderable from "./renderable.js"; // flicker interval in ms (~15 flashes per second) @@ -68,18 +68,14 @@ export default class Sprite extends Renderable { // call the super constructor super(x, y, 0, 0); - /** - * @type {boolean} - * @default false - */ - this.animationpause = false; - - /** - * animation cycling speed (delay between frame in ms) - * @type {number} - * @default 100 - */ - this.animationspeed = 100; + // 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. + // Created up front, before the texture is resolved, so the setup code can + // use the accessors. + this._frameAnim = new FrameAnimation(this, (region) => { + this._applyFrame(region); + }); /** * global offset for the position to draw from on the source image. @@ -115,45 +111,6 @@ export default class Sprite extends Renderable { */ this._normalMap = null; - // hold all defined animation - this.anim = {}; - - // a flag to reset animation - this.resetAnim = undefined; - - // current frame information - // (reusing current, any better/cleaner place?) - this.current = { - // the current animation name - name: undefined, - // length of the current animation name - length: 0, - //current frame texture offset - offset: vector2dPool.get(0, 0), - // current frame size - width: 0, - height: 0, - // Source rotation angle for pre-rotating the source image - angle: 0, - // current frame index - idx: 0, - // trim offset for trimmed sprites - trim: null, - }; - - // animation frame delta - this.dt = 0; - - // playback rate multiplier set per-play via the options form of - // setCurrentAnimation (1 = authored speed). Scales how fast `dt` - // accumulates, on top of each frame's `delay`. - this._animSpeed = 1; - - // set true when a `loop: false` animation has completed its single - // cycle, so update() stops advancing without touching `animationpause` - // (cleared whenever a new animation is selected). - this._animDone = false; - /** * flicker settings * @ignore @@ -348,6 +305,57 @@ export default class Sprite extends Renderable { } } + /** + * defined animations, keyed by id (see {@link Sprite#addAnimation}). + * @type {object} + */ + get anim() { + return this._frameAnim.anim; + } + + /** + * current frame information (name / index / texture offset & size / trim). + * @type {object} + */ + get current() { + return this._frameAnim.current; + } + + /** + * elapsed time within the current animation frame, in milliseconds. + * @type {number} + */ + get dt() { + return this._frameAnim.dt; + } + set dt(value) { + this._frameAnim.dt = value; + } + + /** + * animation cycling speed (delay between frames in ms). + * @type {number} + * @default 100 + */ + get animationspeed() { + return this._frameAnim.animationspeed; + } + set animationspeed(value) { + this._frameAnim.animationspeed = value; + } + + /** + * pause the frame animation, freezing the current frame. + * @type {boolean} + * @default false + */ + get animationpause() { + return this._frameAnim.animationpause; + } + set animationpause(value) { + this._frameAnim.animationpause = value; + } + /** * The optional normal-map image paired with this sprite's color * texture (SpriteIlluminator workflow). When set, the WebGL @@ -439,13 +447,15 @@ export default class Sprite extends Renderable { */ stop() { this.animationpause = true; - this._animDone = false; - this.dt = 0; if (this.isVideo) { + // clear the frame-anim timer/hold flags too (parity with the legacy + // unconditional reset), then rewind the video + this._frameAnim.resetTimer(); this.image.pause(); this.image.currentTime = 0; - } else if (this.current.name !== undefined && this.current.length > 0) { - this.setAnimationFrame(0); + } else { + // rewind the frame animation to its first frame + this._frameAnim.rewind(); } return this; } @@ -507,70 +517,7 @@ export default class Sprite extends Renderable { * this.setCurrentAnimation("stand"); */ addAnimation(name, index, animationspeed) { - this.anim[name] = { - name: name, - frames: [], - idx: 0, - length: 0, - }; - - // # of frames - let counter = 0; - - if (typeof this.textureAtlas !== "object") { - return 0; - } - - if (index == null) { - index = []; - // create a default animation with all frame - Object.keys(this.textureAtlas).forEach((v, i) => { - index[i] = i; - }); - } - - // set each frame configuration (offset, size, etc..) - for (let i = 0, len = index.length; i < len; i++) { - const frame = index[i]; - let frameObject; - if (typeof frame === "number" || typeof frame === "string") { - frameObject = { - name: frame, - delay: animationspeed || this.animationspeed, - }; - } else { - frameObject = frame; - } - const frameObjectName = frameObject.name; - if (typeof frameObjectName === "number") { - if (typeof this.textureAtlas[frameObjectName] !== "undefined") { - // see https://github.com/melonjs/melonJS/issues/1281 - this.anim[name].frames[i] = Object.assign( - {}, - this.textureAtlas[frameObjectName], - frameObject, - ); - counter++; - } - } else { - // string - if (this.source.getFormat().includes("Spritesheet")) { - throw new Error( - "string parameters for addAnimation are not allowed for standard spritesheet based Texture", - ); - } else { - this.anim[name].frames[i] = Object.assign( - {}, - this.textureAtlas[this.atlasIndices[frameObjectName]], - frameObject, - ); - counter++; - } - } - } - this.anim[name].length = counter; - - return counter; + return this._frameAnim.addAnimation(name, index, animationspeed); } /** @@ -611,56 +558,7 @@ export default class Sprite extends Renderable { * }); */ setCurrentAnimation(name, resetAnim, preserve_dt = false) { - if (typeof this.anim[name] !== "undefined") { - if (!this.isCurrentAnimation(name)) { - this.current.name = name; - this.current.length = this.anim[this.current.name].length; - const opts = parseAnimationOptions(resetAnim); - this._animSpeed = opts.speed; - this._animDone = false; - const onComplete = opts.onComplete; - if (opts.legacyFn) { - // legacy bare-function callback: invoked at each loop end, - // return `false` to hold the last frame (contract unchanged) - this.resetAnim = onComplete; - } else if (typeof opts.next === "string") { - // chain to another animation when this one ends (the legacy - // string form and the options `next` field), firing - // `onComplete` first when provided - const next = opts.next; - this.resetAnim = () => { - if (typeof onComplete === "function") { - onComplete(); - } - this.setCurrentAnimation(next, null, true); - }; - } else if (opts.loop === false) { - // play once: fire onComplete, hold the last frame, and stop - // advancing (without touching `animationpause`) - this.resetAnim = () => { - if (typeof onComplete === "function") { - onComplete(); - } - this._animDone = true; - return false; - }; - } else if (typeof onComplete === "function") { - // loop forever, firing onComplete at each cycle - this.resetAnim = () => { - onComplete(); - }; - } else { - this.resetAnim = undefined; - } - this.setAnimationFrame(0); - if (!preserve_dt) { - this.dt = 0; - } - this.isDirty = true; - } - } else { - throw new Error("animation id '" + name + "' not defined"); - } + this._frameAnim.setCurrentAnimation(name, resetAnim, preserve_dt); return this; } @@ -671,11 +569,9 @@ export default class Sprite extends Renderable { * @see Sprite#animationspeed */ reverseAnimation(name) { - if (typeof name !== "undefined" && typeof this.anim[name] !== "undefined") { - this.anim[name].frames.reverse(); - } else { - this.anim[this.current.name].frames.reverse(); - } + this._frameAnim.reverseAnimation(name); + // reversing doesn't re-apply a frame, so mark dirty here (the host owns + // its dirty flag) this.isDirty = true; return this; } @@ -690,7 +586,7 @@ export default class Sprite extends Renderable { * } */ isCurrentAnimation(name) { - return this.current.name === name; + return this._frameAnim.isCurrentAnimation(name); } /** @@ -703,7 +599,7 @@ export default class Sprite extends Renderable { * sprite.getAnimationNames(); // ["walk", "idle"] */ getAnimationNames() { - return Object.keys(this.anim); + return this._frameAnim.getAnimationNames(); } /** @@ -716,17 +612,20 @@ export default class Sprite extends Renderable { * mySprite.setRegion(mytexture.getRegion("shadedDark13.png")); */ setRegion(region) { + this._frameAnim.setRegion(region); + return this; + } + + /** + * Apply the current frame's texture region to this sprite's geometry: swap + * the source sub-texture, then resolve size / anchor (honoring trimming). + * Invoked by the shared {@link FrameAnimation} engine via `setRegion`. + * @param {object} region - the texture region object + * @ignore + */ + _applyFrame(region) { // set the source texture for the given region this.image = this.source.getTexture(region); - // set the sprite offset within the texture - this.current.offset.setV(region.offset); - // set angle if defined - this.current.angle = typeof region.angle === "number" ? region.angle : 0; - // update the current frame size (trimmed dimensions, used for drawing) - this.current.width = region.width; - this.current.height = region.height; - // cache trim offset for drawing - this.current.trim = region.trim || null; if (region.trimmed && region.sourceSize) { // use the original untrimmed size for stable bounds across trimmed frames @@ -760,8 +659,9 @@ export default class Sprite extends Renderable { // update the sprite bounding box this.updateBounds(); + // the frame changed → this sprite needs a redraw (the host owns its + // dirty flag; the FrameAnimation engine never sets it) this.isDirty = true; - return this; } /** @@ -773,10 +673,8 @@ export default class Sprite extends Renderable { * this.setAnimationFrame(); */ setAnimationFrame(index = 0) { - this.current.idx = index % this.current.length; - return this.setRegion( - this.getAnimationFrameObjectByIndex(this.current.idx), - ); + this._frameAnim.setAnimationFrame(index); + return this; } /** @@ -784,7 +682,7 @@ export default class Sprite extends Renderable { * @returns {number} current animation frame index */ getCurrentAnimationFrame() { - return this.current.idx; + return this._frameAnim.getCurrentAnimationFrame(); } /** @@ -794,7 +692,7 @@ export default class Sprite extends Renderable { * @returns {number} if using number indices. Returns {object} containing frame data if using texture atlas */ getAnimationFrameObjectByIndex(id) { - return this.anim[this.current.name].frames[id]; + return this._frameAnim.getAnimationFrameObjectByIndex(id); } /** @@ -814,45 +712,9 @@ export default class Sprite extends Renderable { } this.isDirty = !this.image.paused; } else { - // Update animation if necessary - if (!this.animationpause && !this._animDone && this.current.length > 1) { - let duration = this.getAnimationFrameObjectByIndex( - this.current.idx, - ).delay; - // `_animSpeed` (per-play multiplier) scales how fast the frame - // delay is consumed — 2 = twice as fast, 0.5 = half speed - this.dt += dt * this._animSpeed; - while (this.dt >= duration) { - this.isDirty = true; - this.dt -= duration; - - const nextFrame = - this.current.length > 1 ? this.current.idx + 1 : this.current.idx; - this.setAnimationFrame(nextFrame); - - // Switch animation if we reach the end of the strip and a callback is defined - if (this.current.idx === 0) { - if (typeof this.onended === "function") { - this.onended(); - } - if (typeof this.resetAnim === "function") { - // Otherwise is must be callable - if (this.resetAnim() === false) { - // Reset to last frame - this.setAnimationFrame(this.current.length - 1); - - // Bail early without skipping any more frames. - this.dt %= duration; - break; - } - } - } - // Get next frame duration - duration = this.getAnimationFrameObjectByIndex( - this.current.idx, - ).delay; - } - } + // advance the shared frame-animation engine; a frame change marks this + // sprite dirty via `_applyFrame` (the engine owns no dirty flag) + this._frameAnim.update(dt); } //update the "flickering" state if necessary @@ -867,6 +729,8 @@ export default class Sprite extends Renderable { this.isDirty = true; } + // `isDirty` is the single source of truth — sub-updates above set it, + // `Renderable.update` returns it return super.update(dt); } @@ -964,6 +828,8 @@ export default class Sprite extends Renderable { * @ignore */ destroy() { + // release the engine's pooled `current.offset` + this._frameAnim.destroy(); vector2dPool.release(this.offset); this.offset = undefined; if (this.isVideo) { diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js new file mode 100644 index 000000000..2387b68e1 --- /dev/null +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -0,0 +1,738 @@ +import Camera3d from "../camera/camera3d.ts"; +import { getImage } from "../loader/loader.js"; +import { Vector3d } from "../math/vector3d.ts"; +import { TextureAtlas } from "../video/texture/atlas.js"; +import FrameAnimation from "./frameAnimation.js"; +import Mesh from "./mesh.js"; + +// reusable basis vectors for the billboard projection (one draw runs at a time) +const _right = new Vector3d(); +const _up = new Vector3d(); +const _fwd = new Vector3d(); +// render space is Y-down, so the visual "up" axis is -Y. Used to keep a +// cylindrical billboard upright. MUST be treated as read-only — it is a shared +// module singleton, only ever copied/crossed-from, never mutated in place. +const WORLD_UP = new Vector3d(0, -1, 0); + +// 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`. +function imageSize(settings) { + const src = settings.image ?? settings.texture; + if (src instanceof TextureAtlas) { + const texture = src.getTexture(); + return { w: texture.width, h: texture.height }; + } + const image = typeof src === "object" ? src : getImage(src); + if (!image) { + throw new Error("Sprite3d: '" + src + "' image/texture not found!"); + } + return { w: image.width, h: image.height }; +} + +/** + * A textured **quad in 3D space** — the 3D counterpart of {@link Sprite}, + * for rendering sprites under a {@link Camera3d} (the 2.5D workflow: characters, + * pickups, foliage, signs, particles in a 3D scene). It's a thin {@link Mesh} + * subclass, so it rides the same world-space mesh pipeline (depth testing, + * frustum culling) and supports the same material features (`lit`, `emissive`, + * `alphaCutoff`). + * + * Its headline feature is **billboarding** — keeping the quad facing the camera + * regardless of camera orientation (see {@link Sprite3d#billboard}). With + * billboarding off it's a fixed-orientation quad (decals, posters, ground + * markers). + * + * **Frame animation** is supported through the same API as {@link Sprite} + * ({@link Sprite3d#addAnimation}, {@link Sprite3d#setCurrentAnimation}, + * {@link Sprite3d#play}/`pause`/`stop`) — pass `framewidth`/`frameheight` for a + * spritesheet, or a packed {@link TextureAtlas}, exactly as you would for a 2D + * `Sprite`. Both share the {@link FrameAnimation} engine, so the timing, looping + * and chaining behavior is identical; `Sprite3d` maps the current frame onto the + * quad each step — including packer **rotated** and **trimmed** regions, mapped + * to full parity with the 2D `Sprite`. + * + * **Camera3d only.** Like {@link Mesh}, `Sprite3d` renders through the 3D + * 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. + * @augments Mesh + * @category Game Objects + * @example + * import { Application, Camera3d, Sprite3d } from "melonjs"; + * + * // a 3D app (Camera3d is required for billboarding) + * const app = new Application(1024, 768, { cameraClass: Camera3d }); + * + * // a tree that always faces the camera but stays upright (2.5D). + * // its texture has a transparent background — the mesh pass is opaque, so + * // `alphaCutoff` (default 0.5) discards those texels for a clean silhouette. + * const tree = new Sprite3d(0, 0, { + * image: "tree", // a preloaded image with transparency + * width: 64, height: 96, + * z: -200, // 3D depth (world z) + * billboard: true, // = "cylindrical" + * // alphaCutoff: 0.5, // the default — lower it to keep softer edges, + * // or set 0 for a fully-opaque quad + * }); + * app.world.addChild(tree); // add it to the game world, like any Renderable + * + * // an animated, fully camera-facing pickup from a spritesheet, mirrored + * const coin = new Sprite3d(0, 0, { + * image: "coins", + * framewidth: 32, frameheight: 32, + * width: 48, height: 48, + * billboard: "spherical", + * alphaCutoff: 0.5, // cut out the transparent frame background + * }); + * coin.addAnimation("spin", [0, 1, 2, 3, 4, 5]); + * coin.setCurrentAnimation("spin"); + * coin.flipX(); // face the other way (mirrors the sprite) + * app.world.addChild(coin); + */ +export default class Sprite3d extends Mesh { + /** + * @param {number} x - world x position + * @param {number} y - world y position + * @param {object} settings - configuration + * @param {HTMLImageElement|TextureAtlas|string} [settings.image] - the sprite texture (image name, image, or atlas). Alias: `settings.texture`. + * @param {number} [settings.width=settings.framewidth] - quad width in world units (pixels) + * @param {number} [settings.height=settings.width] - quad height in world units + * @param {number} [settings.framewidth] - width of a single frame within a spritesheet (enables frame animation) + * @param {number} [settings.frameheight] - height of a single frame within a spritesheet + * @param {string} [settings.region] - region name when using a texture atlas (see {@link TextureAtlas}) + * @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 {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}) + * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link Mesh}) + * @param {number} [settings.alphaCutoff=0.5] - alpha cutout threshold (see {@link Mesh}). The mesh pass is opaque (no alpha blending), so this defaults to `0.5` to discard a sprite's transparent background (clean cutout silhouette, correct depth, no sorting). Set `0` for a fully-opaque quad, or tune the threshold. + */ + constructor(x, y, settings) { + // world-space quad size (in pixels): explicit width/height first, then the + // spritesheet frame size, then the full texture size. The texture atlas + // itself is resolved by the Mesh base class (passing framewidth/frameheight + // through, below) — so Sprite3d never touches the renderer directly. + let w; + let h; + if (typeof settings.width === "number") { + w = settings.width; + h = typeof settings.height === "number" ? settings.height : w; + } else if (typeof settings.framewidth === "number") { + w = settings.framewidth; + h = typeof settings.frameheight === "number" ? settings.frameheight : w; + } else { + const size = imageSize(settings); + w = size.w; + h = size.h; + } + const hw = w / 2; + const hh = h / 2; + // 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, + hw, + hh, + 0, + -hw, + hh, + 0, + ]); + // 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. + const uvs = new Float32Array([0, 1, 1, 1, 1, 0, 0, 0]); + const indices = new Uint16Array([0, 1, 2, 0, 2, 3]); + const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]); + + super(x, y, { + vertices, + uvs, + indices, + normals, + // let Mesh resolve the texture; forward the spritesheet grid so the + // resolved atlas carries animation frames + 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), + scale: 1, + normalize: false, + rightHanded: true, + // a sprite quad should be visible from both sides (a billboard can end + // up wound either way, and a fixed quad is usually viewed front-on) + cullBackFaces: false, + lit: settings.lit === true, + emissive: settings.emissive, + // the mesh pass is opaque (no alpha blending), so a sprite's + // transparent background would otherwise render as a solid box. Use an + // alpha cutout by default — fragments below the threshold are + // discarded, giving a clean transparent silhouette with correct depth + // testing and no back-to-front sorting. Pass `alphaCutoff: 0` to + // disable (fully opaque), or tune the threshold. + alphaCutoff: + typeof settings.alphaCutoff === "number" ? settings.alphaCutoff : 0.5, + }); + + /** + * the quad's half-width in world units (see {@link Sprite3d#billboard}). + * @type {number} + * @ignore + */ + this._halfW = hw; + /** @ignore */ + this._halfH = hh; + // 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) + /** @ignore */ + this._refLw = 0; + /** @ignore */ + this._refLh = 0; + // horizontal / vertical mirror flags. Applied by mirroring the local quad + // in `_applyFrame` (uniform for the billboard + fixed paths, and agnostic + // to atlas rotation/trim) rather than via the inherited transform flip, + // which the billboard projection doesn't use. + /** @ignore */ + this._flipX = false; + /** @ignore */ + this._flipY = false; + // the last region handed to `_applyFrame`, so a flip can re-map immediately + /** @ignore */ + this._region = null; + + // ── frame-animation: texture refs the engine reads + the engine itself ── + // (the animation state — anim / current / dt / … — lives on the engine and + // is surfaced through the accessors below, matching Sprite) + /** + * a callback fired when the current animation completes a cycle. + * @type {Function} + */ + this.onended = undefined; + /** the frame-aware atlas (== this.texture) @ignore */ + this.source = this.texture; + // region dictionary + name→index map. `settings.atlas` / `atlasIndices` + // (as produced by `TextureAtlas.getAnimationSettings` / + // `createAnimationFromName`) take precedence so atlas-by-name animation + // works exactly as it does for a 2D Sprite; otherwise fall back to the + // resolved atlas's own dictionary. + /** the atlas region dictionary @ignore */ + this.textureAtlas = settings.atlas ?? this.texture.getAtlas(); + /** name→index map for named-region atlases @ignore */ + this.atlasIndices = settings.atlasIndices; + /** the shared frame-animation engine @ignore */ + this._frameAnim = new FrameAnimation(this, (region) => { + this._applyFrame(region); + }); + + // 3D depth (world z) + this.depth = typeof settings.z === "number" ? settings.z : 0; + + /** + * Billboard mode — keeps the quad facing the active {@link Camera3d}: + * - `false` (default) — fixed orientation (a flat quad in the XY plane; + * decals, posters, ground markers). + * - `true` / `"cylindrical"` — faces the camera but stays upright + * (rotates only around the world up axis). The 2.5D default — trees, + * characters, items. + * - `"spherical"` — faces the camera on all axes (particles, glints). + * + * **Only applies under a `Camera3d`**; ignored on the 2D path. + * + * Note: while billboarding, orientation comes from the camera, so the + * renderable's `currentTransform` (`rotate()` / `scale()` / parent-container + * transforms) and `meshScale` are **not** applied — only `pos` / `depth`, + * `flipX` / `flipY`, and the quad's authored size. With billboarding `false` + * the standard {@link Mesh} world transform applies as usual. + * @type {boolean|string} + * @default false + */ + this.billboard = settings.billboard ?? false; + + /** the camera captured at draw time for the billboard projection @ignore */ + this._billboardCam = null; + + // select an initial frame: an explicit atlas region, then any predefined + // animations, then a catch-all "default" covering every frame. + if (typeof settings.region !== "undefined") { + const region = this.source.getRegion(settings.region); + if (!region) { + throw new Error( + "Sprite3d: region for " + settings.region + " not found", + ); + } + this.setRegion(region); + } + if (typeof settings.anims !== "undefined") { + for (const id in settings.anims) { + this.addAnimation( + settings.anims[id].name, + settings.anims[id].index, + settings.anims[id].speed, + ); + } + } + // addAnimation returns 0 when there is no usable frame grid (e.g. a named + // atlas, where the region above already selected the frame) + if ( + this.current.name === undefined && + this.addAnimation("default", null) !== 0 + ) { + this.setCurrentAnimation("default"); + } + + // optional initial flip (after the first frame is applied, so the re-map + // in flipX/flipY has a region to work with) + if (settings.flipX) { + this.flipX(true); + } + if (settings.flipY) { + this.flipY(true); + } + } + + /** + * defined animations, keyed by id (see {@link Sprite3d#addAnimation}). + * @type {object} + */ + get anim() { + return this._frameAnim.anim; + } + + /** + * current frame information (name / index / texture offset & size / trim). + * @type {object} + */ + get current() { + return this._frameAnim.current; + } + + /** + * elapsed time within the current animation frame, in milliseconds. + * @type {number} + */ + get dt() { + return this._frameAnim.dt; + } + set dt(value) { + this._frameAnim.dt = value; + } + + /** + * animation cycling speed (delay between frames in ms). + * @type {number} + * @default 100 + */ + get animationspeed() { + return this._frameAnim.animationspeed; + } + set animationspeed(value) { + this._frameAnim.animationspeed = value; + } + + /** + * pause the frame animation, freezing the current frame. + * @type {boolean} + * @default false + */ + get animationpause() { + return this._frameAnim.animationpause; + } + set animationpause(value) { + this._frameAnim.animationpause = value; + } + + /** + * Add an animation, identical to {@link Sprite#addAnimation}. + * @param {string} name - animation id + * @param {number[]|string[]|object[]} index - frame indices / names (see {@link Sprite#addAnimation}) + * @param {number} [animationspeed] - cycling speed in ms + * @returns {number} number of frames added + */ + addAnimation(name, index, animationspeed) { + return this._frameAnim.addAnimation(name, index, animationspeed); + } + + /** + * Select the active animation, identical to {@link Sprite#setCurrentAnimation}. + * @param {string} name - animation id + * @param {string|Function|object} [resetAnim] - loop / chain / completion behavior + * @param {boolean} [preserve_dt=false] + * @returns {Sprite3d} Reference to this object for method chaining + */ + setCurrentAnimation(name, resetAnim, preserve_dt = false) { + this._frameAnim.setCurrentAnimation(name, resetAnim, preserve_dt); + return this; + } + + /** + * Reverse the given (or current) animation in place (see {@link Sprite#reverseAnimation}). + * @param {string} [name] - animation id + * @returns {Sprite3d} Reference to this object for method chaining + */ + reverseAnimation(name) { + this._frameAnim.reverseAnimation(name); + // reversing doesn't re-apply a frame, so mark dirty here (the host owns + // its dirty flag) + this.isDirty = true; + return this; + } + + /** + * Mirror the sprite horizontally (e.g. flip a character to face the other + * way). Unlike the 2D {@link Sprite}, the flip mirrors the quad's local + * geometry so it works for every billboard mode and for rotated/trimmed atlas + * regions alike. Takes effect immediately on the current frame. + * @param {boolean} [flip=true] + * @returns {Sprite3d} Reference to this object for method chaining + */ + flipX(flip = true) { + this._flipX = !!flip; + if (this._region !== null) { + // re-map the current frame with the new flip (also sets isDirty) + this._applyFrame(this._region); + } + return this; + } + + /** + * Mirror the sprite vertically. See {@link Sprite3d#flipX}. + * @param {boolean} [flip=true] + * @returns {Sprite3d} Reference to this object for method chaining + */ + flipY(flip = true) { + this._flipY = !!flip; + if (this._region !== null) { + this._applyFrame(this._region); + } + return this; + } + + /** + * @returns {boolean} true if the sprite is mirrored horizontally. + */ + isFlippedX() { + return this._flipX === true; + } + + /** + * @returns {boolean} true if the sprite is mirrored vertically. + */ + isFlippedY() { + return this._flipY === true; + } + + /** + * Play (and optionally switch to) an animation, identical to {@link Sprite#play}. + * @param {string} [name] - animation id to play; omit to resume + * @param {string|Function|object} [options] - loop / chain / completion behavior + * @returns {Sprite3d} Reference to this object for method chaining + */ + play(name, options) { + this.animationpause = false; + if (name !== undefined) { + this.setCurrentAnimation(name, options); + } + return this; + } + + /** + * Pause the current animation, freezing the current frame (see {@link Sprite#pause}). + * @returns {Sprite3d} Reference to this object for method chaining + */ + pause() { + this.animationpause = true; + return this; + } + + /** + * Stop and reset the current animation to its first frame (see {@link Sprite#stop}). + * @returns {Sprite3d} Reference to this object for method chaining + */ + stop() { + this.animationpause = true; + this._frameAnim.rewind(); + return this; + } + + /** + * Force the current animation frame index (see {@link Sprite#setAnimationFrame}). + * @param {number} [index=0] - animation frame index + * @returns {Sprite3d} Reference to this object for method chaining + */ + setAnimationFrame(index = 0) { + this._frameAnim.setAnimationFrame(index); + return this; + } + + /** + * Apply a texture region directly (see {@link Sprite#setRegion}). + * @param {object} region - typically from `texture.getRegion(name)` + * @returns {Sprite3d} Reference to this object for method chaining + */ + setRegion(region) { + this._frameAnim.setRegion(region); + return this; + } + + /** + * @param {string} name - animation id + * @returns {boolean} true if `name` is the current animation (see {@link Sprite#isCurrentAnimation}). + */ + isCurrentAnimation(name) { + return this._frameAnim.isCurrentAnimation(name); + } + + /** + * @returns {string[]} the names of every defined animation (see {@link Sprite#getAnimationNames}). + */ + getAnimationNames() { + return this._frameAnim.getAnimationNames(); + } + + /** + * @returns {number} the current animation frame index (see {@link Sprite#getCurrentAnimationFrame}). + */ + getCurrentAnimationFrame() { + return this._frameAnim.getCurrentAnimationFrame(); + } + + /** + * The frame object for the given index within the current animation. + * @param {number} id - the frame id + * @returns {object} the frame data + * @ignore + */ + getAnimationFrameObjectByIndex(id) { + return this._frameAnim.getAnimationFrameObjectByIndex(id); + } + + /** + * Apply the current frame's texture region to the quad — the geometry hook + * called by the shared {@link FrameAnimation} engine (the 3D counterpart of + * {@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`. + * @param {object} region - the texture region object + * @ignore + */ + _applyFrame(region) { + // remember the region so a later flipX/flipY can re-map immediately + this._region = region; + // the texture backing this frame (full sheet for a spritesheet, the atlas + // page for a packed atlas) + const image = this.source.getTexture(region); + const iw = image.width; + const ih = image.height; + + // `region.width`/`height` are the art's UNROTATED dimensions; a + // packer-rotated region occupies a height×width box in the atlas. + const rotated = region.angle !== 0; + const aw = region.width; + const ah = region.height; + const atlasW = rotated ? ah : aw; + const atlasH = rotated ? aw : ah; + + // ── UVs: the atlas AABB, corners permuted 90° when the region is rotated + const ox = region.offset.x; + const oy = region.offset.y; + const uL = ox / iw; + const uR = (ox + atlasW) / iw; + // texture-space top is the smaller pixel y; the baked UVs put v=0 at the + // top corners (local +y) so the frame lands upright under Y-down. + const vT = oy / ih; + const vB = (oy + atlasH) / ih; + const uv = this.uvs; + // corners: 0=(left,bottom) 1=(right,bottom) 2=(right,top) 3=(left,top) + if (rotated) { + // TexturePacker stores rotated art 90° clockwise, so undo it by + // rotating the corner→UV assignment: the quad's left edge samples the + // atlas top row, its top edge samples the atlas right column. + uv[0] = uL; + uv[1] = vT; // BL → atlas top-left + uv[2] = uL; + uv[3] = vB; // BR → atlas bottom-left + uv[4] = uR; + uv[5] = vB; // TR → atlas bottom-right + uv[6] = uR; + uv[7] = vT; // TL → atlas top-right + } else { + uv[0] = uL; + uv[1] = vB; + uv[2] = uR; + uv[3] = vB; + uv[4] = uR; + uv[5] = vT; + uv[6] = uL; + uv[7] = vT; + } + + // ── geometry: place the (possibly trimmed) art rect inside the logical + // frame. Logical size is the untrimmed sourceSize; the art occupies a + // sub-rect offset by `trim`. The world quad (±_halfW/±_halfH) maps the + // logical frame captured from the first applied frame, so trimmed frames + // keep their position/size relative to the full sprite footprint. + const trimmed = region.trimmed === true && region.sourceSize != null; + const logicalW = trimmed ? region.sourceSize.w : aw; + const logicalH = trimmed ? region.sourceSize.h : ah; + const tx = region.trim ? region.trim.x : 0; + const ty = region.trim ? region.trim.y : 0; + if (this._refLw === 0) { + this._refLw = logicalW; + this._refLh = logicalH; + } + // world units per logical pixel + const sx = (2 * this._halfW) / this._refLw; + const sy = (2 * this._halfH) / this._refLh; + // art rect in centered local space (texture top = +y, render Y-down) + let xl = (tx - logicalW / 2) * sx; + let xr = (tx + aw - logicalW / 2) * sx; + let yt = (logicalH / 2 - ty) * sy; + let yb = (logicalH / 2 - (ty + ah)) * sy; + // mirror the local quad around its origin for flips — uniform across the + // billboard + fixed paths and independent of atlas rotation/trim (each + // corner's UV is unchanged, so the texture mirrors) + if (this._flipX) { + xl = -xl; + xr = -xr; + } + if (this._flipY) { + yt = -yt; + yb = -yb; + } + const v = this.originalVertices; + // c0 BL, c1 BR, c2 TR, c3 TL (z stays 0) + v[0] = xl; + v[1] = yb; + v[3] = xr; + v[4] = yb; + v[6] = xr; + v[7] = yt; + v[9] = xl; + v[10] = yt; + // the frame changed → this sprite needs a redraw (the host owns its dirty + // flag; the FrameAnimation engine never sets it) + this.isDirty = true; + } + + /** + * Advance the frame animation, then defer to the standard mesh update. + * @param {number} dt - time since the last update in milliseconds + * @returns {boolean} true if the sprite changed and needs a redraw + * @ignore + */ + update(dt) { + // advance the engine first (a frame change marks this sprite dirty via + // `_applyFrame`), then let `super.update` report `isDirty` — same order and + // single-source-of-truth as Sprite + this._frameAnim.update(dt); + return super.update(dt); + } + + /** + * Release resources (returns the engine's pooled `current.offset` to the + * pool), then defer to the standard mesh/renderable teardown. + * @ignore + */ + destroy() { + this._frameAnim.destroy(); + this._region = null; + super.destroy(); + } + + /** + * Capture the rendering camera so {@link Sprite3d#_projectVerticesWorld} can + * orient the billboard toward it. Read from the per-draw `viewport` (passed + * by `Container.draw`), so a multi-camera stage billboards correctly against + * whichever camera is drawing. When no `Camera3d` viewport is supplied the + * billboard is inactive and the quad renders fixed-orientation. + * @param {CanvasRenderer|WebGLRenderer} renderer + * @param {Camera2d} [viewport] + * @ignore + */ + draw(renderer, viewport) { + this._billboardCam = viewport instanceof Camera3d ? viewport : null; + super.draw(renderer, viewport); + } + + /** + * Orient the quad toward the camera when billboarding; otherwise defer to the + * standard {@link Mesh} world projection (a fixed-orientation quad). + * @ignore + */ + _projectVerticesWorld(offsetX, offsetY, offsetZ) { + const mode = this.billboard; + const cam = this._billboardCam; + if (mode === false || mode === "none" || cam === null) { + super._projectVerticesWorld(offsetX, offsetY, offsetZ); + return; + } + + // camera basis (world space): right / up / forward. Note `getBasis` + // returns a math basis (+Y up) while render space is Y-down — each mode + // below resolves that sign so the card lands right-way-up. + cam.getBasis(_right, _up, _fwd); + + if (mode === "cylindrical" || mode === true) { + // stay upright: up = world up (-Y in render space), right = horizontal + // axis = forward × up (this order makes the front face the camera). + // Falls back to the camera right when looking straight up/down + // (forward ∥ world up → degenerate cross product). + _up.copy(WORLD_UP); + _right.copy(_fwd).cross(WORLD_UP); + if (_right.length() < 1e-4) { + cam.getRight(_right); + } else { + _right.normalize(); + } + } else { + // spherical: face the camera on all axes. `getBasis` returns a math + // (+Y up) basis, but render space is Y-down — so the camera's "up" + // actually points screen-DOWN. Negate it to get the screen-up axis + // (matching WORLD_UP at head-on), then right = forward × up to match + // the cylindrical handedness so the front face — not the mirrored + // back — points at the camera. + _up.negateSelf(); + _right.copy(_fwd).cross(_up); + if (_right.length() < 1e-4) { + cam.getRight(_right); + } else { + _right.normalize(); + } + } + + // emit the 4 corners as center ± right·localX ± up·localY (the local + // offsets are the baked ±hw / ±hh quad coordinates) + const out = this.vertices; + const src = this.originalVertices; + const rx = _right.x; + const ry = _right.y; + const rz = _right.z; + const ux = _up.x; + const uy = _up.y; + const uz = _up.z; + for (let i = 0; i < 4; i++) { + const i3 = i * 3; + const lx = src[i3]; + const ly = src[i3 + 1]; + out[i3] = offsetX + rx * lx + ux * ly; + out[i3 + 1] = offsetY + ry * lx + uy * ly; + out[i3 + 2] = offsetZ + rz * lx + uz * ly; + } + } +} diff --git a/packages/melonjs/tests/frameAnimation.spec.js b/packages/melonjs/tests/frameAnimation.spec.js new file mode 100644 index 000000000..0ae480f57 --- /dev/null +++ b/packages/melonjs/tests/frameAnimation.spec.js @@ -0,0 +1,528 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { boot, FrameAnimation, Sprite, Sprite3d, video } from "../src/index.js"; + +/** + * FrameAnimation — the shared frame-animation engine behind Sprite (2D) and + * Sprite3d (3D). The exact same battery is run against BOTH hosts, so every + * accessor (`anim` / `current` / `dt` / `animationspeed` / `animationpause`) + * and every proxied method (`addAnimation` / `setCurrentAnimation` / `play` / + * `pause` / `stop` / `reverseAnimation` / `setAnimationFrame` / …) is verified + * to behave identically whether it's surfaced through a Sprite or a Sprite3d. + */ + +// a 128×32 sheet → four 32×32 frames (indices 0..3), each a solid block +const makeSheet = () => { + const c = document.createElement("canvas"); + c.width = 128; + c.height = 32; + c.getContext("2d").fillRect(0, 0, 128, 32); + return c; +}; + +// host factories — same spritesheet, same frame grid, exposed through each class +const HOSTS = [ + { + name: "Sprite", + make: () => { + return new Sprite(0, 0, { + image: makeSheet(), + framewidth: 32, + frameheight: 32, + }); + }, + }, + { + name: "Sprite3d", + make: () => { + return new Sprite3d(0, 0, { + image: makeSheet(), + framewidth: 32, + frameheight: 32, + width: 32, + height: 32, + }); + }, + }, +]; + +for (const HOST of HOSTS) { + describe(`FrameAnimation via ${HOST.name}`, () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + // a fresh host with two animations defined (4-frame "walk", 2-frame "ab") + const make = () => { + const s = HOST.make(); + s.addAnimation("walk", [0, 1, 2, 3], 100); + s.addAnimation("ab", [0, 1], 100); + return s; + }; + + // ── addAnimation + animationspeed defaulting ───────────────────────── + + it("addAnimation returns the frame count and records the frames", () => { + const s = HOST.make(); + expect(s.addAnimation("a", [0, 1, 2])).toBe(3); + expect(s.anim["a"].length).toBe(3); + expect(s.anim["a"].frames[2].name).toBe(2); + }); + + it("addAnimation defaults each frame delay to animationspeed (100)", () => { + const s = HOST.make(); + s.addAnimation("def", [0, 1]); + expect(s.anim["def"].frames[0].delay).toBe(100); + expect(s.anim["def"].frames[1].delay).toBe(100); + }); + + it("addAnimation honors a host-level animationspeed override", () => { + const s = HOST.make(); + s.animationspeed = 250; // accessor → engine + s.addAnimation("slow", [0, 1]); + expect(s.anim["slow"].frames[0].delay).toBe(250); + // an explicit per-call speed still wins + s.addAnimation("fast", [0, 1], 30); + expect(s.anim["fast"].frames[0].delay).toBe(30); + }); + + it("addAnimation accepts per-frame delay objects", () => { + const s = HOST.make(); + s.addAnimation("mix", [ + { name: 0, delay: 200 }, + { name: 1, delay: 50 }, + ]); + expect(s.anim["mix"].frames[0].delay).toBe(200); + expect(s.anim["mix"].frames[1].delay).toBe(50); + }); + + // ── selection + query accessors ────────────────────────────────────── + + it("setCurrentAnimation sets name/length and isCurrentAnimation reports it", () => { + const s = make(); + s.setCurrentAnimation("walk"); + expect(s.current.name).toBe("walk"); + expect(s.current.length).toBe(4); + expect(s.isCurrentAnimation("walk")).toBe(true); + expect(s.isCurrentAnimation("ab")).toBe(false); + }); + + it("getAnimationNames lists every defined animation", () => { + const s = make(); + expect(s.getAnimationNames()).toEqual( + expect.arrayContaining(["walk", "ab"]), + ); + }); + + it("setCurrentAnimation throws on an unknown id", () => { + const s = make(); + expect(() => { + return s.setCurrentAnimation("nope"); + }).toThrow(); + }); + + it("setAnimationFrame / getCurrentAnimationFrame jump to a frame", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.setAnimationFrame(2); + expect(s.getCurrentAnimationFrame()).toBe(2); + // `current` reflects the frame's texture offset/size (frame 2 at x=64) + expect(s.current.idx).toBe(2); + expect(s.current.offset.x).toBe(64); + expect(s.current.width).toBe(32); + }); + + it("getAnimationFrameObjectByIndex returns the frame data", () => { + const s = make(); + s.setCurrentAnimation("walk"); + expect(s.getAnimationFrameObjectByIndex(3).name).toBe(3); + }); + + // ── update timing (the core stepping) ──────────────────────────────── + + it("update advances one frame per animationspeed-worth of time", () => { + const s = make(); + s.setCurrentAnimation("walk"); + expect(s.getCurrentAnimationFrame()).toBe(0); + s.update(100); + expect(s.getCurrentAnimationFrame()).toBe(1); + s.update(100); + expect(s.getCurrentAnimationFrame()).toBe(2); + s.update(200); // skip two frames at once + expect(s.getCurrentAnimationFrame()).toBe(0); // 4-frame loop wraps + }); + + // ── dirty flag: the host owns it, the engine never sets it directly ── + + it("host.update() returns isDirty — true only when a frame changed", () => { + const s = make(); + s.setCurrentAnimation("walk"); // 100ms frames + // selecting the animation dirtied the host; clear it so the return + // reflects THIS update's frame change, not prior state + s.isDirty = false; + expect(s.update(50)).toBeFalsy(); // < 100ms → no advance, still clean + s.isDirty = false; + expect(s.update(60)).toBeTruthy(); // 50 + 60 ≥ 100 → advance → dirty + }); + + it("host.update() returns false while paused", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.pause(); + s.isDirty = false; + expect(s.update(1000)).toBeFalsy(); + }); + + it("the engine update() returns whether a frame changed, and marks dirty only via _applyFrame", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.isDirty = false; + // no frame change → returns false, host left clean (engine sets nothing) + expect(s._frameAnim.update(50)).toBe(false); + expect(s.isDirty).toBe(false); + // frame change → returns true, host marked dirty inside _applyFrame + expect(s._frameAnim.update(60)).toBe(true); + expect(s.isDirty).toBe(true); + }); + + it("every frame-selecting call marks the host dirty", () => { + const s = make(); + for (const act of [ + () => { + s.setCurrentAnimation("ab"); + }, + () => { + s.setAnimationFrame(1); + }, + () => { + s.reverseAnimation("walk"); + }, + ]) { + s.isDirty = false; + act(); + expect(s.isDirty).toBe(true); + } + }); + + it("setRegion marks the host dirty", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.isDirty = false; + s.setRegion(s.getAnimationFrameObjectByIndex(2)); + expect(s.isDirty).toBe(true); + }); + + it("the speed option scales how fast frames advance", () => { + const s = make(); + s.setCurrentAnimation("walk", { speed: 2 }); + s.update(50); // 50 × 2 = 100ms consumed → one frame + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("loop:false holds on the last frame", () => { + const s = make(); + s.setCurrentAnimation("ab", { loop: false }); + s.update(100); // → frame 1 (last) + expect(s.getCurrentAnimationFrame()).toBe(1); + s.update(1000); // would loop, but held + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("the next option chains to another animation at cycle end", () => { + const s = make(); + s.setCurrentAnimation("ab", { next: "walk" }); + s.update(200); // one full 2-frame cycle → chain + expect(s.isCurrentAnimation("walk")).toBe(true); + }); + + it("onComplete fires at each cycle end", () => { + const s = make(); + const spy = vi.fn(); + s.setCurrentAnimation("ab", { onComplete: spy }); + s.update(200); // one full cycle + expect(spy).toHaveBeenCalledTimes(1); + }); + + // ── legacy resetAnim forms (back-compat) ───────────────────────────── + + it("legacy: a bare function returning false holds the last frame", () => { + const s = make(); + const cb = vi.fn(() => { + return false; + }); + s.setCurrentAnimation("ab", cb); + s.update(200); + expect(cb).toHaveBeenCalled(); + expect(s.getCurrentAnimationFrame()).toBe(1); // held on last + }); + + it("legacy: a string second arg chains to that animation", () => { + const s = make(); + s.setCurrentAnimation("ab", "walk"); + s.update(200); + expect(s.isCurrentAnimation("walk")).toBe(true); + }); + + // ── pause / play / stop + animationpause accessor ──────────────────── + + it("animationpause freezes stepping; clearing it resumes", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.animationpause = true; + s.update(500); + expect(s.getCurrentAnimationFrame()).toBe(0); // frozen + expect(s.animationpause).toBe(true); + s.animationpause = false; + s.update(100); + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("pause() / play() toggle animationpause", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.pause(); + expect(s.animationpause).toBe(true); + s.play(); + expect(s.animationpause).toBe(false); + }); + + it("play(name) switches to and plays an animation", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.pause(); + s.play("ab"); + expect(s.isCurrentAnimation("ab")).toBe(true); + expect(s.animationpause).toBe(false); + }); + + it("stop() pauses and rewinds to the first frame", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.update(200); // → frame 2 + expect(s.getCurrentAnimationFrame()).toBe(2); + s.stop(); + expect(s.animationpause).toBe(true); + expect(s.getCurrentAnimationFrame()).toBe(0); + }); + + // ── reverseAnimation + dt accessor ─────────────────────────────────── + + it("reverseAnimation reverses the frame order", () => { + const s = make(); + expect(s.anim["walk"].frames[0].name).toBe(0); + s.reverseAnimation("walk"); + expect(s.anim["walk"].frames[0].name).toBe(3); + expect(s.anim["walk"].frames[3].name).toBe(0); + }); + + it("the dt accessor reads and writes the frame timer", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.dt = 40; + expect(s.dt).toBe(40); + // 40 + 70 = 110 ≥ 100 → advances one frame, 10ms carried over + s.update(70); + expect(s.getCurrentAnimationFrame()).toBe(1); + expect(s.dt).toBe(10); + }); + + it("switching animation resets the speed multiplier to 1", () => { + const s = make(); + s.setCurrentAnimation("walk", { speed: 4 }); + s.setCurrentAnimation("ab"); // no speed → back to 1× + s.update(100); + expect(s.getCurrentAnimationFrame()).toBe(1); // 100ms = one frame + }); + + // ── adversarial ────────────────────────────────────────────────────── + + it("ADVERSARIAL: re-selecting the current animation does NOT rewind it", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.update(200); // → frame 2 + s.setCurrentAnimation("walk"); // already current → no-op + expect(s.getCurrentAnimationFrame()).toBe(2); + }); + + it("ADVERSARIAL: a single-frame animation never advances and never hangs", () => { + const s = make(); + s.addAnimation("solo", [1]); + s.setCurrentAnimation("solo"); + s.update(1_000_000); // length === 1 → the stepping loop is skipped + expect(s.getCurrentAnimationFrame()).toBe(0); + expect(s.current.length).toBe(1); + }); + + it("ADVERSARIAL: setAnimationFrame wraps an out-of-range index", () => { + const s = make(); + s.setCurrentAnimation("walk"); // 4 frames + s.setAnimationFrame(5); // 5 % 4 + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("ADVERSARIAL: speed 0 freezes the animation (no divide-by-zero / drift)", () => { + const s = make(); + s.setCurrentAnimation("walk", { speed: 0 }); + s.update(100000); + expect(s.getCurrentAnimationFrame()).toBe(0); + }); + + it("ADVERSARIAL: a huge single dt steps deterministically and terminates", () => { + const s = make(); + s.setCurrentAnimation("walk"); // 4 frames × 100ms + s.update(1000); // 10 frames worth → 10 % 4 + expect(s.getCurrentAnimationFrame()).toBe(2); + }); + + it("ADVERSARIAL: an Infinity frame delay holds forever (die pattern)", () => { + const s = make(); + s.addAnimation("die", [ + { name: 0, delay: 100 }, + { name: 1, delay: Number.POSITIVE_INFINITY }, + ]); + s.setCurrentAnimation("die"); + s.update(100); // → frame 1 + expect(s.getCurrentAnimationFrame()).toBe(1); + s.update(1e12); // Infinity delay never elapses + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("ADVERSARIAL: changing animationspeed after addAnimation leaves baked delays intact", () => { + const s = make(); + expect(s.anim["walk"].frames[0].delay).toBe(100); + s.animationspeed = 10; // must not retro-edit existing frames + expect(s.anim["walk"].frames[0].delay).toBe(100); + }); + + it("ADVERSARIAL: reverseAnimation() with no arg reverses the CURRENT animation", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.reverseAnimation(); + expect(s.anim["walk"].frames[0].name).toBe(3); + }); + + it("ADVERSARIAL: onComplete fires before chaining via next", () => { + const s = make(); + const order = []; + s.setCurrentAnimation("ab", { + onComplete: () => { + return order.push("complete"); + }, + next: "walk", + }); + s.update(200); // one cycle → onComplete then chain + expect(order).toEqual(["complete"]); + expect(s.isCurrentAnimation("walk")).toBe(true); + }); + + it("ADVERSARIAL: a chained animation keeps looping afterwards", () => { + const s = make(); + s.setCurrentAnimation("ab", { next: "walk" }); + s.update(200); // → chains to walk @ frame 0 + expect(s.isCurrentAnimation("walk")).toBe(true); + expect(s.getCurrentAnimationFrame()).toBe(0); + s.update(100); // walk keeps advancing + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("ADVERSARIAL: manual setAnimationFrame still works while paused", () => { + const s = make(); + s.setCurrentAnimation("walk"); + s.pause(); + s.setAnimationFrame(3); + expect(s.getCurrentAnimationFrame()).toBe(3); + s.update(500); // paused → no drift + expect(s.getCurrentAnimationFrame()).toBe(3); + }); + }); +} + +// Sprite3d-only: a frame change must remap the quad's UVs (its `_applyFrame`) +describe("FrameAnimation → Sprite3d UV remap", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + it("maps the current frame onto the quad UVs", () => { + const s = new Sprite3d(0, 0, { + image: makeSheet(), + framewidth: 32, + frameheight: 32, + width: 32, + height: 32, + }); + s.addAnimation("walk", [0, 1, 2, 3], 100); + s.setCurrentAnimation("walk"); + // frame 0 → u ∈ [0, 0.25] (32/128) + expect(s.uvs[0]).toBeCloseTo(0, 5); + expect(s.uvs[2]).toBeCloseTo(0.25, 5); + s.update(100); // → frame 1 → u ∈ [0.25, 0.5] + expect(s.uvs[0]).toBeCloseTo(0.25, 5); + expect(s.uvs[2]).toBeCloseTo(0.5, 5); + }); +}); + +// Engine in isolation: prove FrameAnimation owns NO dirty flag — only the host's +// applyFrame callback can mark dirty. A fake host whose applyFrame deliberately +// does nothing must stay clean through every engine path. +describe("FrameAnimation engine — host owns the dirty flag", () => { + // minimal host: a 2-frame numeric atlas, applyFrame is a no-op that does NOT + // touch isDirty (so any isDirty=true would have to come from the engine) + const makeHost = () => { + const region = (x) => { + return { offset: { x, y: 0 }, width: 32, height: 32 }; + }; + return { + textureAtlas: { 0: region(0), 1: region(32) }, + atlasIndices: undefined, + source: { + getFormat: () => { + return "Spritesheet (fixed cell size)"; + }, + }, + onended: undefined, + isDirty: false, + applied: 0, + _applyFrame() { + // host geometry hook — intentionally does NOT set isDirty here, so + // the test can detect if the engine sets it instead + this.applied++; + }, + }; + }; + + it("never sets host.isDirty itself — applies frames only through the callback", () => { + const host = makeHost(); + const fa = new FrameAnimation(host, (region) => { + host._applyFrame(region); + }); + fa.addAnimation("walk", [0, 1], 100); + + fa.setCurrentAnimation("walk"); // applies frame 0 via the callback + expect(host.applied).toBeGreaterThan(0); // frame WAS applied + expect(host.isDirty).toBe(false); // …but the engine left isDirty alone + + const appliedBefore = host.applied; + fa.update(100); // advance a frame + expect(host.applied).toBeGreaterThan(appliedBefore); // applied again + expect(host.isDirty).toBe(false); // still never set by the engine + + fa.setAnimationFrame(1); + fa.setRegion(fa.getAnimationFrameObjectByIndex(0)); + fa.reverseAnimation("walk"); + expect(host.isDirty).toBe(false); // no engine path sets it + }); + + it("update() returns whether a frame changed (pure signal, no isDirty)", () => { + const host = makeHost(); + const fa = new FrameAnimation(host, (region) => { + host._applyFrame(region); + }); + fa.addAnimation("walk", [0, 1], 100); + fa.setCurrentAnimation("walk"); + expect(fa.update(100)).toBe(true); // stepped a frame + expect(fa.update(10)).toBe(false); // not enough time → no step + expect(host.isDirty).toBe(false); // and never set by the engine + }); +}); diff --git a/packages/melonjs/tests/sprite.spec.js b/packages/melonjs/tests/sprite.spec.js index a84d27bf2..f415c0ab5 100644 --- a/packages/melonjs/tests/sprite.spec.js +++ b/packages/melonjs/tests/sprite.spec.js @@ -469,6 +469,61 @@ describe("Sprite", () => { s.destroy(); expect(s.normalMap).toBeNull(); }); + + // integration: the shared FrameAnimation engine must drive the actual + // draw — advancing a frame must change the source rect handed to drawImage + it("animation advances the source frame fed to drawImage (update → draw)", () => { + // 128×32 sheet, 32px frames → frame i sourced at sx = i * 32 + const s = new Sprite(0, 0, { + framewidth: 32, + frameheight: 32, + image: video.createCanvas(128, 32), + }); + s.addAnimation("walk", [0, 1, 2, 3], 100); + s.setCurrentAnimation("walk"); + + const noop = () => {}; + const sx = []; + const stub = { + drawImage: (_img, sxArg) => { + return sx.push(sxArg); + }, + save: noop, + restore: noop, + translate: noop, + scale: noop, + transform: noop, + rotate: noop, + setGlobalAlpha: noop, + globalAlpha: () => { + return 1; + }, + setTint: noop, + clearTint: noop, + setDepth: noop, + setMask: noop, + clearMask: noop, + setBlendMode: noop, + getBlendMode: () => { + return "normal"; + }, + beginPostEffect: noop, + endPostEffect: noop, + currentNormalMap: null, + }; + const drawOnce = () => { + s.preDraw(stub); + s.draw(stub); + s.postDraw(stub); + }; + + drawOnce(); // frame 0 → sx 0 + s.update(100); + drawOnce(); // frame 1 → sx 32 + s.update(100); + drawOnce(); // frame 2 → sx 64 + expect(sx).toEqual([0, 32, 64]); + }); }); describe("animation API (options + speed)", () => { @@ -485,6 +540,26 @@ describe("Sprite", () => { return s; }; + // ── addAnimation frame delay defaulting ──────────────────────────── + + it("addAnimation defaults each frame delay to animationspeed", () => { + const s = makeSprite(); + // no per-call speed → falls back to the sprite's animationspeed (100) + s.addAnimation("def", [0, 1]); + expect(s.anim["def"].frames[0].delay).toBe(100); + expect(s.anim["def"].frames[1].delay).toBe(100); + }); + + it("addAnimation honors a sprite-level animationspeed override", () => { + const s = makeSprite(); + s.animationspeed = 250; // flows through the accessor to the engine + s.addAnimation("slow", [0, 1]); + expect(s.anim["slow"].frames[0].delay).toBe(250); + // an explicit per-call speed still wins over the default + s.addAnimation("fast", [0, 1], 30); + expect(s.anim["fast"].frames[0].delay).toBe(30); + }); + // ── legacy forms must keep working (non-breaking) ────────────────── it("legacy: loops by default", () => { @@ -608,7 +683,8 @@ describe("Sprite", () => { s.setCurrentAnimation("a", { loop: false }); s.update(400); // done + held s.setCurrentAnimation("b"); // switch - expect(s._animDone).toBe(false); + // `_animDone` is the internal hold flag, now owned by the shared engine + expect(s._frameAnim._animDone).toBe(false); s.update(100); expect(s.isCurrentAnimation("b")).toBe(true); expect(s.getCurrentAnimationFrame()).toBe(1); diff --git a/packages/melonjs/tests/sprite3d.spec.js b/packages/melonjs/tests/sprite3d.spec.js new file mode 100644 index 000000000..8ccdf622f --- /dev/null +++ b/packages/melonjs/tests/sprite3d.spec.js @@ -0,0 +1,643 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { + boot, + Camera3d, + Sprite3d, + Vector2d, + Vector3d, + video, +} from "../src/index.js"; + +/** + * Sprite3d billboard math — exercised directly through the world-space + * projection (`_projectVerticesWorld`) and the `Camera3d` orientation basis, so + * no WebGL / rendering is needed. The visual example validates the on-screen + * result; these pin the sign-sensitive geometry. + */ + +describe("Camera3d.getBasis", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + const r = new Vector3d(); + const u = new Vector3d(); + const f = new Vector3d(); + + it("yaw=0, pitch=0 → identity basis (forward = +Z)", () => { + const cam = new Camera3d(0, 0, 64, 64); + cam.yaw = 0; + cam.pitch = 0; + cam.getBasis(r, u, f); + expect(r.x).toBeCloseTo(1, 5); + expect(u.y).toBeCloseTo(1, 5); + expect(f.z).toBeCloseTo(1, 5); + }); + + it("basis stays orthonormal for arbitrary yaw/pitch", () => { + const cam = new Camera3d(0, 0, 64, 64); + cam.yaw = 0.7; + cam.pitch = -0.4; + cam.getBasis(r, u, f); + // unit length + expect(r.length()).toBeCloseTo(1, 5); + expect(u.length()).toBeCloseTo(1, 5); + expect(f.length()).toBeCloseTo(1, 5); + // mutually orthogonal + expect(r.x * u.x + r.y * u.y + r.z * u.z).toBeCloseTo(0, 5); + expect(r.x * f.x + r.y * f.y + r.z * f.z).toBeCloseTo(0, 5); + expect(u.x * f.x + u.y * f.y + u.z * f.z).toBeCloseTo(0, 5); + }); + + it("yaw swings the forward axis through the XZ plane (pitch 0)", () => { + const cam = new Camera3d(0, 0, 64, 64); + cam.pitch = 0; + cam.yaw = 0.9; + cam.getForward(f); + expect(f.y).toBeCloseTo(0, 5); // no vertical component at zero pitch + expect(f.x !== 0).toBe(true); // yaw introduced a horizontal component + }); +}); + +describe("Sprite3d billboard projection", () => { + 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; + }; + + // normal of the projected quad (corners 0,1,3) — should face the camera + const quadNormal = (v) => { + const e1 = new Vector3d( + v[3] - v[0], + v[4] - v[1], + v[5] - v[2], // corner1 - corner0 + ); + const e2 = new Vector3d( + v[9] - v[0], + v[10] - v[1], + v[11] - v[2], // corner3 - corner0 + ); + return e1.cross(e2).normalize(); + }; + + it("spherical: the quad normal is parallel to the camera forward axis", () => { + const cam = new Camera3d(0, 0, 64, 64); + const f = new Vector3d(); + const s = new Sprite3d(100, 50, { + image: makeTex(), + width: 20, + height: 30, + z: -200, + billboard: "spherical", + }); + 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 n = quadNormal(s.vertices); + cam.getForward(f); + const dot = n.x * f.x + n.y * f.y + n.z * f.z; + // facing the camera ⇒ normal parallel to forward (|dot| ≈ 1) + expect(Math.abs(dot)).toBeCloseTo(1, 4); + } + }); + + it("cylindrical: stays upright — the vertical edge is purely world-up", () => { + const cam = new Camera3d(0, 0, 64, 64); + cam.yaw = 1.0; + cam.pitch = -0.5; // pitched camera must NOT tilt a cylindrical billboard + const s = new Sprite3d(0, 0, { + image: makeTex(), + width: 20, + height: 30, + z: -100, + billboard: "cylindrical", + }); + s._billboardCam = cam; + s._projectVerticesWorld(s.pos.x, s.pos.y, s.depth); + const v = s.vertices; + // corner3 - corner0 share the same local x (-hw), differ in local y → + // the edge must be vertical (no x/z component) regardless of camera pitch + const dx = v[9] - v[0]; + const dy = v[10] - v[1]; + const dz = v[11] - v[2]; + expect(dx).toBeCloseTo(0, 4); + expect(dz).toBeCloseTo(0, 4); + expect(Math.abs(dy)).toBeCloseTo(30, 4); // == height + }); + + // project the quad's 4 corners to screen pixels and check the texture lands + // upright and unmirrored — the deterministic equivalent of "look at it". + // corner uvs (see Sprite3d): 0=(left,bottom) 1=(right,bottom) 2=(right,top) + // 3=(left,top). screen y grows downward, so "top above" = smaller y. + const screenCorners = (sprite, cam) => { + sprite._billboardCam = cam; + sprite._projectVerticesWorld(sprite.pos.x, sprite.pos.y, sprite.depth); + const v = sprite.vertices; + const out = []; + for (let i = 0; i < 4; i++) { + const s = cam.worldToScreen( + new Vector3d(v[i * 3], v[i * 3 + 1], v[i * 3 + 2]), + new Vector2d(), + ); + out.push(s); + } + return out; // [bl, br, tr, tl] by uv + }; + + const facingCam = (z) => { + const cam = new Camera3d(0, 0, 1024, 768); + cam.pos.set(0, -130, z); + cam.lookAt(0, -130, 0); + return cam; + }; + + for (const mode of ["spherical", "cylindrical"]) { + it(`${mode}: renders upright and unmirrored on screen (head-on)`, () => { + const cam = facingCam(600); + const s = new Sprite3d(0, -130, { + image: makeTex(), + width: 150, + height: 225, + z: 0, + billboard: mode, + }); + const [bl, br, tr, tl] = screenCorners(s, cam); + // texture top (tl/tr) above bottom (bl/br): smaller screen y + expect(tl.y).toBeLessThan(bl.y); + expect(tr.y).toBeLessThan(br.y); + // texture left (bl/tl) left of right (br/tr): smaller screen x + expect(bl.x).toBeLessThan(br.x); + expect(tl.x).toBeLessThan(tr.x); + }); + + it(`${mode}: stays upright and unmirrored when the camera orbits`, () => { + // camera off to the side, still looking at the sprite + const cam = new Camera3d(0, 0, 1024, 768); + cam.pos.set(450, -260, 430); + cam.lookAt(0, -130, 0); + const s = new Sprite3d(0, -130, { + image: makeTex(), + width: 150, + height: 225, + z: 0, + billboard: mode, + }); + const [bl, br, tr, tl] = screenCorners(s, cam); + expect(tl.y).toBeLessThan(bl.y); + expect(tr.y).toBeLessThan(br.y); + expect(bl.x).toBeLessThan(br.x); + expect(tl.x).toBeLessThan(tr.x); + }); + } + + it("animates frames by remapping the quad UVs (spritesheet)", () => { + // a 64×32 sheet = two 32×32 frames side by side + 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, + width: 48, + height: 48, + z: 0, + }); + s.addAnimation("spin", [0, 1], 100); + s.setCurrentAnimation("spin"); + + // frame 0 → left half of the sheet: u in [0, 0.5] + const uv0 = Array.from(s.uvs); + expect(uv0[0]).toBeCloseTo(0, 5); // left edge u + expect(uv0[2]).toBeCloseTo(0.5, 5); // right edge u + + // advance past the frame delay → frame 1 (right half: u in [0.5, 1]) + s.update(120); + const uv1 = Array.from(s.uvs); + expect(uv1[0]).toBeCloseTo(0.5, 5); + expect(uv1[2]).toBeCloseTo(1, 5); + + // world geometry (quad size) is independent of the 32px frame size + expect(s._halfW).toBeCloseTo(24, 5); + expect(s._halfH).toBeCloseTo(24, 5); + }); + + it("resolves a usable texture from a plain image (no framewidth)", () => { + // regression: a plain-image Sprite3d must still resolve an atlas whose + // getTexture() returns the source (else the mesh batcher reads .width of + // undefined at draw time). A default frame must also be selected. + const img = document.createElement("canvas"); + img.width = 48; + img.height = 64; + img.getContext("2d").fillRect(0, 0, 48, 64); + const s = new Sprite3d(0, 0, { image: img, width: 48, height: 64 }); + const tex = s.texture.getTexture(); + expect(tex).toBeDefined(); + expect(tex.width).toBe(48); + expect(tex.height).toBe(64); + // the catch-all "default" animation should have been selected + expect(s.current.name).toBe("default"); + }); + + it("defaults alphaCutoff to 0.5 so transparent sprite backgrounds cut out", () => { + const img = document.createElement("canvas"); + img.width = 32; + img.height = 32; + img.getContext("2d").fillRect(0, 0, 32, 32); + // default → 0.5 (the mesh pass is opaque, so a cutout gives transparency) + const a = new Sprite3d(0, 0, { image: img, width: 32, height: 32 }); + expect(a.alphaCutoff).toBe(0.5); + // explicit override is honored, including 0 (fully opaque) + const b = new Sprite3d(0, 0, { + image: img, + width: 32, + height: 32, + alphaCutoff: 0, + }); + expect(b.alphaCutoff).toBe(0); + const c = new Sprite3d(0, 0, { + image: img, + width: 32, + height: 32, + alphaCutoff: 0.25, + }); + expect(c.alphaCutoff).toBe(0.25); + }); + + it("world quad size falls back to the frame size when width is omitted", () => { + 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, + }); + expect(s._halfW).toBeCloseTo(16, 5); + expect(s._halfH).toBeCloseTo(16, 5); + }); + + it("billboard:false renders a fixed-orientation quad (no camera facing)", () => { + const cam = new Camera3d(0, 0, 64, 64); + cam.yaw = 1.0; + const s = new Sprite3d(0, 0, { + image: makeTex(), + width: 20, + height: 30, + z: -100, + billboard: false, + }); + s._billboardCam = cam; + s._projectVerticesWorld(0, 0, -100); + // fixed quad lives in the XY plane: all corners share the same z + const v = s.vertices; + expect(v[2]).toBeCloseTo(v[5], 4); + expect(v[5]).toBeCloseTo(v[8], 4); + expect(v[8]).toBeCloseTo(v[11], 4); + }); +}); + +describe("Sprite3d atlas region mapping (trim + rotation)", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + // a 200×200 source so logical (untrimmed) frame = 200 → world scale 1 at + // width/height 200; setRegion is exercised directly with synthetic regions + const make = (imgW, imgH, w, h) => { + const img = document.createElement("canvas"); + img.width = imgW; + img.height = imgH; + img.getContext("2d").fillRect(0, 0, imgW, imgH); + return new Sprite3d(0, 0, { image: img, width: w, height: h }); + }; + + it("maps a trimmed region to the correct sub-rect of the quad + UVs", () => { + const s = make(200, 200, 200, 200); // hw = hh = 100, scale = 1 + // art 120×140 packed at atlas (10,20), offset by trim (40,30) inside a + // 200×200 logical frame + 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; + // c0 BL, c1 BR, c2 TR, c3 TL — the art rect within the centered frame + expect([v[0], v[1]]).toEqual([-60, -70]); + expect([v[3], v[4]]).toEqual([60, -70]); + expect([v[6], v[7]]).toEqual([60, 70]); + expect([v[9], v[10]]).toEqual([-60, 70]); + const uv = s.uvs; + // atlas AABB: u ∈ [10/200, 130/200], v ∈ [20/200, 160/200] + expect(uv[0]).toBeCloseTo(0.05, 5); // BL u (left) + expect(uv[1]).toBeCloseTo(0.8, 5); // BL v (bottom) + expect(uv[4]).toBeCloseTo(0.65, 5); // TR u (right) + expect(uv[5]).toBeCloseTo(0.1, 5); // TR v (top) + }); + + it("maps a packer-rotated region with the atlas AABB swapped + UVs permuted 90°", () => { + const s = make(80, 80, 40, 80); // hw = 20, hh = 40 + // simulate the atlas-first-frame capture (no default frame on a named + // atlas): logical size comes from this region, not the page image + s._refLw = 0; + s._refLh = 0; + // art 40 wide × 80 tall (unrotated); stored rotated → atlas AABB 80×40 + s.setRegion({ + offset: { x: 0, y: 0 }, + width: 40, + height: 80, + trimmed: false, + trim: null, + sourceSize: null, + angle: -Math.PI / 2, + }); + const uv = s.uvs; + // atlas AABB is height×width = 80×40 → uR = 80/80 = 1, vB = 40/80 = 0.5 + // rotated permutation: BL→(0,0) BR→(0,0.5) TR→(1,0.5) TL→(1,0) + expect([uv[0], uv[1]]).toEqual([0, 0]); // BL → atlas top-left + expect([uv[2], uv[3]]).toEqual([0, 0.5]); // BR → atlas bottom-left + expect([uv[4], uv[5]]).toEqual([1, 0.5]); // TR → atlas bottom-right + expect([uv[6], uv[7]]).toEqual([1, 0]); // TL → atlas top-right + // quad geometry is the full (untrimmed) art: ±hw / ±hh + const v = s.originalVertices; + expect([v[0], v[1]]).toEqual([-20, -40]); + expect([v[6], v[7]]).toEqual([20, 40]); + }); + + it("maps a region that is BOTH rotated and trimmed (UVs + geometry)", () => { + const s = make(256, 256, 60, 100); // hw = 30, hh = 50 + s._refLw = 0; // logical comes from this region's sourceSize (60×100) + s._refLh = 0; + // art 40×80 (unrotated), packed rotated at atlas (5,7) → AABB 80×40; + // trimmed: art sits at (10,8) inside a 60×100 logical frame + 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, + }); + // geometry: trimmed art sub-rect inside the centered logical frame (scale 1) + const v = s.originalVertices; + expect([v[0], v[1]]).toEqual([-20, -38]); // c0 BL + expect([v[3], v[4]]).toEqual([20, -38]); // c1 BR + expect([v[6], v[7]]).toEqual([20, 42]); // c2 TR + expect([v[9], v[10]]).toEqual([-20, 42]); // c3 TL + // UVs: atlas AABB is height×width = 80×40, with the rotated permutation + const uv = s.uvs; + expect(uv[0]).toBeCloseTo(5 / 256, 5); // BL u (atlas left) + expect(uv[1]).toBeCloseTo(7 / 256, 5); // BL v (atlas top) + expect(uv[2]).toBeCloseTo(5 / 256, 5); // BR u + expect(uv[3]).toBeCloseTo(47 / 256, 5); // BR v (atlas bottom = (7+40)/256) + expect(uv[4]).toBeCloseTo(85 / 256, 5); // TR u (atlas right = (5+80)/256) + expect(uv[5]).toBeCloseTo(47 / 256, 5); + expect(uv[6]).toBeCloseTo(85 / 256, 5); // TL u + expect(uv[7]).toBeCloseTo(7 / 256, 5); + }); +}); + +describe("Sprite3d flipX / flipY", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + // 64×32 sheet → two 32×32 frames; sprite sized 1:1 (world unit == frame px) + const makeAnimated = (settings) => { + 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, + width: 32, + height: 32, + ...settings, + }); + s.addAnimation("walk", [0, 1], 100); + s.setCurrentAnimation("walk"); + return s; + }; + + it("flipX mirrors the quad horizontally — geometry only, UVs unchanged", () => { + const s = makeAnimated(); + const ov = Array.from(s.originalVertices); + const uv = Array.from(s.uvs); + expect(s.isFlippedX()).toBe(false); + expect(s.flipX()).toBe(s); // chainable + expect(s.isFlippedX()).toBe(true); + for (let i = 0; i < 4; i++) { + expect(s.originalVertices[i * 3]).toBeCloseTo(-ov[i * 3], 5); // x negated + expect(s.originalVertices[i * 3 + 1]).toBeCloseTo(ov[i * 3 + 1], 5); // y same + } + expect(Array.from(s.uvs)).toEqual(uv); // texture mirrors via geometry + }); + + it("flipY mirrors the quad vertically — geometry only, UVs unchanged", () => { + const s = makeAnimated(); + const ov = Array.from(s.originalVertices); + const uv = Array.from(s.uvs); + s.flipY(); + expect(s.isFlippedY()).toBe(true); + for (let i = 0; i < 4; i++) { + expect(s.originalVertices[i * 3]).toBeCloseTo(ov[i * 3], 5); // x same + expect(s.originalVertices[i * 3 + 1]).toBeCloseTo(-ov[i * 3 + 1], 5); // y neg + } + expect(Array.from(s.uvs)).toEqual(uv); + }); + + it("flipX(false) restores the original orientation", () => { + const s = makeAnimated(); + const ov = Array.from(s.originalVertices); + s.flipX(); + s.flipX(false); + expect(s.isFlippedX()).toBe(false); + for (let i = 0; i < 12; i++) { + expect(s.originalVertices[i]).toBeCloseTo(ov[i], 5); + } + }); + + it("settings.flipX applies the flip at construction", () => { + const a = makeAnimated(); + const b = makeAnimated({ flipX: true }); + expect(b.isFlippedX()).toBe(true); + expect(b.originalVertices[0]).toBeCloseTo(-a.originalVertices[0], 5); + }); + + it("flip marks the sprite dirty", () => { + const s = makeAnimated(); + s.isDirty = false; + s.flipX(); + expect(s.isDirty).toBe(true); + }); + + it("flip persists across animation frames", () => { + const s = makeAnimated(); + s.flipX(); + const x0 = s.originalVertices[0]; + s.update(100); // advance to frame 1 + expect(s.getCurrentAnimationFrame()).toBe(1); + expect(s.isFlippedX()).toBe(true); + // same-size frames → geometry identical, still mirrored to the same side + expect(s.originalVertices[0]).toBeCloseTo(x0, 5); + expect(s.originalVertices[0]).toBeGreaterThan(0); // c0 on the mirrored side + }); + + // ── adversarial: flip × texture atlas (trimmed + rotated) ─────────────── + + const makeAtlasSprite = (imgW, imgH, w, h) => { + const img = document.createElement("canvas"); + img.width = imgW; + img.height = imgH; + img.getContext("2d").fillRect(0, 0, imgW, imgH); + return new Sprite3d(0, 0, { image: img, width: w, height: h }); + }; + + it("ADVERSARIAL: flipX mirrors a trimmed region (geometry only, UVs intact)", () => { + const s = makeAtlasSprite(200, 200, 200, 200); + 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 ov = Array.from(s.originalVertices); + const uv = Array.from(s.uvs); + s.flipX(); + for (let i = 0; i < 4; i++) { + expect(s.originalVertices[i * 3]).toBeCloseTo(-ov[i * 3], 5); + expect(s.originalVertices[i * 3 + 1]).toBeCloseTo(ov[i * 3 + 1], 5); + } + expect(Array.from(s.uvs)).toEqual(uv); // trim UVs untouched by the flip + }); + + it("ADVERSARIAL: flipX mirrors a packer-rotated region, UV permutation intact", () => { + const s = makeAtlasSprite(80, 80, 40, 80); + s._refLw = 0; // simulate atlas-first-frame capture (logical from this region) + s._refLh = 0; + s.setRegion({ + offset: { x: 0, y: 0 }, + width: 40, + height: 80, + trimmed: false, + trim: null, + sourceSize: null, + angle: -Math.PI / 2, + }); + const ov = Array.from(s.originalVertices); + const uv = Array.from(s.uvs); // the rotated permutation + s.flipX(); + for (let i = 0; i < 4; i++) { + expect(s.originalVertices[i * 3]).toBeCloseTo(-ov[i * 3], 5); + } + expect(Array.from(s.uvs)).toEqual(uv); // rotation permutation preserved + }); + + it("ADVERSARIAL: flipX + flipY mirror both axes; toggling each back restores", () => { + const s = makeAnimated(); + const ov = Array.from(s.originalVertices); + s.flipX(); + s.flipY(); + for (let i = 0; i < 4; i++) { + expect(s.originalVertices[i * 3]).toBeCloseTo(-ov[i * 3], 5); + expect(s.originalVertices[i * 3 + 1]).toBeCloseTo(-ov[i * 3 + 1], 5); + } + s.flipX(false); + s.flipY(false); + for (let i = 0; i < 12; i++) { + expect(s.originalVertices[i]).toBeCloseTo(ov[i], 5); + } + }); + + it("ADVERSARIAL: flipX swaps on-screen left/right under a billboard camera", () => { + const cam = new Camera3d(0, 0, 1024, 768); + cam.pos.set(0, 0, 600); + cam.lookAt(0, 0, 0); + const s = makeAnimated({ billboard: "cylindrical" }); + const project = () => { + s._billboardCam = cam; + s._projectVerticesWorld(0, 0, 0); + const c0 = cam.worldToScreen( + new Vector3d(s.vertices[0], s.vertices[1], s.vertices[2]), + new Vector2d(), + ); + const c1 = cam.worldToScreen( + new Vector3d(s.vertices[3], s.vertices[4], s.vertices[5]), + new Vector2d(), + ); + return { c0, c1 }; + }; + const before = project(); + expect(before.c0.x).toBeLessThan(before.c1.x); // BL left of BR + s.flipX(); + const after = project(); + expect(after.c0.x).toBeGreaterThan(after.c1.x); // mirrored on screen + }); + + it("flip is maintained across full animation cycles and animation switches", () => { + const s = makeAnimated(); // "walk" [0, 1] @ 100ms → wraps every 200ms + s.addAnimation("idle", [0, 1], 100); + s.flipX(); + // advance through several full loops (incl. wraps back to frame 0) + for (let i = 0; i < 6; i++) { + s.update(100); + expect(s.isFlippedX()).toBe(true); + // same-size frames → c0 stays on the mirrored (positive x) side every frame + expect(s.originalVertices[0]).toBeGreaterThan(0); + } + expect(s.getCurrentAnimationFrame()).toBe(0); // 6 × 100ms on a 2-frame loop + // switching animations keeps the flip + s.setCurrentAnimation("idle"); + expect(s.isFlippedX()).toBe(true); + expect(s.originalVertices[0]).toBeGreaterThan(0); + }); +}); + +describe("Sprite3d resource cleanup", () => { + beforeAll(() => { + boot(); + video.init(64, 64, { parent: "screen", renderer: video.CANVAS }); + }); + + it("destroy() releases the engine's pooled current.offset", () => { + const img = document.createElement("canvas"); + img.width = 32; + img.height = 32; + img.getContext("2d").fillRect(0, 0, 32, 32); + const s = new Sprite3d(0, 0, { image: img, width: 32, height: 32 }); + const offset = s._frameAnim.current.offset; + expect(offset).not.toBeNull(); + s.destroy(); + // the pooled Vector2d is returned and the reference cleared + expect(s._frameAnim.current.offset).toBeNull(); + }); +});