UNPKG

three-aseprite

Version:

Three.js mesh rendering for Aseprite JSON sprite sheets

1,002 lines (952 loc) 32.7 kB
import { BufferAttribute, BufferGeometry, Color, ColorRepresentation, EventDispatcher, EventListener, Mesh, RawShaderMaterial, Texture, Vector2, Vector4, } from "three"; import { AsepriteJSON, AsepriteJSONFrame, AsepriteJSONFrameTag, AsepriteJSONLayer, } from "./aseprite-export-types"; import { createAspriteShaderMaterial } from "./aseprite-shader-material"; export * from "./aseprite-export-types"; // Interface type for vectors passed to the sprite. export type IVector2 = { x: number; y: number; }; // Parameters provided to FrameNameSpecifier. export type FrameParams = { frame: number; layerName: string; }; // Frames are referenced by name, but there's a freeform // text field with formatting options in Aseprite that allows // a variety of formats. Therefore, we expect the user to provide // a function that maps FrameParams to the layer's filename. export type FrameNameSpecifier = (frameParams: FrameParams) => string; // We also support the inverse - resolving the layer and tag from the frame name. // This comes in handy for complex sprites with a high number of empty frames/layer // combinations. export type InvertedFrameNameSpecifier = ( frameName: string ) => Partial<FrameParams>; // Options passed to the constructor for ThreeAseprite. export type ThreeAsepriteOptions<LayerName> = { texture: Texture; sourceJSON: AsepriteJSON; frameName?: FrameNameSpecifier; frameNameToFrameParams?: InvertedFrameNameSpecifier; offset?: IVector2; layers?: LayerName[]; layerDepth?: number; }; // Internal bookkeeping for layer groups. type LayerGrouping = { [key: string]: string[]; }; // Empty frame definition for internal use. const emptyFrameDef: AsepriteJSONFrame = { frame: { x: 0, y: 0, w: 0, h: 0 }, rotated: false, trimmed: true, spriteSourceSize: { x: 0, y: 0, w: 0, h: 0 }, sourceSize: { w: 0, h: 0 }, duration: 100, }; export type LayerClipping = { xMin: number; xMax: number; yMin: number; yMax: number; }; type FrameInfo = { duration: number; layerFrames: Record<string, AsepriteJSONFrame>; }; type Trigger<EventNames> = { tagName: string | null; eventName: EventNames; }; export type FrameTriggerEvent<EventNames> = { type: EventNames; }; export const defaultLayerName = "Default"; export const defaultTagName = "Default"; export enum StandardEvents { animationComplete = "animationComplete", tagSwitched = "tagSwitched", } const ANIMATION_COMPLETE_EVENT = { type: StandardEvents.animationComplete, } as const; const TAG_SWITCHED_EVENT = { type: StandardEvents.tagSwitched, } as const; /** * Three.js aseprite sprite renderer class. */ export class ThreeAseprite< LayerNames extends string = string, EventNames extends string = string > extends EventDispatcher< Record< EventNames | StandardEvents, FrameTriggerEvent<EventNames | StandardEvents> > > { public mesh: Mesh; public texture: Texture; public playingAnimation: boolean = true; public playingAnimationBackwards: boolean = false; public readonly sourceJSON: AsepriteJSON; private frames: Record<number, FrameInfo> = {}; private tags: Record<string, AsepriteJSONFrameTag> = {}; private orderedLayers: string[]; private layerGroups: LayerGrouping = {}; private minFrame = 0; private maxFrame = 0; private currentFrame: number = 0; private currentFrameTime: number = 0; private currentTag: string | null = null; private currentTagFrame: number | null = null; private geometry: BufferGeometry; private material: RawShaderMaterial; private textureWidth: number; private textureHeight: number; private vtxIndex: Uint16Array; private vtxPos: Float32Array; private vtxUV: Float32Array; private vtxOpacity: Float32Array; private vtxColor: Float32Array; private vtxFade: Float32Array; private vtxOutlineSpread: Float32Array; private offset: Vector2 = new Vector2(); private options: ThreeAsepriteOptions<LayerNames>; private clipping?: LayerClipping; private outlineSpread?: number; private triggers: Record<number, Trigger<EventNames>[]> = {}; constructor(options: ThreeAsepriteOptions<LayerNames>) { super(); // Preserve options to allow easy cloning later. this.options = options; this.sourceJSON = options.sourceJSON; if (options.offset) { this.offset.x = options.offset.x; this.offset.y = options.offset.y; } // Pick a z offset per layer so that higher layers are above lower layers. const layerDepth = options.layerDepth ?? 0.05; // Assign texture, size. this.texture = options.texture; // We assume that textures are loaded through Three.TextureLoaded // and contain references to images. Video textures not supported. this.textureWidth = this.texture.image.naturalWidth; this.textureHeight = this.texture.image.naturalHeight; // Extract layers and groups from the sprite sheet definitions. if (options.layers) { this.orderedLayers = options.layers; } else if (options.sourceJSON.meta.layers?.length ?? 0 > 0) { this.orderedLayers = []; for (const layerInfo of options.sourceJSON.meta.layers ?? []) { if ((layerInfo as AsepriteJSONLayer).opacity !== undefined) { this.orderedLayers.push(layerInfo.name); } else { if (!this.layerGroups[layerInfo.name]) this.layerGroups[layerInfo.name] = []; } if (layerInfo.group !== undefined) { if (!this.layerGroups[layerInfo.group]) this.layerGroups[layerInfo.group] = []; this.layerGroups[layerInfo.group].push(layerInfo.name); } } } else { this.orderedLayers = [defaultLayerName]; } // If tags are available, populate tags and min/max frames. if (options.sourceJSON.meta.frameTags?.length) { this.currentTag = options.sourceJSON.meta.frameTags[0]?.name ?? null; for (const frameTag of options.sourceJSON.meta.frameTags) { this.tags[frameTag.name] = frameTag; if (this.minFrame === -1) { this.minFrame = frameTag.from; } else { this.minFrame = Math.min(this.minFrame, frameTag.from); } this.maxFrame = Math.max(this.maxFrame, frameTag.to); } } // Get the hash of frame filenames to frames. let framesByFilename: Record<string, AsepriteJSONFrame> | undefined; if (Array.isArray(options.sourceJSON.frames)) { framesByFilename = {}; for (const frame of options.sourceJSON.frames) { framesByFilename[frame.filename] = frame; } } else { framesByFilename = options.sourceJSON.frames; } // Ensure we have at least one frame. const framesByFilenameKeys = Object.keys(framesByFilename); if (framesByFilenameKeys.length === 0) throw new Error("[ThreeAseprite]: no frames present in source JSON."); // Populate frames. This is easy if either: // - there are layers, tags, and there's a frameName function. // - there are no layers or tags. // TODO: support pattern matching. if (this.minFrame === -1) { this.minFrame = Number.parseInt(framesByFilenameKeys[0]); if (Number.isNaN(this.minFrame)) throw new Error( "[ThreeAseprite]: unable to resolve first frame index for sprite." ); } if (this.maxFrame === -1) { if (this.orderedLayers.length > 1) console.warn( `[ThreeAseprite]: layers were supplied but frames were not - using best guess based on frame defs length / layer count.` ); this.maxFrame = this.minFrame + Math.floor(framesByFilenameKeys.length / this.orderedLayers.length) - 1; } if (this.minFrame > this.maxFrame) throw new Error("[ThreeAseprite]: unable to resolve frame range."); this.currentFrame = this.minFrame; // Extract and map frames. if (options.frameNameToFrameParams) { const frameNameToFrameParams = options.frameNameToFrameParams; for (const [frameName, frame] of Object.entries(framesByFilename)) { const frameParams = frameNameToFrameParams(frameName); const frameIndex = frameParams.frame; const frameLayer = frameParams.layerName ?? defaultLayerName; if (frameIndex === undefined) { console.warn( "[ThreeAseprite]: failed to get frame index from frameNameToFrameParams." ); continue; } let frameInfo: FrameInfo | undefined = this.frames[frameIndex]; if (frameInfo === undefined) { frameInfo = { duration: frame.duration, layerFrames: {}, }; this.frames[frameIndex] = frameInfo; } frameInfo.layerFrames[frameLayer] = frame; } } else { let frameKeyIndex = 0; for ( let frameIndex = this.minFrame; frameIndex <= this.maxFrame; frameIndex++ ) { const frameInfo: FrameInfo = { duration: 0, layerFrames: {}, }; for ( let layerIndex = 0; layerIndex < this.orderedLayers.length; layerIndex++ ) { const layerName = this.orderedLayers[layerIndex]; let frameName: string | undefined; if (options.frameName) { frameName = options.frameName({ frame: frameIndex, layerName: layerName, }); } else { // This makes a huge assumption that Aseprite will always export things in the expected order. frameName = framesByFilenameKeys[frameKeyIndex++]; } if (frameName === undefined) throw new Error( `[ThreeAsperite]: unable to identify frame name (frame ${frameIndex}, layer ${layerName}).` ); const frameDef = framesByFilename[frameName]; if (frameDef === undefined) continue; frameInfo.duration = Math.max(frameInfo.duration, frameDef.duration); frameInfo.layerFrames[layerName] = frameDef; } this.frames[frameIndex] = frameInfo; } } // Create geometry. const quadCount = this.orderedLayers.length; this.vtxIndex = new Uint16Array(6 * quadCount); this.vtxPos = new Float32Array(12 * quadCount); this.vtxUV = new Float32Array(8 * quadCount); this.vtxOpacity = new Float32Array(4 * quadCount); this.vtxColor = new Float32Array(12 * quadCount); this.vtxFade = new Float32Array(16 * quadCount); this.vtxOutlineSpread = new Float32Array(8 * quadCount); this.geometry = new BufferGeometry(); this.geometry.setAttribute("position", new BufferAttribute(this.vtxPos, 3)); this.geometry.setAttribute("uv", new BufferAttribute(this.vtxUV, 2)); this.geometry.setAttribute( "vtxOpacity", new BufferAttribute(this.vtxOpacity, 1) ); this.geometry.setAttribute( "vtxColor", new BufferAttribute(this.vtxColor, 3) ); this.geometry.setAttribute("vtxFade", new BufferAttribute(this.vtxFade, 4)); this.geometry.setAttribute("vtxOutlineSpread", new BufferAttribute(this.vtxOutlineSpread, 2)); this.geometry.setIndex(new BufferAttribute(this.vtxIndex, 1)); this.vtxOpacity.fill(1); this.vtxColor.fill(1); this.vtxOutlineSpread.fill(1); // Initialize indices and layer z values. for (let qi = 0; qi < quadCount; qi++) { const qi6 = qi * 6; const qi4 = qi * 4; this.vtxIndex[qi6 + 0] = qi4 + 0; this.vtxIndex[qi6 + 1] = qi4 + 1; this.vtxIndex[qi6 + 2] = qi4 + 2; this.vtxIndex[qi6 + 3] = qi4 + 2; this.vtxIndex[qi6 + 4] = qi4 + 3; this.vtxIndex[qi6 + 5] = qi4 + 0; const qi12 = qi * 12; this.vtxPos[qi12 + 2] = qi * layerDepth; this.vtxPos[qi12 + 5] = qi * layerDepth; this.vtxPos[qi12 + 8] = qi * layerDepth; this.vtxPos[qi12 + 11] = qi * layerDepth; } // Create material. this.material = createAspriteShaderMaterial({ texture: options.texture, }); // Create mesh. this.mesh = new Mesh(this.geometry, this.material); // We assume the coordinate system is the same as screen-space coordinates in all math, // buy typical camera coordinates are Y-up. this.mesh.scale.y = -1; // Initialize geometry to default tag and frame. this.updateGeometryToFrame(this.currentFrame); } clone(): ThreeAseprite<LayerNames> { // TODO: advance to the correct frame. // TODO: copy opacity/color configs. return new ThreeAseprite(this.options); } getCurrentFrame() { return this.currentFrame; } getFrameDuration(frameNumber: number) { const frame = this.frames[frameNumber]; if (frame === undefined) return 0; return frame.duration; } getCurrentFrameDuration() { const frame = this.frames[this.currentFrame]; if (frame === undefined) return 0; return frame.duration; } getCurrentTag() { return this.currentTag; } getCurrentTagFrame() { if (this.currentTag === null) return null; const tag = this.tags[this.currentTag]; return this.currentTagFrame; } getCurrentTagFrameCount() { if (this.currentTag === null) return null; const tag = this.tags[this.currentTag]; return tag.from - tag.to + 1; } animate(deltaMs: number) { if (!this.playingAnimation) return; let frameNo = this.currentFrame; let frame = this.frames[frameNo]; if (frame === undefined) return; if (frame.duration >= this.currentFrameTime + deltaMs) { this.currentFrameTime += deltaMs; return; } const tag = this.currentTag ? this.tags[this.currentTag] : undefined; const step = this.playingAnimationBackwards ? -1 : 1; let remainingDeltaMs = this.currentFrameTime + deltaMs; let frameNoMin = this.minFrame; let frameNoRange = this.maxFrame - this.minFrame + 1; if (tag !== undefined) { frameNoMin = tag.from; frameNoRange = tag.to - tag.from + 1; } let eventsToDispatch: Parameters<typeof this.dispatchEvent>[0][] = []; while (remainingDeltaMs > frame.duration) { remainingDeltaMs -= frame.duration || 1; frameNo = ((frameNo - frameNoMin + step + frameNoRange) % frameNoRange) + frameNoMin; if (this.playingAnimationBackwards) { if (frameNo === frameNoMin + frameNoRange - 1) { eventsToDispatch.push(ANIMATION_COMPLETE_EVENT); } } else { if (frameNo === frameNoMin) { eventsToDispatch.push(ANIMATION_COMPLETE_EVENT); } } frame = this.frames[frameNo]; if (frame === undefined) return; this.currentFrame = frameNo; const triggers = this.triggers[frameNo]; if (triggers !== undefined) { for (const trigger of triggers) { if (trigger.tagName !== null && trigger.tagName !== this.currentTag) continue; eventsToDispatch.push({ type: trigger.eventName as Extract<EventNames, string>, }); } } } this.currentFrameTime = -remainingDeltaMs; if (tag !== undefined) { this.currentTagFrame = frameNo - tag.from; } this.updateGeometryToFrame(frameNo); for (const event of eventsToDispatch) { this.dispatchEvent(event); } } gotoTag(tagName: string | null) { if (this.currentTag === tagName) return; if (tagName === null) { this.currentTag = null; this.currentTagFrame = null; return; } const tag = this.tags[tagName]; if (tag === undefined) return; this.currentTag = tagName; this.currentTagFrame = 0; this.currentFrame = tag.from; this.currentFrameTime = 0; this.updateGeometryToFrame(this.currentFrame); this.dispatchEvent(TAG_SWITCHED_EVENT); } gotoFrame(frameNo: number) { if (this.currentFrame === frameNo) return; this.currentFrame = frameNo; this.currentFrameTime = 0; this.updateGeometryToFrame(frameNo); } gotoTagFrame(tagFrameNo: number) { if (this.currentTag === null) return; const tag = this.tags[this.currentTag]; if (tag === undefined) return; this.currentTagFrame = tagFrameNo; this.currentFrame = tagFrameNo + tag.from; this.currentFrameTime = 0; this.updateGeometryToFrame(this.currentFrame); } setClipping(clipping: LayerClipping | null) { this.clipping = clipping ?? undefined; } private updateGeometryToFrame(frameNo: number) { const { textureWidth, textureHeight, offset, clipping, outlineSpread } = this; const { x: xOffset, y: yOffset } = offset; const invWidth = 1 / textureWidth; const invHeight = 1 / textureHeight; const frameDef = this.frames[frameNo]; if (frameDef === undefined) throw new Error(`[ThreeAseprite]: unknown frame "#${frameNo}".`); for (let li = 0; li < this.orderedLayers.length; li++) { const layerName = this.orderedLayers[li]; const layerFrameDef = frameDef.layerFrames[layerName] ?? emptyFrameDef; let { x, y, w, h } = layerFrameDef.frame; let { x: sx, y: sy } = layerFrameDef.spriteSourceSize; let { w: sw, h: sh } = layerFrameDef.sourceSize; // Apply optional clipping. if (clipping !== undefined) { const { xMin, xMax, yMin, yMax } = clipping; if (xMin !== undefined && xMin > sx) { const deltaX = xMin - sx; x += deltaX; sx += deltaX; w -= deltaX; } if (xMax !== undefined && xMax < sx + w) { const deltaX = sx + w - xMax; w -= deltaX; } if (yMin !== undefined && yMin > sy) { const deltaY = yMin - sy; y += deltaY; sy += deltaY; h -= deltaY; } if (yMax !== undefined && yMax < sy + h) { const deltaY = sy + h - yMax; h -= deltaY; } if (w <= 0) { x = 0; w = 0; sx = 0; sw = 0; } if (h <= 0) { y = 0; h = 0; } } // Apply optional outlining. if (outlineSpread !== undefined && layerFrameDef !== emptyFrameDef) { sx -= outlineSpread; sy -= outlineSpread; x -= outlineSpread; y -= outlineSpread; w += outlineSpread * 2; h += outlineSpread * 2; } // Update vertex positions. const vtxI = 12 * li; this.vtxPos[vtxI + 0] = w * 0 + sx - sw * 0.5 + xOffset; this.vtxPos[vtxI + 1] = h * 0 + sy - sh * 0.5 + yOffset; this.vtxPos[vtxI + 3] = w * 1 + sx - sw * 0.5 + xOffset; this.vtxPos[vtxI + 4] = h * 0 + sy - sh * 0.5 + yOffset; this.vtxPos[vtxI + 6] = w * 1 + sx - sw * 0.5 + xOffset; this.vtxPos[vtxI + 7] = h * 1 + sy - sh * 0.5 + yOffset; this.vtxPos[vtxI + 9] = w * 0 + sx - sw * 0.5 + xOffset; this.vtxPos[vtxI + 10] = h * 1 + sy - sh * 0.5 + yOffset; this.geometry.getAttribute("position").needsUpdate = true; // Update texture coordinates. const vtxUVI = 8 * li; this.vtxUV[vtxUVI + 0] = x * invWidth; this.vtxUV[vtxUVI + 1] = 1 - y * invHeight; this.vtxUV[vtxUVI + 2] = (x + w) * invWidth; this.vtxUV[vtxUVI + 3] = 1 - y * invHeight; this.vtxUV[vtxUVI + 4] = (x + w) * invWidth; this.vtxUV[vtxUVI + 5] = 1 - (y + h) * invHeight; this.vtxUV[vtxUVI + 6] = x * invWidth; this.vtxUV[vtxUVI + 7] = 1 - (y + h) * invHeight; this.geometry.getAttribute("uv").needsUpdate = true; // Update bounding box. this.geometry.computeBoundingBox(); } } protected expandLayerGroups<T>( attrMap: Partial<Record<LayerNames, T>> ): Partial<Record<LayerNames, T>> { const assignments: Partial<Record<LayerNames, T>> = {}; const toAssign = [...Object.entries(attrMap)] as [LayerNames, T][]; while (toAssign.length > 0) { const assignment = toAssign.pop(); if (assignment === undefined) break; const [layer, value] = assignment; assignments[layer] = value; if (this.layerGroups[layer]) { for (const subLayer of this.layerGroups[layer]) { toAssign.push([subLayer as LayerNames, value]); } } } return assignments; } /** * Sets the sprite's opacity. * @param opacity */ setOpacity(opacity: number) { this.material.uniforms.opacity.value = opacity; this.material.uniformsNeedUpdate = true; } /** * Sets an optional multiplicative color for the sprite. * @param color */ setColor(color: ColorRepresentation) { (this.material.uniforms.color.value as Color).set(color); this.material.uniformsNeedUpdate = true; } /** * Sets an optional fade color for the sprite. * @param fadeColor * @param fadeAmount */ setFade(fadeColor: ColorRepresentation, fadeAmount: number) { const color = new Color(fadeColor); const fade4 = this.material.uniforms.fade.value as Vector4; fade4.set(color.r, color.g, color.b, fadeAmount); this.material.uniformsNeedUpdate = true; } /** * Sets an optional opacity per layer, plus an optional default to apply to all others. * This is useful when sprites have multiple conditional layers. * @param opacityMap * @param defaultOpacity */ setLayerOpacities( opacityMap: Partial<Record<LayerNames, number>>, defaultOpacity?: number ) { const opacityAssignments = this.expandLayerGroups(opacityMap); for (let li = 0; li < this.orderedLayers.length; li++) { const layerName = this.orderedLayers[li] as LayerNames; const layerOpacity = opacityAssignments[layerName] ?? defaultOpacity; if (layerOpacity === undefined) continue; const oi = li * 4; this.vtxOpacity[oi + 0] = layerOpacity; this.vtxOpacity[oi + 1] = layerOpacity; this.vtxOpacity[oi + 2] = layerOpacity; this.vtxOpacity[oi + 3] = layerOpacity; } this.geometry.getAttribute("vtxOpacity").needsUpdate = true; } /** * Sets an optional multiplicative color per layer. This is useful for sprite recoloration. * @param colorMap */ setLayerColors(colorMap: Partial<Record<LayerNames, ColorRepresentation>>) { const colorAssignments = this.expandLayerGroups(colorMap); const color = new Color(); for (let li = 0; li < this.orderedLayers.length; li++) { const layerName = this.orderedLayers[li] as LayerNames; const colorAssignment = colorAssignments[layerName]; if (colorAssignment === undefined) continue; color.set(colorAssignment); const ci = li * 12; color.toArray(this.vtxColor, ci + 0); color.toArray(this.vtxColor, ci + 3); color.toArray(this.vtxColor, ci + 6); color.toArray(this.vtxColor, ci + 9); } this.geometry.getAttribute("vtxColor").needsUpdate = true; } /** * Sets an optional fade color and amount per layer. This is useful for sprite recoloration. * @param colorMap */ setLayerFades( colorMap: Partial<Record<LayerNames, [ColorRepresentation, number]>> ) { const fadeAssignments = this.expandLayerGroups(colorMap); const color = new Color(); for (let li = 0; li < this.orderedLayers.length; li++) { const layerName = this.orderedLayers[li] as LayerNames; const fade = fadeAssignments[layerName]; if (fade === undefined) continue; const [colorRep, fadeAmount] = fade; color.set(colorRep); const fi = li * 16; color.toArray(this.vtxFade, fi + 0); color.toArray(this.vtxFade, fi + 4); color.toArray(this.vtxFade, fi + 8); color.toArray(this.vtxFade, fi + 12); this.vtxFade[fi + 3] = fadeAmount; this.vtxFade[fi + 7] = fadeAmount; this.vtxFade[fi + 11] = fadeAmount; this.vtxFade[fi + 15] = fadeAmount; } this.geometry.getAttribute("vtxFade").needsUpdate = true; } /** * Sets an optional outline on the sprite. Outlines expand the boundaries * of the sprite by a specified number of pixels with a specified color and opacity. * * When using this feature, ensure the sprite sheet has a padding equal or greater * than 2 * outlineWidth; otherwise, collisions with other frames will occur. * @param outlineWidth - width, in sprite-space pixels, of the outline. * @param outlineColor - color of the outline. * @param outlineOpacity - opacity of the outline. */ setOutline( outlineWidth: number, outlineColor?: ColorRepresentation, outlineOpacity?: number ) { const uOutlineSpread = this.material.uniforms.outlineSpread .value as Vector2; const uOutline = this.material.uniforms.outline.value as Vector4; if (outlineWidth > 0 && outlineColor !== undefined) { const outlineColorAsColor = new Color(outlineColor); this.outlineSpread = outlineWidth; uOutlineSpread.set( outlineWidth / this.textureWidth, outlineWidth / this.textureHeight ); uOutline.set( outlineColorAsColor.r, outlineColorAsColor.g, outlineColorAsColor.b, outlineOpacity ?? 1 ); } else { this.outlineSpread = 0; uOutlineSpread.set(0, 0); uOutline.set(0, 0, 0, 0); } this.material.uniformsNeedUpdate = true; } /** * Sets optional outline distances for each layer of the sprite. * These widths are relative to the outline width set in setOutline. */ setRealtiveLayerOutlines( outlineSizeMap: Partial<Record<LayerNames, number>> ) { const outlineAssignments = this.expandLayerGroups(outlineSizeMap); for (let li = 0; li < this.orderedLayers.length; li++) { const layerName = this.orderedLayers[li] as LayerNames; const size = outlineAssignments[layerName]; if (size === undefined) continue; const fi = li * 8; for (let oi = 0; oi < 8; oi++) this.vtxOutlineSpread[fi + oi] = size; } this.geometry.getAttribute("vtxOutlineSpread").needsUpdate = true; } /** * Get available layers. * @returns */ getLayers() { return this.orderedLayers; } /** * Get available layer groups. * @returns */ getLayerGroups() { return this.layerGroups; } /** * Get available tags. * @returns */ getTags() { return this.tags; } /** * Adds an event trigger at the specified frame. * @param frameNo * @param eventName */ addFrameTrigger(frameNo: number, eventName: EventNames) { const trigger: Trigger<EventNames> = { tagName: null, eventName, }; if (this.triggers[frameNo] === undefined) this.triggers[frameNo] = []; this.triggers[frameNo].push(trigger); } /** * Adds an event trigger at the specified tag and frame. * @param tagName * @param tagFrameNo * @param eventName * @returns */ addTagFrameTrigger( tagName: string, tagFrameNo: number, eventName: EventNames ) { const tag = this.tags[tagName]; if (tag === undefined) return; const frameNo = tag.from + tagFrameNo; const trigger: Trigger<EventNames> = { tagName, eventName, }; if (this.triggers[frameNo] === undefined) this.triggers[frameNo] = []; this.triggers[frameNo].push(trigger); } /** * Removes an event trigger. * @param frameNo * @param eventName */ removeFrameTrigger(frameNo: number, eventName: EventNames) { if (this.triggers[frameNo]) this.triggers[frameNo] = this.triggers[frameNo].filter((t) => { if (t.eventName !== eventName) return true; if (t.tagName !== null) return true; return false; }); } /** * Removes a tagged event trigger. */ removeTagFrameTrigger( tagName: string, tagFrameNo: number, eventName: EventNames ) { const tag = this.tags[tagName]; if (tag === undefined) return; const frameNo = tag.from + tagFrameNo; if (this.triggers[frameNo]) this.triggers[frameNo] = this.triggers[frameNo].filter((t) => { if (t.eventName !== eventName) return true; if (t.tagName !== tagName) return true; return false; }); } /** * Determines whether a given layer of layer group is present within a given tag. * This is primairly used by the example to produce a clickable group/tag matrix. * @param layerOrGroupName * @param tagName * @param detectEmpty */ hasLayerAtTag( layerOrGroupName: LayerNames, tagName: string, detectEmpty?: boolean ) { const allSubLayers = Object.keys( this.expandLayerGroups({ [layerOrGroupName]: true } as Partial< Record<LayerNames, boolean> >) ); const tag = this.tags[tagName]; if (tag === undefined) return false; for (let fi = tag.from; fi <= tag.to; fi++) { const frame = this.frames[fi]; if (frame === undefined) continue; for (const layerName of allSubLayers) { const layerInfo = frame.layerFrames[layerName]; if (layerInfo === undefined) continue; if (detectEmpty && layerInfo.frame.w < 2 && layerInfo.frame.h < 2) continue; return true; } } return false; } /** * Dispose of geometry and material. */ dispose() { this.geometry.dispose(); this.material.dispose(); } /** * Gets the bounding box, in pixels, of a given layer. * @param layerName */ getLayerBoundingBox( layerName: LayerNames | LayerNames[], tagName?: string, frameNo?: number ): LayerClipping | null { const initialLayers: Partial<Record<LayerNames, true>> = {}; if (Array.isArray(layerName)) { for (const singleLayerName of layerName) { initialLayers[singleLayerName] = true; } } else { initialLayers[layerName] = true; } const allSubLayers = Object.keys( this.expandLayerGroups(initialLayers) ) as LayerNames[]; return this.getMultiLayerBoundingBox(allSubLayers, tagName, frameNo); } private getMultiLayerBoundingBox( layerNames: LayerNames[], tagName?: string, frameNo?: number ): LayerClipping | null { let frameIndex = this.currentFrame; if (tagName && frameNo) { frameIndex = this.tags[tagName].from + frameNo; } else if (tagName) { frameIndex = this.tags[tagName].from; } else if (frameNo) { frameIndex = frameNo; } const offset = this.offset; const frame = this.frames[frameIndex]; if (frame === undefined) throw new Error("[ThreeAseprite]: frame not found."); let bounds: LayerClipping | null = null; for (const layerName of layerNames) { const layerFrame = frame.layerFrames[layerName]; if (layerFrame === undefined) continue; const { x, y, w, h } = layerFrame.spriteSourceSize; const { w: sw, h: sh } = layerFrame.sourceSize; // Empty layers don't have bounding boxes. if (x === 0 && y === 0 && w === 1 && h === 1) continue; const leftBound = w * 0 + x - sw * 0.5 + offset.x; const rightBound = w * 1 + x - sw * 0.5 + offset.x; const bottomBound = h * 0 + y - sh * 0.5 + offset.y; const topBound = h * 1 + y - sh * 0.5 + offset.y; if (bounds === null) { bounds = { xMin: leftBound, xMax: rightBound, yMin: bottomBound, yMax: topBound, }; } else { bounds.xMin = Math.min(bounds.xMin, leftBound); bounds.xMax = Math.max(bounds.xMax, rightBound); bounds.yMin = Math.min(bounds.yMin, bottomBound); bounds.yMax = Math.max(bounds.yMax, topBound); } } return bounds; } // Redeclare parent class's method signature. // This addresses a TS / VsCode bug where these methods aren't available. addEventListener<T extends Extract<EventNames | StandardEvents, string>>( type: T, listener: EventListener< Record< EventNames | StandardEvents, FrameTriggerEvent<EventNames | StandardEvents> >[T], T, this > ): void { super.addEventListener(type, listener); } hasEventListener<T extends Extract<EventNames | StandardEvents, string>>( type: T, listener: EventListener< Record< EventNames | StandardEvents, FrameTriggerEvent<EventNames | StandardEvents> >[T], T, this > ): boolean { return super.hasEventListener(type, listener); } removeEventListener<T extends Extract<EventNames | StandardEvents, string>>( type: T, listener: EventListener< Record< EventNames | StandardEvents, FrameTriggerEvent<EventNames | StandardEvents> >[T], T, this > ): void { super.removeEventListener(type, listener); } }