| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- 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> = {}): 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");
- });
- });
|