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 00000000..f2081bc5 Binary files /dev/null and b/e2e/fixtures/adventurer.png differ 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); + }); +});