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