import { describe, expect, it } from "vitest"; import type { AnimationDef, Entity, SpriteDef } from "@nengine/core"; import { frameAtTimeOrHold, spriteDurationMs } from "../src/sprite-draw.js"; import { MOVE_DURATION_MS, animState, animationState, easeOutQuad, motionState, resolveAnimationState, tweenPosition, tweenTile, } from "../src/tween.js"; const TILE = 32; function entity(overrides: Partial = {}): Entity { return { id: "e", type: "player", x: 2, y: 3, ...overrides }; } const PLAYER_ANIM: AnimationDef = { id: "player", initial: "idle", states: [ { id: "idle", spriteRef: "player", loop: true }, { id: "walk", spriteRef: "player-walk", fps: 8, loop: true }, { id: "dance", spriteRef: "player-dance", loop: false }, ], }; describe("tweenPosition", () => { it("returns the start tile at elapsed 0", () => { const pos = tweenPosition({ x: 1, y: 2 }, { x: 4, y: 6 }, 0, MOVE_DURATION_MS, TILE); expect(pos.xPx).toBe(1 * TILE); expect(pos.yPx).toBe(2 * TILE); }); it("returns the end tile at elapsed >= duration (settled)", () => { const pos = tweenPosition({ x: 1, y: 2 }, { x: 4, y: 6 }, MOVE_DURATION_MS, MOVE_DURATION_MS, TILE); expect(pos.xPx).toBe(4 * TILE); expect(pos.yPx).toBe(6 * TILE); }); it("interpolates at 50% elapsed with easeOutQuad", () => { const t = 0.5; const expected = easeOutQuad(t); // 1 - 0.25 = 0.75 const pos = tweenPosition({ x: 1, y: 2 }, { x: 3, y: 6 }, MOVE_DURATION_MS / 2, MOVE_DURATION_MS, TILE); expect(pos.xPx).toBe((1 + (3 - 1) * expected) * TILE); expect(pos.yPx).toBe((2 + (6 - 2) * expected) * TILE); }); it("clamps elapsed beyond the window and handles zero duration", () => { expect(tweenPosition({ x: 0, y: 0 }, { x: 2, y: 2 }, 10_000, MOVE_DURATION_MS, TILE)).toEqual({ xPx: 2 * TILE, yPx: 2 * TILE }); expect(tweenPosition({ x: 0, y: 0 }, { x: 2, y: 2 }, 100, 0, TILE)).toEqual({ xPx: 2 * TILE, yPx: 2 * TILE }); }); it("tweenTile returns fractional tile coords the viewport feeds to sprite drawing", () => { const atHalf = tweenTile({ x: 1, y: 1 }, { x: 5, y: 1 }, MOVE_DURATION_MS / 2, MOVE_DURATION_MS); expect(atHalf.x).toBe(1 + 4 * easeOutQuad(0.5)); expect(atHalf.y).toBe(1); }); }); describe("motionState", () => { it("walk while moving across tiles, idle when stationary", () => { expect(motionState({ x: 1, y: 1 }, { x: 2, y: 1 }, true)).toBe("walk"); expect(motionState({ x: 1, y: 1 }, { x: 1, y: 1 }, true)).toBe("idle"); expect(motionState({ x: 1, y: 1 }, { x: 2, y: 1 }, false)).toBe("idle"); expect(motionState({ x: 1, y: 1 }, { x: 1, y: 2 }, true)).toBe("walk"); }); }); describe("animState", () => { it("the explicit anim field wins when non-empty", () => { expect(animState(entity({ anim: "dance" }), 2, 3, "idle")).toBe("dance"); expect(animState(entity({ anim: "walk" }), 2, 3, "idle")).toBe("walk"); }); it("falls back to walk on a coordinate change from prev", () => { expect(animState(entity({ x: 3, y: 3 }), 2, 3, "idle")).toBe("walk"); }); it("falls back to the passed motion inference when stationary", () => { expect(animState(entity({ x: 2, y: 3 }), 2, 3, "idle")).toBe("idle"); expect(animState(entity({ x: 2, y: 3 }), 2, 3, "walk")).toBe("walk"); }); it("a null anim field (set-state clear) is not an explicit override", () => { expect(animState(entity({ anim: null }), 2, 3, "idle")).toBe("idle"); expect(animState(entity({ anim: null, x: 4, y: 3 }), 2, 3, "idle")).toBe("walk"); }); }); describe("resolveAnimationState / animationState", () => { it("declared state names pass through; unknown names fall back to initial", () => { expect(resolveAnimationState(PLAYER_ANIM, "walk")).toBe("walk"); expect(resolveAnimationState(PLAYER_ANIM, "dance")).toBe("dance"); expect(resolveAnimationState(PLAYER_ANIM, "jump")).toBe("idle"); expect(resolveAnimationState(PLAYER_ANIM, "")).toBe("idle"); }); it("looks up declared states by id", () => { expect(animationState(PLAYER_ANIM, "walk")).toEqual({ id: "walk", spriteRef: "player-walk", fps: 8, loop: true }); expect(animationState(PLAYER_ANIM, "nope")).toBeUndefined(); }); }); describe("frameAtTimeOrHold (state fps/loop)", () => { function sprite(frames: SpriteDef["frames"]): SpriteDef { return { id: "test", frames }; } const twoFrame = sprite([ { durationTicks: 1, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#000000" }] }, { durationTicks: 1, layers: [{ shape: "rect", x: 0, y: 0, w: 1, h: 1, fill: "#ffffff" }] }, ]); it("cycles frames when looping at the state fps", () => { // cycle = 2/8 s = 250ms; frame 0 first 125ms expect(frameAtTimeOrHold(twoFrame, 0, 8, true)).toBe(0); expect(frameAtTimeOrHold(twoFrame, 100, 8, true)).toBe(0); expect(frameAtTimeOrHold(twoFrame, 200, 8, true)).toBe(1); expect(frameAtTimeOrHold(twoFrame, 300, 8, true)).toBe(0); }); it("holds the last frame after one cycle when not looping", () => { expect(frameAtTimeOrHold(twoFrame, 100, 8, false)).toBe(0); expect(frameAtTimeOrHold(twoFrame, 200, 8, false)).toBe(1); expect(frameAtTimeOrHold(twoFrame, 10_000, 8, false)).toBe(1); }); it("matches spriteDurationMs for the cycle length", () => { expect(spriteDurationMs(twoFrame, 8)).toBe(250); }); });