tween.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. import { describe, expect, it } from "vitest";
  2. import type { AnimationDef, Entity, SpriteDef } from "@nengine/core";
  3. import { frameAtTimeOrHold, spriteDurationMs } from "../src/sprite-draw.js";
  4. import {
  5. MOVE_DURATION_MS,
  6. animState,
  7. animationState,
  8. easeOutQuad,
  9. motionState,
  10. resolveAnimationState,
  11. tweenPosition,
  12. tweenTile,
  13. } from "../src/tween.js";
  14. const TILE = 32;
  15. function entity(overrides: Partial<Entity> = {}): Entity {
  16. return { id: "e", type: "player", x: 2, y: 3, ...overrides };
  17. }
  18. const PLAYER_ANIM: AnimationDef = {
  19. id: "player",
  20. initial: "idle",
  21. states: [
  22. { id: "idle", spriteRef: "player", loop: true },
  23. { id: "walk", spriteRef: "player-walk", fps: 8, loop: true },
  24. { id: "dance", spriteRef: "player-dance", loop: false },
  25. ],
  26. };
  27. describe("tweenPosition", () => {
  28. it("returns the start tile at elapsed 0", () => {
  29. const pos = tweenPosition({ x: 1, y: 2 }, { x: 4, y: 6 }, 0, MOVE_DURATION_MS, TILE);
  30. expect(pos.xPx).toBe(1 * TILE);
  31. expect(pos.yPx).toBe(2 * TILE);
  32. });
  33. it("returns the end tile at elapsed >= duration (settled)", () => {
  34. const pos = tweenPosition({ x: 1, y: 2 }, { x: 4, y: 6 }, MOVE_DURATION_MS, MOVE_DURATION_MS, TILE);
  35. expect(pos.xPx).toBe(4 * TILE);
  36. expect(pos.yPx).toBe(6 * TILE);
  37. });
  38. it("interpolates at 50% elapsed with easeOutQuad", () => {
  39. const t = 0.5;
  40. const expected = easeOutQuad(t); // 1 - 0.25 = 0.75
  41. const pos = tweenPosition({ x: 1, y: 2 }, { x: 3, y: 6 }, MOVE_DURATION_MS / 2, MOVE_DURATION_MS, TILE);
  42. expect(pos.xPx).toBe((1 + (3 - 1) * expected) * TILE);
  43. expect(pos.yPx).toBe((2 + (6 - 2) * expected) * TILE);
  44. });
  45. it("clamps elapsed beyond the window and handles zero duration", () => {
  46. expect(tweenPosition({ x: 0, y: 0 }, { x: 2, y: 2 }, 10_000, MOVE_DURATION_MS, TILE)).toEqual({ xPx: 2 * TILE, yPx: 2 * TILE });
  47. expect(tweenPosition({ x: 0, y: 0 }, { x: 2, y: 2 }, 100, 0, TILE)).toEqual({ xPx: 2 * TILE, yPx: 2 * TILE });
  48. });
  49. it("tweenTile returns fractional tile coords the viewport feeds to sprite drawing", () => {
  50. const atHalf = tweenTile({ x: 1, y: 1 }, { x: 5, y: 1 }, MOVE_DURATION_MS / 2, MOVE_DURATION_MS);
  51. expect(atHalf.x).toBe(1 + 4 * easeOutQuad(0.5));
  52. expect(atHalf.y).toBe(1);
  53. });
  54. });
  55. describe("motionState", () => {
  56. it("walk while moving across tiles, idle when stationary", () => {
  57. expect(motionState({ x: 1, y: 1 }, { x: 2, y: 1 }, true)).toBe("walk");
  58. expect(motionState({ x: 1, y: 1 }, { x: 1, y: 1 }, true)).toBe("idle");
  59. expect(motionState({ x: 1, y: 1 }, { x: 2, y: 1 }, false)).toBe("idle");
  60. expect(motionState({ x: 1, y: 1 }, { x: 1, y: 2 }, true)).toBe("walk");
  61. });
  62. });
  63. describe("animState", () => {
  64. it("the explicit anim field wins when non-empty", () => {
  65. expect(animState(entity({ anim: "dance" }), 2, 3, "idle")).toBe("dance");
  66. expect(animState(entity({ anim: "walk" }), 2, 3, "idle")).toBe("walk");
  67. });
  68. it("falls back to walk on a coordinate change from prev", () => {
  69. expect(animState(entity({ x: 3, y: 3 }), 2, 3, "idle")).toBe("walk");
  70. });
  71. it("falls back to the passed motion inference when stationary", () => {
  72. expect(animState(entity({ x: 2, y: 3 }), 2, 3, "idle")).toBe("idle");
  73. expect(animState(entity({ x: 2, y: 3 }), 2, 3, "walk")).toBe("walk");
  74. });
  75. it("a null anim field (set-state clear) is not an explicit override", () => {
  76. expect(animState(entity({ anim: null }), 2, 3, "idle")).toBe("idle");
  77. expect(animState(entity({ anim: null, x: 4, y: 3 }), 2, 3, "idle")).toBe("walk");
  78. });
  79. });
  80. describe("resolveAnimationState / animationState", () => {
  81. it("declared state names pass through; unknown names fall back to initial", () => {
  82. expect(resolveAnimationState(PLAYER_ANIM, "walk")).toBe("walk");
  83. expect(resolveAnimationState(PLAYER_ANIM, "dance")).toBe("dance");
  84. expect(resolveAnimationState(PLAYER_ANIM, "jump")).toBe("idle");
  85. expect(resolveAnimationState(PLAYER_ANIM, "")).toBe("idle");
  86. });
  87. it("looks up declared states by id", () => {
  88. expect(animationState(PLAYER_ANIM, "walk")).toEqual({ id: "walk", spriteRef: "player-walk", fps: 8, loop: true });
  89. expect(animationState(PLAYER_ANIM, "nope")).toBeUndefined();
  90. });
  91. });
  92. describe("frameAtTimeOrHold (state fps/loop)", () => {
  93. function sprite(frames: SpriteDef["frames"]): SpriteDef {
  94. return { id: "test", frames };
  95. }
  96. const twoFrame = sprite([
  97. { durationTicks: 1, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] },
  98. { durationTicks: 1, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#ffffff" }] },
  99. ]);
  100. it("cycles frames when looping at the state fps", () => {
  101. // cycle = 2/8 s = 250ms; frame 0 first 125ms
  102. expect(frameAtTimeOrHold(twoFrame, 0, 8, true)).toBe(0);
  103. expect(frameAtTimeOrHold(twoFrame, 100, 8, true)).toBe(0);
  104. expect(frameAtTimeOrHold(twoFrame, 200, 8, true)).toBe(1);
  105. expect(frameAtTimeOrHold(twoFrame, 300, 8, true)).toBe(0);
  106. });
  107. it("holds the last frame after one cycle when not looping", () => {
  108. expect(frameAtTimeOrHold(twoFrame, 100, 8, false)).toBe(0);
  109. expect(frameAtTimeOrHold(twoFrame, 200, 8, false)).toBe(1);
  110. expect(frameAtTimeOrHold(twoFrame, 10_000, 8, false)).toBe(1);
  111. });
  112. it("matches spriteDurationMs for the cycle length", () => {
  113. expect(spriteDurationMs(twoFrame, 8)).toBe(250);
  114. });
  115. });