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
+
+ {
+ if (fieldName === 'description') {
+ let type = getFieldFormatted('type')
+ if (type.indexOf('TSFunctionType') !== -1 && propName.startsWith('on')) {
+ type = '(frameName:string) => void
'
+ }
+ return type + ' – ' + valueFormatted
+ }
+}"
+ />
+
+## `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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
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,
}