diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 67ed5361..20b3add0 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -65,6 +65,7 @@ export default defineConfig({ { text: 'useFBO', link: '/guide/abstractions/use-fbo' }, { text: 'useSurfaceSampler', link: '/guide/abstractions/use-surface-sampler' }, { text: 'Sampler', link: '/guide/abstractions/sampler' }, + { text: 'AnimatedSprite', link: '/guide/abstractions/animated-sprite' }, ], }, { diff --git a/docs/.vitepress/theme/components/AnimatedSpriteAnchorDemo.vue b/docs/.vitepress/theme/components/AnimatedSpriteAnchorDemo.vue new file mode 100644 index 00000000..ca60d560 --- /dev/null +++ b/docs/.vitepress/theme/components/AnimatedSpriteAnchorDemo.vue @@ -0,0 +1,86 @@ + + + diff --git a/docs/.vitepress/theme/components/AnimatedSpriteDemo.vue b/docs/.vitepress/theme/components/AnimatedSpriteDemo.vue new file mode 100644 index 00000000..2c895e64 --- /dev/null +++ b/docs/.vitepress/theme/components/AnimatedSpriteDemo.vue @@ -0,0 +1,18 @@ + + + diff --git a/docs/.vitepress/theme/components/AnimatedSpriteNoAtlasDemo.vue b/docs/.vitepress/theme/components/AnimatedSpriteNoAtlasDemo.vue new file mode 100644 index 00000000..ae5ce344 --- /dev/null +++ b/docs/.vitepress/theme/components/AnimatedSpriteNoAtlasDemo.vue @@ -0,0 +1,18 @@ + + + diff --git a/docs/guide/abstractions/animated-sprite.md b/docs/guide/abstractions/animated-sprite.md new file mode 100644 index 00000000..f64f3dc1 --- /dev/null +++ b/docs/guide/abstractions/animated-sprite.md @@ -0,0 +1,131 @@ +# AnimatedSprite + + + + + +`` allows you to use 2D animations defined in a [texture atlas](https://en.wikipedia.org/wiki/Texture_atlas). A typical `` will use: + +* an image containing multiple sprites +* a JSON atlas containing the coordinates of the image + +## Usage + +<<< @/.vitepress/theme/components/AnimatedSpriteDemo.vue{3,11-16} + +::: warning Suspense +`` loads resources asynchronously, so it must be wrapped in a ``. +::: + +## Props + + + +## `animation` + +The `animation` prop holds either the name of the currently playing animation or a range of frames to play, or a frame number to display. + +### Named animations + +Frames are automatically grouped into named animations, if you use either of the following naming conventions for your source images: + +* `[key][frame number].[file_extension]` +* `[key]_[frame number].[file_extension]` + +The `` will automatically make all [key] available for playback as named animations. + +#### Example + +Here are some example source image names, to be compiled into an image texture and atlas. + +* heroIdle00.png +* heroIdle01.png +* heroIdle02.png +* heroRun00.png +* heroRun01.png +* heroRun02.png +* heroRun03.png +* heroHeal00.png +* heroHeal01.png + +When the resulting image texture and atlas are provided to ``, "heroIdle", "heroRun", and "heroHeal" will be available as named animations. Animation names can be used as follows: + +```vue{3} + +``` + +### Ranges + +A `[number, number]` range can be supplied as the `animation` prop. The numbers correspond to the position of the frame in the `atlas` `frames` array, starting with `0`. The first `number` in the range represents the start frame of the animation. The last `number` represents the end frame. + +### Single frame + +To display a single animation frame, a `number` can be supplied as the `animation` prop. The `number` corresponds to the position of the frame in the `atlas` `frames` array, starting with `0`. + +## `anchor` + +The `anchor` allow you to control how differently sized source images "grow" and "shrink". Namely, they "grow out from" and "shrink towards" the anchor. `[0, 0]` places the anchor at the top left corner of the ``. `[1,1]` places the anchor at the bottom right corner. By default, the anchor is placed at `[0.5, 0.5]` i.e., the center. + +Below is a simple animation containing differently sized source images. The anchor is visible at world position `0, 0, 0`. + + + + +::: warning +Changing the anchor from the default can have unpredictable results if `asSprite` is `true`. +::: + +## `definitions` + +For each [named animation](#named-animations), you can supply a "definition" that specifies frame order and repeated frames (delays). For the [named animation example above](#named-animations), the `definitions` prop might look like this: + +```vue + +``` + +## Compiling an atlas + +In typical usage, `` requires both the URL to a texture of compiled sprite images and a JSON atlas containing information about the sprites in the texture. + +* [example compiled texture](https://raw.githubusercontent.com/Tresjs/assets/6c0b087768a0a2b76148c99fc87d7e6ddc3c6d66/textures/animated-sprite/namedAnimationsTexture.png) +* [example JSON atlas](https://raw.githubusercontent.com/Tresjs/assets/6c0b087768a0a2b76148c99fc87d7e6ddc3c6d66/textures/animated-sprite/namedAnimationsAtlas.json) + +Compiling source images into a texture atlas is usually handled by third-party software. You may find [TexturePacker](https://www.codeandweb.com/texturepacker) useful. + +## Without an atlas + +There may be cases where you don't want to supply a generated JSON atlas as an `atlas` prop. This is possible if you compile your source images in a single row of equally sized columns *and* set the `atlas` prop to the number of columns. + +### Example + +This image is comprised of 16 source images, compiled into a single image, in a single row: + + + + + + + +<<< @/.vitepress/theme/components/AnimatedSpriteNoAtlasDemo.vue{12,13} \ No newline at end of file diff --git a/playground/src/pages/abstractions/AnimatedSprite.vue b/playground/src/pages/abstractions/AnimatedSprite.vue new file mode 100644 index 00000000..b67a9c74 --- /dev/null +++ b/playground/src/pages/abstractions/AnimatedSprite.vue @@ -0,0 +1,154 @@ + + + diff --git a/playground/src/router/routes/abstractions.ts b/playground/src/router/routes/abstractions.ts index d880700c..5e900b5e 100644 --- a/playground/src/router/routes/abstractions.ts +++ b/playground/src/router/routes/abstractions.ts @@ -49,4 +49,9 @@ export const abstractionsRoutes = [ name: 'Sampler', component: () => import('../../pages/abstractions/Sampler.vue'), }, + { + path: '/abstractions/animated-sprite', + name: 'AnimatedSprite', + component: () => import('../../pages/abstractions/AnimatedSprite.vue'), + }, ] diff --git a/src/core/abstractions/AnimatedSprite/Atlas.ts b/src/core/abstractions/AnimatedSprite/Atlas.ts new file mode 100644 index 00000000..1b122d6c --- /dev/null +++ b/src/core/abstractions/AnimatedSprite/Atlas.ts @@ -0,0 +1,271 @@ +import type { Texture } from "three"; +import { TextureLoader } from "three"; +import { useLoader, useLogger } from "@tresjs/core"; +import { getNumbersFromEnd, stripUnderscoresNumbersFromEnd } from "./StringOps"; +import { expand } from "./AtlasAnimationDefinitionParser"; + +export interface AtlasFrame { + name: string; + width: number; + height: number; + offsetX: number; + offsetY: number; + repeatX: number; + repeatY: number; +} + +export interface AtlasPage { + frames: AtlasFrame[]; + namedFrames: Record; + texture: Texture; +} + +export async function getAtlasPageAsync( + atlas: string | number | string[] | TexturePackerFrameDataArray | TexturePackerFrameDataObject, + image: string, + definitions?: Record +): Promise { + const texturePromise = useLoader(TextureLoader, image); + const atlasPromise = + typeof atlas === "string" + ? fetch(atlas) + .then((response) => response.json()) + .catch((e) => useLogger().logError("Cientos Atlas - " + e)) + : new Promise((resolve) => resolve(atlas)); + + const pagePromise = Promise.all([atlasPromise, texturePromise]).then( + (response) => { + const texture: Texture = response[1]; + const processingFn = (() => { + if (typeof atlas === "string" || atlas.hasOwnProperty("frames")) { + return framesFromTexturePackerData; + } else if (typeof atlas === "number") { + return framesFromNumColsWidthHeight; + } else { + return framesFromAnimationNamesWidthHeight; + } + })(); + const frames = processingFn( + response[0], + texture.image.width, + texture.image.height + ); + const namedFrames = groupFramesByKey(frames); + texture.matrixAutoUpdate = false; + const page: AtlasPage = { frames, namedFrames, texture }; + if (definitions) { + setDefinitions(page, definitions); + } + return page; + } + ); + + return pagePromise; +} + +export type TexturePackerFrameData = { + filename: string; + frame: { x: number; y: number; w: number; h: number }; +}; + +export type TexturePackerFrameDataArray = { + frames: TexturePackerFrameData[]; +}; + +export type TexturePackerFrameDataObject = { + frames: Record; +}; + +function framesFromTexturePackerData( + data: TexturePackerFrameDataArray | TexturePackerFrameDataObject, + width: number, + height: number +) { + return Array.isArray(data.frames) + ? framesFromTexturePackerDataArray( + data as TexturePackerFrameDataArray, + width, + height + ) + : framesFromTexturePackerDataObject( + data as TexturePackerFrameDataObject, + width, + height + ); +} + +function framesFromTexturePackerDataArray( + data: TexturePackerFrameDataArray, + width: number, + height: number +): AtlasFrame[] { + const invWidth = 1 / width; + const invHeight = 1 / height; + return data.frames.map((d) => ({ + name: d.filename, + offsetX: d.frame.x * invWidth, + offsetY: 1 - (d.frame.y + d.frame.h) * invHeight, + repeatX: d.frame.w * invWidth, + repeatY: d.frame.h * invHeight, + width: d.frame.w, + height: d.frame.h, + })); +} + +function framesFromTexturePackerDataObject( + data: TexturePackerFrameDataObject, + width: number, + height: number +): AtlasFrame[] { + const invWidth = 1 / width; + const invHeight = 1 / height; + return Object.entries(data.frames).map(([k, v]) => ({ + name: k, + offsetX: v.frame.x * invWidth, + offsetY: 1 - (v.frame.y + v.frame.h) * invHeight, + repeatX: v.frame.w * invWidth, + repeatY: v.frame.h * invHeight, + width: v.frame.w, + height: v.frame.h, + })); +} + +function framesFromNumColsWidthHeight( + numCols: number, + width: number, + height: number, + name = "default" +): AtlasFrame[] { + const frameWidth = width / numCols; + const invWidth = 1 / width; + const padAmount = numCols.toString().length; + return new Array(numCols).fill(0).map((_, i) => ({ + name: name + String(i).padStart(padAmount, "0"), + offsetX: i * frameWidth * invWidth, + offsetY: 0, + repeatX: 1 / numCols, + repeatY: 1, + width: width / numCols, + height, + })); +} + +function framesFromAnimationNamesWidthHeight( + animationNames: string[], + width: number, + height: number +): AtlasFrame[] { + const numCols = animationNames.length; + const frames = framesFromNumColsWidthHeight(numCols, width, height); + const padAmount = numCols.toString().length; + animationNames.forEach((name, i) => { + frames[i].name = name + "_" + String(i).padStart(padAmount, "0"); + }); + return frames; +} + +function setDefinitions(page: AtlasPage, definitions: Record) { + for (const [animationName, definitionStr] of Object.entries(definitions)) { + const frames: AtlasFrame[] = getFrames(page, animationName); + const expanded = expand(definitionStr); + for (const i of expanded) { + if (i < 0 || frames.length <= i) { + useLogger().logError( + `Cientos Atlas: Attempting to access frame index ${i} in animation ${animationName}, but it does not exist.` + ); + } + } + page.namedFrames[animationName] = expanded.map((i) => frames[i]); + } +} + +export function getFrames( + page: AtlasPage, + animationNameOrFrameNumber: string | number | [number, number] +): AtlasFrame[] { + if (typeof animationNameOrFrameNumber === "string") + return getFramesByName(page, animationNameOrFrameNumber); + else if (typeof animationNameOrFrameNumber === "number") + return getFramesByIndices( + page, + animationNameOrFrameNumber, + animationNameOrFrameNumber + ); + else { + return getFramesByIndices( + page, + animationNameOrFrameNumber[0], + animationNameOrFrameNumber[1] + ); + } +} + +function getFramesByName(page: AtlasPage, name: string): AtlasFrame[] { + if (!(name in page.namedFrames)) { + useLogger().logError( + `Cientos Atlas: getFramesByName – name ${name} does not exist in page. Available names: ${Object.keys( + page + )}` + ); + } + return page.namedFrames[name]; +} + +function getFramesByIndices( + page: AtlasPage, + startI: number, + endI: number +): AtlasFrame[] { + if ( + startI < 0 || + page.frames.length <= startI || + endI < 0 || + page.frames.length <= endI + ) { + useLogger().logError( + `Cientos Atlas: getFramesByIndex – [${startI}, ${endI}] is out of bounds.` + ); + } + const result = []; + const sign = Math.sign(endI - startI); + if (sign === 0) return [page.frames[startI]]; + for (let i = startI; i !== endI + sign; i += sign) { + result.push(page.frames[i]); + } + return result; +} + +/** + * @returns An object where all AtlasFrames with the same key are grouped in an ordered array by name in ascending value. + * A key is defined as an alphanumeric string preceding a trailing numeric string. + * E.g.: + * "hero0Idle" has no key as it does not have trailing numeric string. + * "heroIdle0" has the key "heroIdle". + * @example ``` + * groupFramesByKey([{name: hero, ...}, {name: heroJump3, ...}, {name: heroJump0, ...}, {name: heroIdle0, ...}, {name: heroIdle1, ...}]) returns + * { + * heroJump: [{name: heroJump0, ...}, {name: heroJump3, ...}], + * heroIdle: [{name: heroIdle0, ...}, {name: heroIdle1, ...}] + * } + * ``` + */ +function groupFramesByKey(frames: AtlasFrame[]): Record { + const result: Record = {}; + + for (const frame of frames) { + if (getNumbersFromEnd(frame.name) !== null) { + const key = stripUnderscoresNumbersFromEnd(frame.name); + if (result.hasOwnProperty(key)) { + result[key].push(frame); + } else { + result[key] = [frame]; + } + } + } + + for (const entry of Object.values(result)) { + entry.sort((a, b) => a.name.localeCompare(b.name)); + } + + return result; +} diff --git a/src/core/abstractions/AnimatedSprite/AtlasAnimationDefinitionParser.ts b/src/core/abstractions/AnimatedSprite/AtlasAnimationDefinitionParser.ts new file mode 100644 index 00000000..f6d499bf --- /dev/null +++ b/src/core/abstractions/AnimatedSprite/AtlasAnimationDefinitionParser.ts @@ -0,0 +1,218 @@ +import { useLogger } from "@tresjs/core"; + +/** + * Expand an animation definition string into an array of numbers. + * @param definitionStr - A comma-separated string of frame numbers with optional parentheses-surrounded durations. + * @example - expand("0,2") === [0,2] + * @example - expand("2(10)") === [2,2,2,2,2,2,2,2,2,2] + * @example - expand("1-4") === [1,2,3,4] + * @example - expand("10-5(2)") === [10,10,9,9,8,8,7,7,6,6,5,5] + * @example - expand("1-4(3),10(2)") === [1,1,1,2,2,2,3,3,3,4,4,4,10,10] + */ + +export function expand(definitionStr: string) : number[] { + const parsed = parse(definitionStr) + const expanded: number[] = []; + for (const info of parsed) { + if (info.duration <= 0) { + } else if (info.endFrame < 0 || info.startFrame === info.endFrame) { + for (let _ = 0; _ < info.duration; _++) { + expanded.push(info.startFrame); + } + continue + } else { + const sign = Math.sign(info.endFrame - info.startFrame); + for ( + let frame = info.startFrame; + frame !== info.endFrame + sign; + frame += sign + ) { + for (let _ = 0; _ < info.duration; _++) { + expanded.push(frame); + } + } + } + } + return expanded +} + +type AnimationDefinition = { + startFrame: number, + endFrame: number, + duration: number +} + +/** + * Parse an animation defintion string into an array of AnimationDefinition. + * @param definitionStr - A comma-separated string of frame numbers with optional parentheses-surrounded durations. + * @example - parse("0,2") === [{startFrame:0, endFrame:0, delay:1}, {startFrame:2, endFrame:2, delay:1}] + * @example - parse("2(10)") === [{startFrame:2, endFrame:2, delay:10}] + * @example - parse("1-4") === [{startFrame:1, endFrame:4, delay:1}] + * @example - parse("10-5(2)") === [{startFrame:10, endFrame:5, delay:2}] + * @example - parse("1-4(3),10(2)") === [{startFrame:1, endFrame:4, delay:3}, {startFrame:10, endFrame:10, delay:2}] + */ + +export function parse(definitionStr: string) : AnimationDefinition[] { + let transition: Transition = "START_FRAME_IN"; + const parsed: AnimationDefinition[] = []; + for (const token of tokenize(definitionStr)) { + if (transition === "START_FRAME_IN") { + if (token.name === "NUMBER") { + parsed.push({ + startFrame: token.value, + endFrame: token.value, + duration: 1, + }); + transition = "START_FRAME_OUT"; + } else { + warnDefinitionSyntaxError( + "number", + token.name, + definitionStr, + token.startI + ); + } + } else if (transition === "START_FRAME_OUT") { + if (token.name === "COMMA") { + transition = "START_FRAME_IN"; + } else if (token.name === "HYPHEN") { + transition = "END_FRAME_IN"; + } else if (token.name === "OPEN_PAREN") { + transition = "DURATION_IN"; + } else { + warnDefinitionSyntaxError( + '",", "-", "("', + token.name, + definitionStr, + token.startI + ); + } + } else if (transition === "END_FRAME_IN") { + if (token.name === "NUMBER") { + parsed[parsed.length - 1].endFrame = token.value; + transition = "END_FRAME_OUT"; + } else { + warnDefinitionSyntaxError( + "number", + token.name, + definitionStr, + token.startI + ); + } + } else if (transition === "END_FRAME_OUT") { + if (token.name === "COMMA") { + transition = "START_FRAME_IN"; + } else if (token.name === "OPEN_PAREN") { + transition = "DURATION_IN"; + } else { + warnDefinitionSyntaxError( + "',' or '('", + token.name, + definitionStr, + token.startI + ); + } + } else if (transition === "DURATION_IN") { + if (token.name === "NUMBER") { + parsed[parsed.length - 1].duration = token.value; + transition = "DURATION_OUT"; + } else { + warnDefinitionSyntaxError( + "number", + token.name, + definitionStr, + token.startI + ); + } + } else if (transition === "DURATION_OUT") { + if (token.name === "CLOSE_PAREN") { + transition = "NEXT_OR_DONE"; + } else { + warnDefinitionSyntaxError('"("', token.name, definitionStr, token.startI); + } + } else if (transition === "NEXT_OR_DONE") { + if (token.name === "COMMA") { + transition = "START_FRAME_IN"; + } else { + warnDefinitionSyntaxError('","', token.name, definitionStr, token.startI); + } + } + } + + return parsed; +} + +type Transition = + | "START_FRAME_IN" + | "START_FRAME_OUT" + | "END_FRAME_IN" + | "END_FRAME_OUT" + | "DURATION_IN" + | "DURATION_OUT" + | "NEXT_OR_DONE"; + +type TokenName = "COMMA" | "HYPHEN" | "OPEN_PAREN" | "CLOSE_PAREN" | "NUMBER"; +interface Token { + name: TokenName; + value: number; + startI: number; +} + +function tokenize(definition: string): Token[] { + const tokenized: Token[] = []; + let ii = 0; + while (ii < definition.length) { + const c = definition[ii]; + if ("0123456789".indexOf(c) > -1) { + if ( + tokenized.length && + tokenized[tokenized.length - 1].name === "NUMBER" + ) { + tokenized[tokenized.length - 1].value *= 10; + tokenized[tokenized.length - 1].value += parseInt(c); + } else { + tokenized.push({ name: "NUMBER", value: parseInt(c), startI: ii }); + } + } else if (c === " ") { + } else if (c === ",") { + tokenized.push({ name: "COMMA", value: -1, startI: ii }); + } else if (c === "(") { + tokenized.push({ name: "OPEN_PAREN", value: -1, startI: ii }); + } else if (c === ")") { + tokenized.push({ name: "CLOSE_PAREN", value: -1, startI: ii }); + } else if (c === "-") { + tokenized.push({ name: "HYPHEN", value: -1, startI: ii }); + } else { + warnDefinitionBadCharacter("0123456789,-()", c, definition, ii); + } + ii++; + } + + return tokenized; +} + +function warnDefinitionBadCharacter( + expected: string, + found: string, + definition: string, + index: number +) { + useLogger().logError( + `Cientos AnimationDefinitionParser: Unexpected character while processing animation definition: expected ${expected}, got ${found}. +${definition} +${Array(index + 1).join(" ")}^` + ); +} + +function warnDefinitionSyntaxError( + expected: string, + found: string, + definition: string, + index: number +) { + useLogger().logError( + `Cientos AnimationDefinitionParser: Syntax error while processing animation definition: expected ${expected}, got ${found}. +${definition} +${Array(index + 1).join(" ")}^` + ); +} diff --git a/src/core/abstractions/AnimatedSprite/StringOps.ts b/src/core/abstractions/AnimatedSprite/StringOps.ts new file mode 100644 index 00000000..46d63d65 --- /dev/null +++ b/src/core/abstractions/AnimatedSprite/StringOps.ts @@ -0,0 +1,14 @@ +const numbersAtEnd = /[0-9]*$/; +const underscoresNumbersAtEnd = /_*[0-9]*$/; + +export function stripUnderscoresNumbersFromEnd(str: string) { + return str.replace(underscoresNumbersAtEnd, ""); +} + +export function getNumbersFromEnd(str: string) { + const matches = str.match(numbersAtEnd); + if (matches) { + return parseInt(matches[matches.length - 1]); + } + return null; +} \ No newline at end of file diff --git a/src/core/abstractions/AnimatedSprite/component.vue b/src/core/abstractions/AnimatedSprite/component.vue new file mode 100644 index 00000000..9f783782 --- /dev/null +++ b/src/core/abstractions/AnimatedSprite/component.vue @@ -0,0 +1,222 @@ + + + diff --git a/src/core/abstractions/index.ts b/src/core/abstractions/index.ts index 7fea5242..3baa545c 100644 --- a/src/core/abstractions/index.ts +++ b/src/core/abstractions/index.ts @@ -7,6 +7,7 @@ import { GlobalAudio } from './GlobalAudio' import Lensflare from './Lensflare/component.vue' import Fbo from './useFBO/component.vue' import Sampler from './useSurfaceSampler/component.vue' +import AnimatedSprite from './AnimatedSprite/component.vue' export * from './useFBO/' export * from './useSurfaceSampler' @@ -21,4 +22,5 @@ export { GlobalAudio, Fbo, Sampler, + AnimatedSprite, }