import { describe, expect, it } from "vitest"; import type { Entity, SpriteDef } from "@nengine/core"; import { frameAtTime, frameWeight, hexToRgbColor, resolveSprite, spriteDrawCommands, spriteDurationMs, } from "../src/sprite-draw.js"; const TILE = 32; function sprite(frames: SpriteDef["frames"]): SpriteDef { return { id: "test", frames }; } function entity(overrides: Partial = {}): Entity { return { id: "e", type: "player", x: 2, y: 3, ...overrides }; } describe("sprite-draw pure mapping", () => { it("converts #rrggbb fills to number colors", () => { expect(hexToRgbColor("#ffb300")).toBe(0xffb300); expect(hexToRgbColor("#8d6e63")).toBe(0x8d6e63); }); it("frameWeight defaults absent/zero durationTicks to 1", () => { const oneFrame: SpriteDef["frames"][number] = { layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }; expect(frameWeight(oneFrame)).toBe(1); expect(frameWeight({ ...oneFrame, durationTicks: 0 })).toBe(1); expect(frameWeight({ ...oneFrame, durationTicks: 3 })).toBe(3); }); it("single-frame sprites are always frame 0 regardless of elapsed time", () => { const def = sprite([{ name: "idle", layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }]); expect(frameAtTime(def, 0)).toBe(0); expect(frameAtTime(def, 10_000)).toBe(0); }); it("advances frames proportionally to durationTicks at the default frame rate", () => { const def = sprite([ { name: "a", durationTicks: 1, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }, { name: "b", durationTicks: 3, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }, ]); // cycle = (1+3)/10 s = 400ms; frame a occupies the first 100ms expect(frameAtTime(def, 0)).toBe(0); expect(frameAtTime(def, 99)).toBe(0); expect(frameAtTime(def, 100)).toBe(1); expect(frameAtTime(def, 399)).toBe(1); expect(frameAtTime(def, 400)).toBe(0); expect(frameAtTime(def, 401)).toBe(0); expect(frameAtTime(def, 500)).toBe(1); }); it("spriteDurationMs spans one full cycle", () => { const def = sprite([ { durationTicks: 2, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }, { durationTicks: 3, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }, ]); expect(spriteDurationMs(def)).toBe(500); expect(spriteDurationMs(def, 20)).toBe(250); }); }); describe("resolveSprite", () => { const catalog: SpriteDef[] = [ sprite([{ layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }]), ]; it("resolves by spriteRef against a def array", () => { expect(resolveSprite(entity({ spriteRef: "test" }), catalog)).toBe(catalog[0]); }); it("resolves by spriteRef against a Map", () => { const map = new Map(catalog.map((s) => [s.id, s])); expect(resolveSprite(entity({ spriteRef: "test" }), map)).toBe(catalog[0]); }); it("returns undefined for a missing spriteRef or unknown id", () => { expect(resolveSprite(entity(), catalog)).toBeUndefined(); expect(resolveSprite(entity({ spriteRef: "nope" }), catalog)).toBeUndefined(); }); }); describe("spriteDrawCommands", () => { const playerSprite = sprite([ { name: "idle", layers: [ { shape: "rect", x: 10, y: 16, w: 12, h: 12, fill: "#8d6e63" }, { shape: "circle", x: 16, y: 12, r: 6, fill: "#ffccbc" }, { shape: "rect", x: 10, y: 8, w: 12, h: 4, fill: "#ffb300" }, ], }, ]); it("translates tile-space layers to entity pixel coordinates at 1x1 tile", () => { const commands = spriteDrawCommands(playerSprite, 0, entity(), TILE); expect(commands).toEqual([ { kind: "rect", x: 2 * TILE + 10, y: 3 * TILE + 16, w: 12, h: 12, color: 0x8d6e63 }, { kind: "circle", x: 2 * TILE + 16, y: 3 * TILE + 12, w: 6, h: 6, color: 0xffccbc }, { kind: "rect", x: 2 * TILE + 10, y: 3 * TILE + 8, w: 12, h: 4, color: 0xffb300 }, ]); }); it("scales a building sprite to its tile footprint box", () => { const building = entity({ type: "building", w: 3, h: 2 }); const coop = sprite([ { layers: [ { shape: "rect", x: 4, y: 10, w: 24, h: 20, fill: "#6d4c41" }, { shape: "circle", x: 8, y: 14, r: 3, fill: "#fff59d" }, ], }, ]); const commands = spriteDrawCommands(coop, 0, building, TILE); expect(commands).toEqual([ { kind: "rect", x: 2 * TILE + 4 * 3, y: 3 * TILE + 10 * 2, w: 24 * 3, h: 20 * 2, color: 0x6d4c41 }, { kind: "circle", x: 2 * TILE + 8 * 3, y: 3 * TILE + 14 * 2, w: 3 * 3, h: 3 * 2, color: 0xfff59d }, ]); }); it("clamps an out-of-range frame index to the first frame", () => { const commands = spriteDrawCommands(playerSprite, 99, entity(), TILE); expect(commands).toHaveLength(3); expect(commands[0]).toMatchObject({ kind: "rect", color: 0x8d6e63 }); }); }); describe("spriteDrawCommands image layers (D31)", () => { const imageSprite = sprite([ { layers: [ { shape: "image", src: "plant-1" }, { shape: "image", src: "plant-1", x: 4, y: 6, w: 20, h: 20 }, ], }, ]); it("maps a whole-image layer to a dest box at the natural pixel size", () => { const dims = new Map([["plant-1", { width: 16, height: 16 }]]); const commands = spriteDrawCommands(imageSprite, 0, entity(), TILE, dims); expect(commands[0]).toEqual({ kind: "image", assetId: "plant-1", x: 2 * TILE, y: 3 * TILE, w: 16, h: 16, }); }); it("crops from the source top-left when w/h are smaller than the asset", () => { const dims = new Map([["plant-1", { width: 16, height: 16 }]]); const spriteDef = sprite([{ layers: [{ shape: "image", src: "plant-1", x: 4, y: 6, w: 8, h: 12 }] }]); const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims); expect(commands[0]).toEqual({ kind: "image", assetId: "plant-1", x: 2 * TILE + 4, y: 3 * TILE + 6, w: 8, h: 12, sourceW: 8, sourceH: 12, }); }); it("upscales the whole image when w/h exceed the asset (crop clamped to source)", () => { const dims = new Map([["plant-1", { width: 16, height: 16 }]]); const spriteDef = sprite([{ layers: [{ shape: "image", src: "plant-1", x: 0, y: 0, w: 24, h: 24 }] }]); const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims); expect(commands[0]).toEqual({ kind: "image", assetId: "plant-1", x: 2 * TILE, y: 3 * TILE, w: 24, h: 24, sourceW: 16, sourceH: 16, }); }); it("falls back to DEFAULT_IMAGE_SIZE when no w/h and the asset dims are unknown", () => { const commands = spriteDrawCommands(imageSprite, 0, entity(), TILE); expect(commands[0]).toEqual({ kind: "image", assetId: "plant-1", x: 2 * TILE, y: 3 * TILE, w: 32, h: 32, }); }); it("scales image layers with the entity footprint (building scaling)", () => { const dims = new Map([["coop-1", { width: 16, height: 16 }]]); const building = entity({ type: "building", w: 3, h: 2 }); const spriteDef = sprite([{ layers: [{ shape: "image", src: "coop-1", x: 0, y: 0, w: 32, h: 32 }] }]); const commands = spriteDrawCommands(spriteDef, 0, building, TILE, dims); expect(commands[0]).toEqual({ kind: "image", assetId: "coop-1", x: 2 * TILE, y: 3 * TILE, w: 32 * 3, h: 32 * 2, sourceW: 16, sourceH: 16, }); }); }); describe("spriteDrawCommands image frame crops (D34 sprite-sheet)", () => { const sheetSprite = sprite([ { layers: [{ shape: "image", src: "sheet-1", x: 0, y: 0, w: 16, h: 16, frameX: 16, frameY: 32, frameW: 16, frameH: 16 }], }, ]); it("carries the frame crop rect through to the image command", () => { const dims = new Map([["sheet-1", { width: 64, height: 64 }]]); const commands = spriteDrawCommands(sheetSprite, 0, entity(), TILE, dims); expect(commands[0]).toMatchObject({ kind: "image", assetId: "sheet-1", frameX: 16, frameY: 32, frameW: 16, frameH: 16, }); }); it("clamps the frame rect to the remaining source pixels", () => { const dims = new Map([["sheet-1", { width: 32, height: 32 }]]); const spriteDef = sprite([ { layers: [{ shape: "image", src: "sheet-1", frameX: 28, frameY: 4, frameW: 16, frameH: 16 }] }, ]); const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims); expect(commands[0]).toMatchObject({ frameX: 28, frameY: 4, frameW: 4, frameH: 16 }); }); it("keeps the D31 behavior when no frame fields are present", () => { const dims = new Map([["plant-1", { width: 16, height: 16 }]]); const spriteDef = sprite([{ layers: [{ shape: "image", src: "plant-1", w: 8, h: 8 }] }]); const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims); expect(commands[0]).toEqual({ kind: "image", assetId: "plant-1", x: 2 * TILE, y: 3 * TILE, w: 8, h: 8, sourceW: 8, sourceH: 8, }); expect(commands[0]).not.toHaveProperty("frameX"); expect(commands[0]).not.toHaveProperty("frameW"); }); });