sprite-draw.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { describe, expect, it } from "vitest";
  2. import type { Entity, SpriteDef } from "@nengine/core";
  3. import {
  4. frameAtTime,
  5. frameWeight,
  6. hexToRgbColor,
  7. resolveSprite,
  8. spriteDrawCommands,
  9. spriteDurationMs,
  10. } from "../src/sprite-draw.js";
  11. const TILE = 32;
  12. function sprite(frames: SpriteDef["frames"]): SpriteDef {
  13. return { id: "test", frames };
  14. }
  15. function entity(overrides: Partial<Entity> = {}): Entity {
  16. return { id: "e", type: "player", x: 2, y: 3, ...overrides };
  17. }
  18. describe("sprite-draw pure mapping", () => {
  19. it("converts #rrggbb fills to number colors", () => {
  20. expect(hexToRgbColor("#ffb300")).toBe(0xffb300);
  21. expect(hexToRgbColor("#8d6e63")).toBe(0x8d6e63);
  22. });
  23. it("frameWeight defaults absent/zero durationTicks to 1", () => {
  24. const oneFrame: SpriteDef["frames"][number] = { layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] };
  25. expect(frameWeight(oneFrame)).toBe(1);
  26. expect(frameWeight({ ...oneFrame, durationTicks: 0 })).toBe(1);
  27. expect(frameWeight({ ...oneFrame, durationTicks: 3 })).toBe(3);
  28. });
  29. it("single-frame sprites are always frame 0 regardless of elapsed time", () => {
  30. const def = sprite([{ name: "idle", layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }]);
  31. expect(frameAtTime(def, 0)).toBe(0);
  32. expect(frameAtTime(def, 10_000)).toBe(0);
  33. });
  34. it("advances frames proportionally to durationTicks at the default frame rate", () => {
  35. const def = sprite([
  36. { name: "a", durationTicks: 1, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] },
  37. { name: "b", durationTicks: 3, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] },
  38. ]);
  39. // cycle = (1+3)/10 s = 400ms; frame a occupies the first 100ms
  40. expect(frameAtTime(def, 0)).toBe(0);
  41. expect(frameAtTime(def, 99)).toBe(0);
  42. expect(frameAtTime(def, 100)).toBe(1);
  43. expect(frameAtTime(def, 399)).toBe(1);
  44. expect(frameAtTime(def, 400)).toBe(0);
  45. expect(frameAtTime(def, 401)).toBe(0);
  46. expect(frameAtTime(def, 500)).toBe(1);
  47. });
  48. it("spriteDurationMs spans one full cycle", () => {
  49. const def = sprite([
  50. { durationTicks: 2, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] },
  51. { durationTicks: 3, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] },
  52. ]);
  53. expect(spriteDurationMs(def)).toBe(500);
  54. expect(spriteDurationMs(def, 20)).toBe(250);
  55. });
  56. });
  57. describe("resolveSprite", () => {
  58. const catalog: SpriteDef[] = [
  59. sprite([{ layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }]),
  60. ];
  61. it("resolves by spriteRef against a def array", () => {
  62. expect(resolveSprite(entity({ spriteRef: "test" }), catalog)).toBe(catalog[0]);
  63. });
  64. it("resolves by spriteRef against a Map", () => {
  65. const map = new Map(catalog.map((s) => [s.id, s]));
  66. expect(resolveSprite(entity({ spriteRef: "test" }), map)).toBe(catalog[0]);
  67. });
  68. it("returns undefined for a missing spriteRef or unknown id", () => {
  69. expect(resolveSprite(entity(), catalog)).toBeUndefined();
  70. expect(resolveSprite(entity({ spriteRef: "nope" }), catalog)).toBeUndefined();
  71. });
  72. });
  73. describe("spriteDrawCommands", () => {
  74. const playerSprite = sprite([
  75. {
  76. name: "idle",
  77. layers: [
  78. { shape: "rect", x: 10, y: 16, w: 12, h: 12, fill: "#8d6e63" },
  79. { shape: "circle", x: 16, y: 12, r: 6, fill: "#ffccbc" },
  80. { shape: "rect", x: 10, y: 8, w: 12, h: 4, fill: "#ffb300" },
  81. ],
  82. },
  83. ]);
  84. it("translates tile-space layers to entity pixel coordinates at 1x1 tile", () => {
  85. const commands = spriteDrawCommands(playerSprite, 0, entity(), TILE);
  86. expect(commands).toEqual([
  87. { kind: "rect", x: 2 * TILE + 10, y: 3 * TILE + 16, w: 12, h: 12, color: 0x8d6e63 },
  88. { kind: "circle", x: 2 * TILE + 16, y: 3 * TILE + 12, w: 6, h: 6, color: 0xffccbc },
  89. { kind: "rect", x: 2 * TILE + 10, y: 3 * TILE + 8, w: 12, h: 4, color: 0xffb300 },
  90. ]);
  91. });
  92. it("scales a building sprite to its tile footprint box", () => {
  93. const building = entity({ type: "building", w: 3, h: 2 });
  94. const coop = sprite([
  95. {
  96. layers: [
  97. { shape: "rect", x: 4, y: 10, w: 24, h: 20, fill: "#6d4c41" },
  98. { shape: "circle", x: 8, y: 14, r: 3, fill: "#fff59d" },
  99. ],
  100. },
  101. ]);
  102. const commands = spriteDrawCommands(coop, 0, building, TILE);
  103. expect(commands).toEqual([
  104. { kind: "rect", x: 2 * TILE + 4 * 3, y: 3 * TILE + 10 * 2, w: 24 * 3, h: 20 * 2, color: 0x6d4c41 },
  105. { kind: "circle", x: 2 * TILE + 8 * 3, y: 3 * TILE + 14 * 2, w: 3 * 3, h: 3 * 2, color: 0xfff59d },
  106. ]);
  107. });
  108. it("clamps an out-of-range frame index to the first frame", () => {
  109. const commands = spriteDrawCommands(playerSprite, 99, entity(), TILE);
  110. expect(commands).toHaveLength(3);
  111. expect(commands[0]).toMatchObject({ kind: "rect", color: 0x8d6e63 });
  112. });
  113. });
  114. describe("spriteDrawCommands image layers (D31)", () => {
  115. const imageSprite = sprite([
  116. {
  117. layers: [
  118. { shape: "image", src: "plant-1" },
  119. { shape: "image", src: "plant-1", x: 4, y: 6, w: 20, h: 20 },
  120. ],
  121. },
  122. ]);
  123. it("maps a whole-image layer to a dest box at the natural pixel size", () => {
  124. const dims = new Map([["plant-1", { width: 16, height: 16 }]]);
  125. const commands = spriteDrawCommands(imageSprite, 0, entity(), TILE, dims);
  126. expect(commands[0]).toEqual({
  127. kind: "image",
  128. assetId: "plant-1",
  129. x: 2 * TILE,
  130. y: 3 * TILE,
  131. w: 16,
  132. h: 16,
  133. });
  134. });
  135. it("crops from the source top-left when w/h are smaller than the asset", () => {
  136. const dims = new Map([["plant-1", { width: 16, height: 16 }]]);
  137. const spriteDef = sprite([{ layers: [{ shape: "image", src: "plant-1", x: 4, y: 6, w: 8, h: 12 }] }]);
  138. const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims);
  139. expect(commands[0]).toEqual({
  140. kind: "image",
  141. assetId: "plant-1",
  142. x: 2 * TILE + 4,
  143. y: 3 * TILE + 6,
  144. w: 8,
  145. h: 12,
  146. sourceW: 8,
  147. sourceH: 12,
  148. });
  149. });
  150. it("upscales the whole image when w/h exceed the asset (crop clamped to source)", () => {
  151. const dims = new Map([["plant-1", { width: 16, height: 16 }]]);
  152. const spriteDef = sprite([{ layers: [{ shape: "image", src: "plant-1", x: 0, y: 0, w: 24, h: 24 }] }]);
  153. const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims);
  154. expect(commands[0]).toEqual({
  155. kind: "image",
  156. assetId: "plant-1",
  157. x: 2 * TILE,
  158. y: 3 * TILE,
  159. w: 24,
  160. h: 24,
  161. sourceW: 16,
  162. sourceH: 16,
  163. });
  164. });
  165. it("falls back to DEFAULT_IMAGE_SIZE when no w/h and the asset dims are unknown", () => {
  166. const commands = spriteDrawCommands(imageSprite, 0, entity(), TILE);
  167. expect(commands[0]).toEqual({
  168. kind: "image",
  169. assetId: "plant-1",
  170. x: 2 * TILE,
  171. y: 3 * TILE,
  172. w: 32,
  173. h: 32,
  174. });
  175. });
  176. it("scales image layers with the entity footprint (building scaling)", () => {
  177. const dims = new Map([["coop-1", { width: 16, height: 16 }]]);
  178. const building = entity({ type: "building", w: 3, h: 2 });
  179. const spriteDef = sprite([{ layers: [{ shape: "image", src: "coop-1", x: 0, y: 0, w: 32, h: 32 }] }]);
  180. const commands = spriteDrawCommands(spriteDef, 0, building, TILE, dims);
  181. expect(commands[0]).toEqual({
  182. kind: "image",
  183. assetId: "coop-1",
  184. x: 2 * TILE,
  185. y: 3 * TILE,
  186. w: 32 * 3,
  187. h: 32 * 2,
  188. sourceW: 16,
  189. sourceH: 16,
  190. });
  191. });
  192. });
  193. describe("spriteDrawCommands image frame crops (D34 sprite-sheet)", () => {
  194. const sheetSprite = sprite([
  195. {
  196. layers: [{ shape: "image", src: "sheet-1", x: 0, y: 0, w: 16, h: 16, frameX: 16, frameY: 32, frameW: 16, frameH: 16 }],
  197. },
  198. ]);
  199. it("carries the frame crop rect through to the image command", () => {
  200. const dims = new Map([["sheet-1", { width: 64, height: 64 }]]);
  201. const commands = spriteDrawCommands(sheetSprite, 0, entity(), TILE, dims);
  202. expect(commands[0]).toMatchObject({
  203. kind: "image",
  204. assetId: "sheet-1",
  205. frameX: 16,
  206. frameY: 32,
  207. frameW: 16,
  208. frameH: 16,
  209. });
  210. });
  211. it("clamps the frame rect to the remaining source pixels", () => {
  212. const dims = new Map([["sheet-1", { width: 32, height: 32 }]]);
  213. const spriteDef = sprite([
  214. { layers: [{ shape: "image", src: "sheet-1", frameX: 28, frameY: 4, frameW: 16, frameH: 16 }] },
  215. ]);
  216. const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims);
  217. expect(commands[0]).toMatchObject({ frameX: 28, frameY: 4, frameW: 4, frameH: 16 });
  218. });
  219. it("keeps the D31 behavior when no frame fields are present", () => {
  220. const dims = new Map([["plant-1", { width: 16, height: 16 }]]);
  221. const spriteDef = sprite([{ layers: [{ shape: "image", src: "plant-1", w: 8, h: 8 }] }]);
  222. const commands = spriteDrawCommands(spriteDef, 0, entity(), TILE, dims);
  223. expect(commands[0]).toEqual({
  224. kind: "image",
  225. assetId: "plant-1",
  226. x: 2 * TILE,
  227. y: 3 * TILE,
  228. w: 8,
  229. h: 8,
  230. sourceW: 8,
  231. sourceH: 8,
  232. });
  233. expect(commands[0]).not.toHaveProperty("frameX");
  234. expect(commands[0]).not.toHaveProperty("frameW");
  235. });
  236. });