Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .cspell/project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ commitlint
dbaeumer
deadzone
deregistering
desaturated
desaturates
despawn
devcontainers
Expand Down Expand Up @@ -64,6 +65,8 @@ prestart
quilez
rasterizer
readback
readbacks
recentering
refac
reinhard
Renderable
Expand Down
45 changes: 32 additions & 13 deletions documentation-site/docs/docs/animations/sprite-animations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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.
4 changes: 4 additions & 0 deletions documentation-site/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ const config: Config = {
to: 'demos/ecs',
label: 'ECS',
},
{
to: 'demos/sprite-animation',
label: 'Sprite Animation',
},
{
to: 'demos/physics',
label: 'Physics',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Game> => {
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<AnimationClip>();

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;
};
Original file line number Diff line number Diff line change
@@ -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 };
}
134 changes: 134 additions & 0 deletions documentation-site/src/pages/demos/sprite-animation/_create-player.ts
Original file line number Diff line number Diff line change
@@ -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<AnimationClip>,
): Promise<Player> {
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,
};
}
Loading
Loading