From d36a82b68419d9fd3c406a97c239b79bb64e44ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:17:12 +0000 Subject: [PATCH] test(e2e): add real-browser suite for sprite animation + keyboard-driven movement Adds a character-animation scene/spec exercising the sprite animation system end-to-end: the adventurer sprite sheet sliced into idle/run AnimationClips, keyboard-driven left/right movement via an Axis1dAction, clip switching, and sprite flipping, asserting against both ECS state and actual rendered/centroid-tracked canvas pixels. Also adds a matching documentation-site demo showcasing the same pattern, and fixes two documentation gaps found while building both: an outdated createImageSprite() call signature in the Sprite Animations guide, and a missing step (manually setting SpriteEcsComponent.uvScale from the sprite sheet's frame dimensions) that the guide's example omitted entirely. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XAiYXVHjozy4yBLTLgTKfo --- .cspell/project-words.txt | 3 + .../docs/docs/animations/sprite-animations.md | 45 +- documentation-site/docusaurus.config.ts | 4 + .../demos/sprite-animation/_create-game.ts | 51 ++ .../demos/sprite-animation/_create-inputs.ts | 42 ++ .../demos/sprite-animation/_create-player.ts | 134 ++++++ .../sprite-animation/_movement.system.ts | 82 ++++ .../pages/demos/sprite-animation/index.tsx | 55 +++ e2e/fixtures/adventurer.png | Bin 0 -> 3647 bytes e2e/fixtures/scenes/character-animation.ts | 434 ++++++++++++++++++ e2e/specs/character-animation.spec.ts | 269 +++++++++++ 11 files changed, 1106 insertions(+), 13 deletions(-) create mode 100644 documentation-site/src/pages/demos/sprite-animation/_create-game.ts create mode 100644 documentation-site/src/pages/demos/sprite-animation/_create-inputs.ts create mode 100644 documentation-site/src/pages/demos/sprite-animation/_create-player.ts create mode 100644 documentation-site/src/pages/demos/sprite-animation/_movement.system.ts create mode 100644 documentation-site/src/pages/demos/sprite-animation/index.tsx create mode 100644 e2e/fixtures/adventurer.png create mode 100644 e2e/fixtures/scenes/character-animation.ts create mode 100644 e2e/specs/character-animation.spec.ts diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index 716ea3e5..b73932b0 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -15,6 +15,7 @@ commitlint dbaeumer deadzone deregistering +desaturated desaturates despawn devcontainers @@ -64,6 +65,8 @@ prestart quilez rasterizer readback +readbacks +recentering refac reinhard Renderable diff --git a/documentation-site/docs/docs/animations/sprite-animations.md b/documentation-site/docs/docs/animations/sprite-animations.md index ea4aa262..d2050a54 100644 --- a/documentation-site/docs/docs/animations/sprite-animations.md +++ b/documentation-site/docs/docs/animations/sprite-animations.md @@ -90,20 +90,24 @@ const spriteEntity = world.createEntity(); // 2. load the sprite sheet const image = await imageCache.getOrLoad('character_sprite_sheet_32_32.png'); -// 3. create a sprite and add it to the entity -addSpriteComponent( - world, - spriteEntity, - createImageSprite( - image, - renderContext, - 1, // 3.1 render layer - new Vector2(32, 32), // 3.2 define the dimensions of a frame - ), -); +// 3. create a sprite sheet +const spriteSheet = createSpriteSheet(image, 2, 5); // 3.1 define the rows and columns (2x5 = 10 frames in total) + +// 4. create a sprite and add it to the entity +const sprite = createImageSprite(image, renderContext, 1, { + // 4.1 render layer is the 3rd argument; frame dimensions and other + // per-sprite options are passed in this options object + frameDimensions: new Vector2(32, 32), // 4.2 define the dimensions of a frame +}); -// 4. create a sprite sheet -const spriteSheet = createSpriteSheet(image, 2, 5); // 4.1 define the rows and columns (2x5 = 10 frames in total) +// 4.3 `frameDimensions` above only sizes the sprite's on-screen quad; +// `createImageSprite` always leaves `uvScale` at its (1, 1) default (the +// whole texture), so it must be set from the sheet's own per-frame UV size, +// or every frame will sample (and squash) the entire sheet instead of a +// single frame. +sprite.uvScale = spriteSheet.frames[0][0].dimensions.clone(); + +addSpriteComponent(world, spriteEntity, sprite); // 5. create an animation clip const idleAnimation = new AnimationClip( @@ -137,6 +141,15 @@ clip ends. ## Notes and troubleshooting +- [`createImageSprite`](/Forge/docs/api/functions/createImageSprite)'s + `frameDimensions` option only sizes the sprite's on-screen quad; it never + sets [`SpriteEcsComponent.uvScale`](/Forge/docs/api/interfaces/SpriteEcsComponent), + which always defaults to `(1, 1)` (the whole texture). For a sprite sheet, + set `uvScale` yourself from the sheet's own per-frame UV size (any frame + works, since every frame in a `SpriteSheet` is the same size): + `sprite.uvScale = spriteSheet.frames[0][0].dimensions.clone();`. Without + this, every frame samples (and squashes) the entire sheet into the + sprite's quad instead of a single frame. - `frameDurationMilliseconds` and `playbackSpeed` must both be greater than `0`. If `frameDurationMilliseconds / playbackSpeed` is `0` or negative, the system throws rather than dividing by zero or running the animation @@ -153,3 +166,9 @@ clip ends. `time.timeInSeconds` is already greater than `0`). If you need the first frame to hold for a full `frameDurationMilliseconds`, initialize it to `time.timeInSeconds` instead. + +See the [Sprite Animation demo](/Forge/demos/sprite-animation) for a full, +runnable example that ties this together with keyboard input: switching +between an idle clip and a run clip as the character moves, and flipping the +sprite with a [`FlipEcsComponent`](/Forge/docs/api/interfaces/FlipEcsComponent) +to face its direction of travel. diff --git a/documentation-site/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index d18ab1e8..8983f28c 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -116,6 +116,10 @@ const config: Config = { to: 'demos/ecs', label: 'ECS', }, + { + to: 'demos/sprite-animation', + label: 'Sprite Animation', + }, { to: 'demos/physics', label: 'Physics', diff --git a/documentation-site/src/pages/demos/sprite-animation/_create-game.ts b/documentation-site/src/pages/demos/sprite-animation/_create-game.ts new file mode 100644 index 00000000..61a2b8b8 --- /dev/null +++ b/documentation-site/src/pages/demos/sprite-animation/_create-game.ts @@ -0,0 +1,51 @@ +import { + createCamera, + createRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { + AnimationClip, + createSpriteAnimationEcsSystem, +} from '@forge-game-engine/forge/animations'; +import { AssetRegistry } from '@forge-game-engine/forge/asset-loading'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { createInputs } from './_create-inputs'; +import { createPlayer } from './_create-player'; +import { createMovementEcsSystem } from './_movement.system'; + +const renderLayers = { + foreground: 1 << 0, +}; + +export const createSpriteAnimationGame = async (): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + const { moveInput } = createInputs(world, time); + + const animationRegistry = new AssetRegistry(); + + const player = await createPlayer( + world, + renderContext, + renderLayers.foreground, + animationRegistry, + ); + + world.addSystem( + createMovementEcsSystem( + moveInput, + time, + player.idleAnimationHandle, + player.runAnimationHandle, + ), + ); + world.addSystem(createSpriteAnimationEcsSystem(time, animationRegistry)); + world.addSystem(createRenderEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/sprite-animation/_create-inputs.ts b/documentation-site/src/pages/demos/sprite-animation/_create-inputs.ts new file mode 100644 index 00000000..36b66478 --- /dev/null +++ b/documentation-site/src/pages/demos/sprite-animation/_create-inputs.ts @@ -0,0 +1,42 @@ +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { Time } from '@forge-game-engine/forge/common'; +import { + actionResetTypes, + Axis1dAction, + KeyboardAxis1dBinding, + KeyboardInputSource, + keyCodes, + registerInputs, +} from '@forge-game-engine/forge/input'; + +export function createInputs( + world: EcsWorld, + time: Time, +): { + moveInput: Axis1dAction; +} { + // `noReset`, since the axis is driven by discrete keydown/keyup edges (see + // KeyboardAxis1dBinding), not re-read every tick - the default `zero` + // reset would zero it out again the instant after each keydown. + const moveInput = new Axis1dAction('move', null, actionResetTypes.noReset); + + const inputManager = registerInputs(world, time, { + axis1dActions: [moveInput], + }); + + const keyboardInputSource = new KeyboardInputSource(inputManager); + + keyboardInputSource.axis1dBindings.add( + new KeyboardAxis1dBinding(moveInput, keyCodes.d, keyCodes.a), + ); + + keyboardInputSource.axis1dBindings.add( + new KeyboardAxis1dBinding( + moveInput, + keyCodes.arrowRight, + keyCodes.arrowLeft, + ), + ); + + return { moveInput }; +} diff --git a/documentation-site/src/pages/demos/sprite-animation/_create-player.ts b/documentation-site/src/pages/demos/sprite-animation/_create-player.ts new file mode 100644 index 00000000..cad2fdba --- /dev/null +++ b/documentation-site/src/pages/demos/sprite-animation/_create-player.ts @@ -0,0 +1,134 @@ +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { + addFlipComponent, + addPositionComponent, + FlipEcsComponent, + PositionEcsComponent, +} from '@forge-game-engine/forge/common'; +import { Vector2 } from '@forge-game-engine/forge/math'; +import { + addSpriteComponent, + createImageSprite, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + addSpriteAnimationComponent, + AnimationClip, + createSpriteSheet, + selectAnimationFrames, + SpriteAnimationEcsComponent, +} from '@forge-game-engine/forge/animations'; +import { AssetRegistry } from '@forge-game-engine/forge/asset-loading'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +// `adventurer_spritesheet.png` is a 416x256 sheet of 32x32 frames: 13 +// columns x 8 rows. Row 0 is an idle/breathing loop (13 frames); row 1 is a +// run cycle (8 frames). +const spriteSheetColumns = 13; +const spriteSheetRows = 8; +const frameSizeInPixels = 32; +const idleFrameCount = 13; +const runFrameCount = 8; +const runRowStartFrameIndex = spriteSheetColumns; +const frameDurationMilliseconds = 90; + +// Scaled well past the sheet's native 32x32 so the character is legible +// against the demo's fixed-size viewport. +export const playerDisplaySize = frameSizeInPixels * 5; + +export interface Player { + /** The character entity's position - written directly by the movement system. */ + position: PositionEcsComponent; + /** Mirrors the character horizontally when moving left. */ + flip: FlipEcsComponent; + /** The character's active sprite animation clip and frame. */ + spriteAnimation: SpriteAnimationEcsComponent; + /** The idle clip's handle in `animationRegistry`. */ + idleAnimationHandle: number; + /** The run clip's handle in `animationRegistry`. */ + runAnimationHandle: number; +} + +/** + * Loads the adventurer sprite sheet, slices it into an idle clip (row 0) and + * a run clip (row 1), registers both in `animationRegistry`, and creates the + * character entity starting in the idle clip - see the Sprite Animations + * guide for the full walkthrough this demo follows. + * @param world - The ECS world to add the character entity to. + * @param renderContext - The render context used to load the sprite sheet. + * @param renderLayer - The render layer the character should be drawn on. + * @param animationRegistry - The registry to register the idle/run clips in. + */ +export async function createPlayer( + world: EcsWorld, + renderContext: RenderContext, + renderLayer: number, + animationRegistry: AssetRegistry, +): Promise { + const characterImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/adventurer_spritesheet.png'), + ); + + const characterSprite = createImageSprite( + characterImage, + renderContext, + renderLayer, + { + frameDimensions: new Vector2(frameSizeInPixels, frameSizeInPixels), + pixelated: true, + }, + ); + + const spriteSheet = createSpriteSheet( + characterImage, + spriteSheetRows, + spriteSheetColumns, + ); + + // `frameDimensions` above only sizes the sprite's on-screen quad; + // `createImageSprite` always leaves `uvScale` at its (1, 1) default (the + // whole texture), so it's set here from the sheet's own per-frame UV + // size, or every frame would sample (and squash) the entire sheet instead + // of a single 32x32 cell - see the Sprite Animations guide. + characterSprite.uvScale = spriteSheet.frames[0][0].dimensions.clone(); + + const idleAnimationHandle = animationRegistry.register( + 'idle', + new AnimationClip(selectAnimationFrames(spriteSheet, idleFrameCount, 0)), + ); + + const runAnimationHandle = animationRegistry.register( + 'run', + new AnimationClip( + selectAnimationFrames(spriteSheet, runFrameCount, runRowStartFrameIndex), + ), + ); + + const entity = world.createEntity(); + + const position = addPositionComponent(world, entity, { + local: new Vector2(0, 0), + world: new Vector2(0, 0), + }); + + addSpriteComponent(world, entity, { + ...characterSprite, + width: playerDisplaySize, + height: playerDisplaySize, + }); + + const flip = addFlipComponent(world, entity); + + const spriteAnimation = addSpriteAnimationComponent(world, entity, { + animationClipHandle: idleAnimationHandle, + frameDurationMilliseconds, + }); + + return { + position, + flip, + spriteAnimation, + idleAnimationHandle, + runAnimationHandle, + }; +} diff --git a/documentation-site/src/pages/demos/sprite-animation/_movement.system.ts b/documentation-site/src/pages/demos/sprite-animation/_movement.system.ts new file mode 100644 index 00000000..deabbb32 --- /dev/null +++ b/documentation-site/src/pages/demos/sprite-animation/_movement.system.ts @@ -0,0 +1,82 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { Axis1dAction } from '@forge-game-engine/forge/input'; +import { + FlipEcsComponent, + flipId, + PositionEcsComponent, + positionId, + Time, +} from '@forge-game-engine/forge/common'; +import { + SpriteAnimationEcsComponent, + spriteAnimationId, +} from '@forge-game-engine/forge/animations'; + +/** How fast the character moves horizontally, in world units per second. */ +const moveSpeedInWorldUnitsPerSecond = 220; + +/** Keeps the character from wandering off the edges of the demo's viewport. */ +const horizontalBoundInWorldUnits = 340; + +/** + * Creates an ECS system that moves the character with `moveInput`, switches + * its `SpriteAnimationEcsComponent` between the idle and run clips to match, + * and flips it via `FlipEcsComponent` to face its direction of travel - see + * the Sprite Animations guide for the full walkthrough this demo follows. + * + * The query (`positionId`/`flipId`/`spriteAnimationId` together) matches + * only the character entity in this demo, since the camera has a position + * but no flip or sprite animation component. + * + * Writes `position.world` directly (rather than only `position.local`) + * since this demo doesn't register `createTransformEcsSystem` - the same + * convention the other demos follow (see the rolling-ball demo's + * `_camera-follow.system.ts`). + * @param moveInput - The horizontal movement axis, positive for rightward. + * @param time - The time instance used to scale movement by delta time. + * @param idleAnimationHandle - The idle clip's `AssetRegistry` handle. + * @param runAnimationHandle - The run clip's `AssetRegistry` handle. + */ +export const createMovementEcsSystem = ( + moveInput: Axis1dAction, + time: Time, + idleAnimationHandle: number, + runAnimationHandle: number, +): EcsSystem< + [PositionEcsComponent, FlipEcsComponent, SpriteAnimationEcsComponent] +> => ({ + query: [positionId, flipId, spriteAnimationId], + run: (result) => { + const [position, flip, spriteAnimation] = result.components; + const isMoving = moveInput.value !== 0; + + if (isMoving) { + const nextX = + position.world.x + + moveInput.value * + moveSpeedInWorldUnitsPerSecond * + time.deltaTimeInSeconds; + + position.world.x = Math.max( + -horizontalBoundInWorldUnits, + Math.min(horizontalBoundInWorldUnits, nextX), + ); + position.local.x = position.world.x; + + flip.flipX = moveInput.value < 0; + } + + const desiredAnimationHandle = isMoving + ? runAnimationHandle + : idleAnimationHandle; + + if (spriteAnimation.animationClipHandle !== desiredAnimationHandle) { + spriteAnimation.animationClipHandle = desiredAnimationHandle; + // The idle and run clips have different frame counts; carrying the + // old animationFrameIndex across a clip switch can index past the new + // clip's frame count - see the Sprite Animations guide's + // troubleshooting note - so it's reset on every switch. + spriteAnimation.animationFrameIndex = 0; + } + }, +}); diff --git a/documentation-site/src/pages/demos/sprite-animation/index.tsx b/documentation-site/src/pages/demos/sprite-animation/index.tsx new file mode 100644 index 00000000..f837cafa --- /dev/null +++ b/documentation-site/src/pages/demos/sprite-animation/index.tsx @@ -0,0 +1,55 @@ +import React, { JSX } from 'react'; +import { createSpriteAnimationGame } from './_create-game'; +import gameCode from '!!raw-loader!./_create-game'; +import createPlayerCode from '!!raw-loader!./_create-player'; +import createInputsCode from '!!raw-loader!./_create-inputs'; +import movementSystemCode from '!!raw-loader!./_movement.system'; + +import { Demo } from '@site/src/components/Demo'; +import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; +import { KeyboardKey } from '@site/src/components/_KeyboardKey'; + +export default function SpriteAnimation(): JSX.Element { + return ( + + } + text="Move left" + /> + } + text="Move right" + /> + + } + codeFiles={[ + { + name: 'game.ts', + content: gameCode, + }, + { + name: 'create-player.ts', + content: createPlayerCode, + }, + { + name: 'create-inputs.ts', + content: createInputsCode, + }, + { + name: 'movement.system.ts', + content: movementSystemCode, + }, + ]} + /> + ); +} diff --git a/e2e/fixtures/adventurer.png b/e2e/fixtures/adventurer.png new file mode 100644 index 0000000000000000000000000000000000000000..f2081bc511478f9fd62a3f0e25c86dd10103c173 GIT binary patch literal 3647 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 E3nJRyZ~y=R literal 0 HcmV?d00001 diff --git a/e2e/fixtures/scenes/character-animation.ts b/e2e/fixtures/scenes/character-animation.ts new file mode 100644 index 00000000..bb88f726 --- /dev/null +++ b/e2e/fixtures/scenes/character-animation.ts @@ -0,0 +1,434 @@ +import { + actionResetTypes, + addFlipComponent, + addPositionComponent, + addSpriteAnimationComponent, + addSpriteComponent, + AnimationClip, + AssetRegistry, + Axis1dAction, + Color, + createCamera, + createCanvas, + createImageSprite, + createPresentEcsSystem, + createRenderContext, + createRenderEcsSystem, + createSpriteAnimationEcsSystem, + createSpriteSheet, + createTransformEcsSystem, + EcsSystem, + EcsWorld, + KeyboardAxis1dBinding, + KeyboardInputSource, + keyCodes, + PositionEcsComponent, + positionId, + registerInputs, + selectAnimationFrames, + Time, + Vector2, +} from '../../../src/index.js'; +import { CreateScene, SceneHandle } from './scene.js'; + +const defaultStepDeltaMilliseconds = 16.6666; +const moveSpeedInWorldUnitsPerSecond = 150; + +// `adventurer.png` (see AGENTS.md's "Adding a new scenario" - a copy lives +// under `e2e/fixtures/` rather than referencing `/demo`'s or +// `/documentation-site`'s asset folders, keeping `/e2e` dependent only on +// `/src`) is a 416x256 sheet of 32x32 frames: 13 columns x 8 rows. Row 0 is +// an idle/breathing loop (13 frames); row 1 is a run cycle (8 frames). +const spriteSheetColumns = 13; +const spriteSheetRows = 8; +const frameSizeInPixels = 32; +const idleFrameCount = 13; +const runFrameCount = 8; +const runRowStartFrameIndex = spriteSheetColumns; +const frameDurationMilliseconds = 90; + +// Scaled well past the sheet's native 32x32 so the character is actually +// legible in the recorded video (playwright.config.ts's `video: 'on'`). +const characterDisplaySize = frameSizeInPixels * 6; + +// A dark, desaturated clear color, chosen to sit far (in RGB distance) from +// every color in the adventurer sprite (skin, hair, blue jacket, maroon +// scarf) so `isCharacterPixel` below can reliably tell character pixels +// apart from background with a single cheap distance check. +const clearColor = new Color(0.08, 0.08, 0.12, 1); +const backgroundRgb = { r: 20, g: 20, b: 31 }; +const backgroundDistanceThreshold = 60; + +/** Matches any rendered pixel that isn't (close to) the scene's clear color. */ +function isCharacterPixel(r: number, g: number, b: number): boolean { + const distance = + Math.abs(r - backgroundRgb.r) + + Math.abs(g - backgroundRgb.g) + + Math.abs(b - backgroundRgb.b); + + return distance > backgroundDistanceThreshold; +} + +/** + * Scans `canvas`'s actual displayed bitmap for pixels matching + * `isCharacterPixel` and returns the mean x-coordinate ("center of mass") of + * every match, or `null` if none are found. Deliberately not the midpoint of + * the leftmost/rightmost match (`scanPixelBounds`'s `left`/`right`): the run + * clip's swinging limbs change the sprite's silhouette *extent* frame to + * frame independent of the character's actual translation, which makes that + * midpoint noisy enough to occasionally miss a real, several-pixel shift. + * Averaging over every matched pixel instead weights it by the character's + * mostly-static torso/head mass, which tracks translation far more reliably + * than its momentarily-extended fingertips/sword tip. + */ +function measureCentroidX( + canvas: HTMLCanvasElement, + isMatch: (r: number, g: number, b: number) => boolean, +): number | null { + const sampleCanvas = document.createElement('canvas'); + + sampleCanvas.width = canvas.width; + sampleCanvas.height = canvas.height; + + const context2d = sampleCanvas.getContext('2d'); + + if (!context2d) { + throw new Error('2D canvas context not available'); + } + + context2d.drawImage(canvas, 0, 0); + + const { data } = context2d.getImageData(0, 0, canvas.width, canvas.height); + + let sumX = 0; + let count = 0; + + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + const offset = (y * canvas.width + x) * 4; + + if (isMatch(data[offset], data[offset + 1], data[offset + 2])) { + sumX += x; + count++; + } + } + } + + return count === 0 ? null : sumX / count; +} + +const pixelChangeTolerancePerChannel = 20; + +/** + * Counts pixels that differ by more than `pixelChangeTolerancePerChannel` in + * at least one channel between two same-sized RGBA pixel buffers. + */ +function countChangedPixels( + before: Uint8ClampedArray, + after: Uint8ClampedArray, +): number { + let changed = 0; + + for (let i = 0; i < before.length; i += 4) { + const rDelta = Math.abs(before[i] - after[i]); + const gDelta = Math.abs(before[i + 1] - after[i + 1]); + const bDelta = Math.abs(before[i + 2] - after[i + 2]); + + if ( + rDelta > pixelChangeTolerancePerChannel || + gDelta > pixelChangeTolerancePerChannel || + bDelta > pixelChangeTolerancePerChannel + ) { + changed++; + } + } + + return changed; +} + +// Wide enough to comfortably contain the character's full display size +// (192px) even with a several-pixel measurement margin. +const patchHalfWidthInPixels = characterDisplaySize / 2 + 20; + +/** + * Reads back a fixed-size, full-height vertical strip of `canvas`'s actual + * displayed bitmap, horizontally centered on `centerX` (clamped so the strip + * never runs off-canvas). Centering on the character's own current position + * - rather than reading a fixed screen rectangle - is what lets + * `countChangedPixelsSinceSnapshot` isolate genuine pose/frame changes (the + * run clip's limbs moving) from the pixel churn that simple translation + * alone would otherwise dominate the comparison with: a fixed-rectangle + * read of a sprite that's just sliding sideways would itself show large, + * unrelated pixel differences having nothing to do with which animation + * frame is showing. + */ +function readCenteredPatch( + canvas: HTMLCanvasElement, + centerX: number, +): Uint8ClampedArray { + const patchWidth = patchHalfWidthInPixels * 2; + const left = Math.min( + Math.max(Math.round(centerX - patchHalfWidthInPixels), 0), + canvas.width - patchWidth, + ); + + const sampleCanvas = document.createElement('canvas'); + + sampleCanvas.width = canvas.width; + sampleCanvas.height = canvas.height; + + const context2d = sampleCanvas.getContext('2d'); + + if (!context2d) { + throw new Error('2D canvas context not available'); + } + + context2d.drawImage(canvas, 0, 0); + + return context2d.getImageData(left, 0, patchWidth, canvas.height).data; +} + +/** The handle `character-animation.spec.ts` drives and asserts against. */ +export interface CharacterAnimationSceneHandle extends SceneHandle { + /** The character entity's local x position, driven by `moveAction`. */ + readonly playerLocalX: number; + /** `FlipEcsComponent.flipX` on the character - mirrors when moving left. */ + readonly isFlippedX: boolean; + /** Whether the run clip (rather than idle) is the active animation clip. */ + readonly isRunClipActive: boolean; + /** The active clip's current `animationFrameIndex`. */ + readonly animationFrameIndex: number; + + /** + * The character's on-screen center of mass (mean x of every non-background + * pixel), or `null` if it isn't visible - see `measureCentroidX` for why + * this is used over a bounding box's midpoint to track translation. Must + * be called in the same `page.evaluate` task as the preceding `step()`. + */ + measureCharacterCentroidX(): number | null; + + /** + * Stores a snapshot of a fixed-size strip of the canvas, centered on the + * character's current `measureCharacterCentroidX`, for a later + * `countChangedPixelsSinceSnapshot` call. Throws if the character isn't + * currently visible. Must be called in the same `page.evaluate` task as + * the preceding `step()`. + */ + captureCanvasSnapshot(): void; + + /** + * Re-measures the character's centroid, reads the same-size strip + * centered on its *current* position, and compares it against the last + * `captureCanvasSnapshot` (or `countChangedPixelsSinceSnapshot`) call - + * see `readCenteredPatch` for why re-centering on each read matters. Returns + * how many pixels changed by more than a per-channel tolerance, and + * re-snapshots for the next call. Must be called in the same + * `page.evaluate` task as the preceding `step()`. + */ + countChangedPixelsSinceSnapshot(): number; +} + +/** + * Builds a scene with a single keyboard-controlled character, driven by an + * `Axis1dAction` bound to the arrow keys and A/D: holding left or right + * moves the character and switches its `SpriteAnimationEcsComponent` from + * the idle clip to the run clip (resetting `animationFrameIndex` to 0 per + * sprite-animations.md's troubleshooting note, since the two clips have + * different frame counts), and flips it horizontally via `FlipEcsComponent` + * to face its direction of travel. Releasing every movement key returns it + * to the idle clip. + * @param container - The element to render the scene's canvas into. + * @returns The scene's handle. + */ +export const createScene: CreateScene = async ( + container: HTMLElement, +): Promise => { + const time = new Time(); + const world = new EcsWorld(); + const canvas = createCanvas(container); + const renderContext = createRenderContext(canvas, { + preserveDrawingBuffer: true, + }); + + const moveAction = new Axis1dAction('move', 'game', actionResetTypes.noReset); + + const inputManager = registerInputs(world, time, { + axis1dActions: [moveAction], + }); + + const keyboardInputSource = new KeyboardInputSource(inputManager); + + keyboardInputSource.axis1dBindings.add( + new KeyboardAxis1dBinding( + moveAction, + keyCodes.arrowRight, + keyCodes.arrowLeft, + ), + ); + + keyboardInputSource.axis1dBindings.add( + new KeyboardAxis1dBinding(moveAction, keyCodes.d, keyCodes.a), + ); + + createCamera(world, { + isStatic: true, + clearColor, + // 1 world unit == 1 screen pixel, matching the other input/camera + // scenes' convention. + verticalWorldUnits: canvas.height, + }); + + const characterImage = + await renderContext.imageCache.getOrLoad('/adventurer.png'); + + const characterSprite = createImageSprite(characterImage, renderContext, 1, { + frameDimensions: new Vector2(frameSizeInPixels, frameSizeInPixels), + pixelated: true, + }); + + const spriteSheet = createSpriteSheet( + characterImage, + spriteSheetRows, + spriteSheetColumns, + ); + + // `frameDimensions` above only sizes the sprite's on-screen quad; + // `createImageSprite` always leaves `uvScale` at its (1, 1) default (the + // whole texture), so it's set here from the sheet's own per-frame UV + // size, or every frame would sample (and squash) the entire sheet instead + // of a single 32x32 cell. + characterSprite.uvScale = spriteSheet.frames[0][0].dimensions.clone(); + + const idleClip = new AnimationClip( + selectAnimationFrames(spriteSheet, idleFrameCount, 0), + ); + const runClip = new AnimationClip( + selectAnimationFrames(spriteSheet, runFrameCount, runRowStartFrameIndex), + ); + + const animationRegistry = new AssetRegistry(); + const idleAnimationHandle = animationRegistry.register('idle', idleClip); + const runAnimationHandle = animationRegistry.register('run', runClip); + + const characterEntity = world.createEntity(); + + const position = addPositionComponent(world, characterEntity, { + local: new Vector2(0, 0), + world: new Vector2(0, 0), + }); + + addSpriteComponent(world, characterEntity, { + ...characterSprite, + width: characterDisplaySize, + height: characterDisplaySize, + }); + + const flip = addFlipComponent(world, characterEntity); + + const spriteAnimation = addSpriteAnimationComponent(world, characterEntity, { + animationClipHandle: idleAnimationHandle, + frameDurationMilliseconds, + }); + + // Anchored on the character entity (query: [positionId] also matches the + // camera, which this system ignores) so it runs exactly once per tick - + // the same single-system pattern `keyboard-input.ts` uses for its + // input-consuming logic. + const movementSystem: EcsSystem<[PositionEcsComponent]> = { + query: [positionId], + run: (result) => { + if (result.entity !== characterEntity) { + return; + } + + const deltaSeconds = time.deltaTimeInMilliseconds / 1000; + + position.local.x += + moveAction.value * moveSpeedInWorldUnitsPerSecond * deltaSeconds; + + const isMoving = moveAction.value !== 0; + const desiredClipHandle = isMoving + ? runAnimationHandle + : idleAnimationHandle; + + if (spriteAnimation.animationClipHandle !== desiredClipHandle) { + spriteAnimation.animationClipHandle = desiredClipHandle; + // The idle and run clips have different frame counts; carrying the + // old animationFrameIndex across a clip switch can index past the + // new clip's frame count (see sprite-animations.md's + // troubleshooting note), so it's reset on every switch. + spriteAnimation.animationFrameIndex = 0; + } + + if (isMoving) { + flip.flipX = moveAction.value < 0; + } + }, + }; + + world.addSystem(movementSystem); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createSpriteAnimationEcsSystem(time, animationRegistry)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + let clockInMilliseconds = 0; + let lastPatchSnapshot: Uint8ClampedArray | null = null; + + function captureCenteredPatch(): Uint8ClampedArray { + const centerX = measureCentroidX(canvas, isCharacterPixel); + + if (centerX === null) { + throw new Error('Character is not currently visible on the canvas.'); + } + + return readCenteredPatch(canvas, centerX); + } + + return { + step(deltaMilliseconds: number = defaultStepDeltaMilliseconds): void { + clockInMilliseconds += deltaMilliseconds; + time.update(clockInMilliseconds); + world.update(); + }, + + get playerLocalX(): number { + return position.local.x; + }, + + get isFlippedX(): boolean { + return flip.flipX; + }, + + get isRunClipActive(): boolean { + return spriteAnimation.animationClipHandle === runAnimationHandle; + }, + + get animationFrameIndex(): number { + return spriteAnimation.animationFrameIndex; + }, + + measureCharacterCentroidX(): number | null { + return measureCentroidX(canvas, isCharacterPixel); + }, + + captureCanvasSnapshot(): void { + lastPatchSnapshot = captureCenteredPatch(); + }, + + countChangedPixelsSinceSnapshot(): number { + if (!lastPatchSnapshot) { + throw new Error( + 'captureCanvasSnapshot must be called before countChangedPixelsSinceSnapshot.', + ); + } + + const current = captureCenteredPatch(); + const changed = countChangedPixels(lastPatchSnapshot, current); + + lastPatchSnapshot = current; + + return changed; + }, + }; +}; diff --git a/e2e/specs/character-animation.spec.ts b/e2e/specs/character-animation.spec.ts new file mode 100644 index 00000000..abbf2b87 --- /dev/null +++ b/e2e/specs/character-animation.spec.ts @@ -0,0 +1,269 @@ +import { expect, test } from '@playwright/test'; +import type { CharacterAnimationSceneHandle } from '../fixtures/scenes/character-animation.js'; + +// `window.__forgeTestHooks` is declared globally (as the base `SceneHandle`) +// by `harness.ts`. Each `page.evaluate` callback below narrows it to this +// spec's own scene handle type inline - see camera-pan-zoom.spec.ts's `Hooks` +// comment for why. +type Hooks = CharacterAnimationSceneHandle; +type Page = import('@playwright/test').Page; + +const captureState = (page: Page) => + page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + scene.step(); + + return { + playerLocalX: scene.playerLocalX, + isFlippedX: scene.isFlippedX, + isRunClipActive: scene.isRunClipActive, + animationFrameIndex: scene.animationFrameIndex, + centroidX: scene.measureCharacterCentroidX(), + }; + }); + +const step = (page: Page) => + page.evaluate(() => (window.__forgeTestHooks as unknown as Hooks).step()); + +// Spreads a change over several real-time-spaced frames so it's actually +// watchable in the recorded video (playwright.config.ts's `video: 'on'`), +// following camera-pan-zoom.spec.ts's `animateFrames` pattern. +const frameSpacingMilliseconds = 60; + +const animateFrames = async (page: Page, frameCount: number): Promise => { + for (let frame = 0; frame < frameCount; frame++) { + // eslint-disable-next-line no-await-in-loop + await step(page); + // eslint-disable-next-line no-await-in-loop, sonarjs/no-fixed-wait-in-tests + await page.waitForTimeout(frameSpacingMilliseconds); + } +}; + +// Long enough for several `frameDurationMilliseconds` (90ms, scene-side) to +// elapse given the scene's fixed ~16.67ms-per-step virtual clock, so the +// active clip's `animationFrameIndex` has a chance to actually advance. +const framesPerHold = 12; + +test.describe('character animation', () => { + test.beforeEach(async ({ page }) => { + await test.step('load the character-animation scene', async () => { + let pageError: Error | undefined; + + page.once('pageerror', (error) => { + pageError = error; + }); + + await page.goto('/?scene=character-animation'); + + try { + await page.waitForFunction(() => Boolean(window.__forgeTestHooks)); + } catch (timeoutError) { + throw pageError ?? timeoutError; + } + }); + }); + + test('idles in place, with no run clip active, until a movement key is pressed', async ({ + page, + }) => { + const before = await test.step('capture the starting state', () => + captureState(page)); + + expect(before.isRunClipActive).toBe(false); + expect(before.centroidX).not.toBeNull(); + + await test.step('advance several frames with no key held', () => + animateFrames(page, framesPerHold)); + + const after = await test.step('capture the state after idling', () => + captureState(page)); + + expect(after.playerLocalX).toBe(before.playerLocalX); + expect(after.isRunClipActive).toBe(false); + }); + + test('holding ArrowRight moves the character right, activates the run clip, and does not flip it', async ({ + page, + }) => { + const earlyHeld = + await test.step('press ArrowRight and let the run pose settle', async () => { + await page.keyboard.down('ArrowRight'); + + // One frame is enough for `movementSystem` to switch to the run clip + // and flip state; the comparison below only cares about movement + // *within* that settled run pose, not the one-time idle-to-run switch + // itself (which - since idle and run are visually distinct poses - + // would otherwise swamp a naive before/after pixel comparison with + // pose-change noise unrelated to translation). + await animateFrames(page, 1); + + return captureState(page); + }); + + expect(earlyHeld.isRunClipActive).toBe(true); + expect(earlyHeld.isFlippedX).toBe(false); + + await test.step('keep holding ArrowRight over several more frames', () => + animateFrames(page, framesPerHold)); + + const lateHeld = + await test.step('capture the state after holding longer', () => + captureState(page)); + + await test.step('release ArrowRight', () => page.keyboard.up('ArrowRight')); + + expect(lateHeld.playerLocalX).toBeGreaterThan(earlyHeld.playerLocalX); + expect(lateHeld.isRunClipActive).toBe(true); + expect(lateHeld.isFlippedX).toBe(false); + + // The rendered proof: the character's on-screen center of mass actually + // moved right, not just its ECS position. + expect(earlyHeld.centroidX).not.toBeNull(); + expect(lateHeld.centroidX).not.toBeNull(); + expect(lateHeld.centroidX!).toBeGreaterThan(earlyHeld.centroidX!); + }); + + test('holding ArrowLeft moves the character left, activates the run clip, and flips it', async ({ + page, + }) => { + const earlyHeld = + await test.step('press ArrowLeft and let the run pose settle', async () => { + await page.keyboard.down('ArrowLeft'); + await animateFrames(page, 1); + + return captureState(page); + }); + + expect(earlyHeld.isRunClipActive).toBe(true); + expect(earlyHeld.isFlippedX).toBe(true); + + await test.step('keep holding ArrowLeft over several more frames', () => + animateFrames(page, framesPerHold)); + + const lateHeld = + await test.step('capture the state after holding longer', () => + captureState(page)); + + await test.step('release ArrowLeft', () => page.keyboard.up('ArrowLeft')); + + expect(lateHeld.playerLocalX).toBeLessThan(earlyHeld.playerLocalX); + expect(lateHeld.isRunClipActive).toBe(true); + expect(lateHeld.isFlippedX).toBe(true); + + // The rendered proof: the character's on-screen center of mass actually + // moved left, not just its ECS position. + expect(earlyHeld.centroidX).not.toBeNull(); + expect(lateHeld.centroidX).not.toBeNull(); + expect(lateHeld.centroidX!).toBeLessThan(earlyHeld.centroidX!); + }); + + test('releasing every movement key returns the character to the idle clip and it stops moving', async ({ + page, + }) => { + await test.step('hold ArrowRight, then release it', async () => { + await page.keyboard.down('ArrowRight'); + await animateFrames(page, framesPerHold); + await page.keyboard.up('ArrowRight'); + // One frame for `movementSystem` to observe the axis back at 0 and + // switch the clip back to idle. + await animateFrames(page, 1); + }); + + const afterRelease = + await test.step('capture the state right after release', () => + captureState(page)); + + expect(afterRelease.isRunClipActive).toBe(false); + + await test.step('advance several more frames', () => + animateFrames(page, framesPerHold)); + + const later = + await test.step('capture the state several frames later', () => + captureState(page)); + + expect(later.playerLocalX).toBe(afterRelease.playerLocalX); + expect(later.isRunClipActive).toBe(false); + }); + + test('the run clip visibly changes the rendered character at each animation frame advance', async ({ + page, + }) => { + await test.step('hold ArrowRight and let the run clip take over', async () => { + await page.keyboard.down('ArrowRight'); + await animateFrames(page, framesPerHold); + }); + + // Everything from here runs inside one `page.evaluate` call: each + // `step()` is immediately followed by a canvas readback (see + // `SceneHandle.step`'s and `captureCanvasSnapshot`'s docs for why a + // canvas readback must share a task with the step that produced the + // frame it's reading), and comparing readbacks across separate + // `page.evaluate` round-trips would add unrelated timing variance to a + // measurement that's already comparing single-digit-millisecond frames + // against each other. + const samples = + await test.step('sample per-step pixel change around a frame-index advance', () => + page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + scene.step(); + scene.captureCanvasSnapshot(); + + let previousFrameIndex = scene.animationFrameIndex; + const results: { frameChanged: boolean; changedPixels: number }[] = + []; + + // `frameDurationMilliseconds` (90ms, scene-side) against the + // scene's fixed ~16.67ms-per-step virtual clock means the clip + // advances roughly every 5-6 steps; 8 is comfortably enough to + // observe one advance. + const maxSamples = 8; + + for (let i = 0; i < maxSamples; i++) { + scene.step(); + + const changedPixels = scene.countChangedPixelsSinceSnapshot(); + const frameChanged = + scene.animationFrameIndex !== previousFrameIndex; + + results.push({ frameChanged, changedPixels }); + previousFrameIndex = scene.animationFrameIndex; + + if (frameChanged) { + break; + } + } + + return results; + })); + + await page.keyboard.up('ArrowRight'); + + const changedSamples = samples.filter((sample) => sample.frameChanged); + const unchangedSamples = samples.filter((sample) => !sample.frameChanged); + + // The clip must have actually advanced within the sampling window, + // and there must be at least one steady-state (pose-unchanged) sample + // to compare it against. + expect(changedSamples.length).toBeGreaterThan(0); + expect(unchangedSamples.length).toBeGreaterThan(0); + + const maxSteadyStateDiff = Math.max( + ...unchangedSamples.map((sample) => sample.changedPixels), + ); + const transitionDiff = changedSamples[0].changedPixels; + + // A generous multiple, not a tight bound: `countChangedPixelsSinceSnapshot` + // re-centers on the character every read (see `readCenteredPatch`), so + // steady-state samples already have translation mostly factored out and + // only show a small residual (recentering/antialiasing) delta. The step + // where `animationFrameIndex` actually advances swaps in a + // pose-different frame (limbs in a different position), which should + // dwarf that residual - this is what ties the rendered pixels directly + // to the animation system's own frame-advance logic, rather than to + // translation alone. + expect(transitionDiff).toBeGreaterThan(maxSteadyStateDiff * 1.5); + }); +});