three-aseprite
Version:
Three.js mesh rendering for Aseprite JSON sprite sheets
1,002 lines (952 loc) • 32.7 kB
text/typescript
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);
}
}