mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-02-15 11:31:37 +08:00
1012 lines
26 KiB
TypeScript
1012 lines
26 KiB
TypeScript
import type { AnimationState, AnimationStateListener, AssetLoader, Bone, Event, NumberArrayLike, RegionAttachment, Skeleton, SkeletonRendererCore, Skin, Slot, TextureAtlas, } from "@esotericsoftware/spine-construct3-lib";
|
|
|
|
const C3 = globalThis.C3;
|
|
const spine = globalThis.spine;
|
|
|
|
spine.Skeleton.yDown = true;
|
|
|
|
class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
|
|
propAtlas = "";
|
|
propSkel = "";
|
|
propLoaderScale = 1;
|
|
propSkin: string[] = [];
|
|
propAnimation?: string;
|
|
propOffsetX = 0;
|
|
propOffsetY = 0;
|
|
propOffsetAngle = 0;
|
|
propScaleX = 1;
|
|
propScaleY = 1;
|
|
propDebugSkeleton = false;
|
|
|
|
isFlippedX = false;
|
|
isPlaying = true;
|
|
animationSpeed = 1.0;
|
|
physicsMode = spine.Physics.update;
|
|
customSkins: Record<string, Skin> = {};
|
|
|
|
textureAtlas?: TextureAtlas;
|
|
renderer?: IRenderer;
|
|
atlasLoaded = false;
|
|
atlasLoading = false;
|
|
skeletonLoaded = false;
|
|
skeletonLoading = false;
|
|
skeleton?: Skeleton;
|
|
state?: AnimationState;
|
|
|
|
public triggeredEventTrack = -1;
|
|
public triggeredEventAnimation = "";
|
|
public triggeredEventName = "";
|
|
public triggeredEventData?: Event;
|
|
|
|
private assetLoader: AssetLoader;
|
|
private skeletonRenderer: SkeletonRendererCore;
|
|
|
|
private a = 0;
|
|
private b = 0;
|
|
private c = 0;
|
|
private d = 0;
|
|
|
|
private tempVertices = new Float32Array(4096);
|
|
private tempColors = new Float32Array(4096);
|
|
private verticesTemp = spine.Utils.newFloatArray(2 * 1024);
|
|
private tempPoint = new spine.Vector2();
|
|
|
|
private boneFollowers = new Map<string, { uid: number, offsetX: number, offsetY: number, offsetAngle: number }>();
|
|
|
|
private dragHandles = new Set<{
|
|
slot?: Slot,
|
|
bone: Bone,
|
|
dragging?: boolean,
|
|
debug: boolean,
|
|
radius: number,
|
|
offsetX: number,
|
|
offsetY: number,
|
|
}>();
|
|
private prevLeftClickDown = false;
|
|
|
|
constructor () {
|
|
super();
|
|
|
|
const properties = this._getInitProperties();
|
|
if (properties) {
|
|
console.log(properties);
|
|
this.propAtlas = properties[0] as string;
|
|
this.propSkel = properties[1] as string;
|
|
this.propLoaderScale = properties[2] as number;
|
|
const skinProp = properties[3] as string;
|
|
this.propSkin = skinProp === "" ? [] : skinProp.split(",");
|
|
this.propAnimation = properties[4] as string;
|
|
this.propDebugSkeleton = properties[5] as boolean;
|
|
|
|
this.propOffsetX = properties[8] as number;
|
|
this.propOffsetY = properties[9] as number;
|
|
this.propOffsetAngle = properties[10] as number;
|
|
this.propScaleX = properties[11] as number;
|
|
this.propScaleY = properties[12] as number;
|
|
}
|
|
|
|
this.assetLoader = new spine.AssetLoader();
|
|
this.skeletonRenderer = new spine.SkeletonRendererCore();
|
|
|
|
this._setTicking(true);
|
|
}
|
|
|
|
/*
|
|
* Update, render, draw
|
|
*/
|
|
|
|
public play () {
|
|
this.isPlaying = true;
|
|
}
|
|
|
|
public stop () {
|
|
this.isPlaying = false;
|
|
}
|
|
|
|
_tick (): void {
|
|
const { renderer } = this;
|
|
|
|
if (!renderer) return;
|
|
|
|
if (!this.atlasLoaded) {
|
|
this.loadAtlas();
|
|
return;
|
|
}
|
|
|
|
if (!this.skeletonLoaded) {
|
|
this.loadSkeleton();
|
|
return;
|
|
}
|
|
|
|
if (!this.isPlaying) return;
|
|
|
|
this.update(this.dt);
|
|
this.runtime.sdk.updateRender();
|
|
}
|
|
|
|
private update (delta: number) {
|
|
const { state, skeleton, animationSpeed, physicsMode } = this;
|
|
|
|
if (!skeleton || !state) return;
|
|
|
|
const adjustedDelta = delta * animationSpeed;
|
|
state.update(adjustedDelta);
|
|
skeleton.update(adjustedDelta);
|
|
state.apply(skeleton);
|
|
|
|
const cos = Math.cos(this.angle + this.propOffsetAngle);
|
|
const sin = Math.sin(this.angle + this.propOffsetAngle);
|
|
this.a = cos;
|
|
this.b = sin;
|
|
this.c = -sin;
|
|
this.d = cos;
|
|
|
|
skeleton.updateWorldTransform(physicsMode);
|
|
|
|
this.updateHandles(skeleton);
|
|
this.updateBoneFollowers();
|
|
}
|
|
|
|
_draw (renderer: IRenderer) {
|
|
this.renderer ||= renderer;
|
|
|
|
if (!this.isVisible) return;
|
|
if (!this.isOnScreen) return;
|
|
|
|
const { skeleton } = this;
|
|
if (!skeleton) return;
|
|
|
|
this.renderSkeleton(renderer, skeleton);
|
|
this.renderDragHandles(renderer);
|
|
this.renderDebugSkeleton(renderer, skeleton);
|
|
}
|
|
|
|
private renderSkeleton (renderer: IRenderer, skeleton: Skeleton) {
|
|
let command = this.skeletonRenderer.render(skeleton);
|
|
const inv255 = 1 / 255;
|
|
while (command) {
|
|
const { numVertices, positions, uvs, colors, indices, numIndices, blendMode } = command;
|
|
|
|
const vertices = this.tempVertices;
|
|
const c3colors = this.tempColors;
|
|
for (let i = 0; i < numVertices; i++) {
|
|
const srcIndex = i * 2;
|
|
const { x, y } = this.skeletonToC3WorldCoordinates(positions[srcIndex], positions[srcIndex + 1]);
|
|
|
|
const dstIndex = i * 3;
|
|
vertices[dstIndex] = x;
|
|
vertices[dstIndex + 1] = y;
|
|
vertices[dstIndex + 2] = 0;
|
|
|
|
// there's something wrong with the hand after adding the colors on spineboy portal animation
|
|
const color = colors[i];
|
|
const colorDst = i * 4;
|
|
c3colors[colorDst] = (color >>> 16 & 0xFF) * inv255;
|
|
c3colors[colorDst + 1] = (color >>> 8 & 0xFF) * inv255;
|
|
c3colors[colorDst + 2] = (color & 0xFF) * inv255;
|
|
c3colors[colorDst + 3] = (color >>> 24 & 0xFF) * inv255 * this.opacity;
|
|
}
|
|
|
|
renderer.setTexture(command.texture.texture);
|
|
renderer.setBlendMode(spine.BlendingModeSpineToC3[blendMode]);
|
|
renderer.drawMesh(
|
|
vertices.subarray(0, numVertices * 3),
|
|
uvs.subarray(0, numVertices * 2),
|
|
indices.subarray(0, numIndices),
|
|
c3colors.subarray(0, numVertices * 4),
|
|
);
|
|
|
|
command = command.next;
|
|
}
|
|
}
|
|
|
|
private renderDragHandles (renderer: IRenderer) {
|
|
for (const { bone, radius, debug } of this.dragHandles) {
|
|
if (!debug) continue;
|
|
const boneApplied = bone.applied;
|
|
const { x: x1, y: y1 } = this.skeletonToC3WorldCoordinates(boneApplied.worldX, boneApplied.worldY);
|
|
renderer.setColorFillMode();
|
|
renderer.setColor([1, 0, 0, .2]);
|
|
renderer.convexPoly(this.circle(x1, y1, radius));
|
|
}
|
|
}
|
|
|
|
private renderDebugSkeleton (renderer: IRenderer, skeleton: Skeleton) {
|
|
if (!this.propDebugSkeleton) return;
|
|
|
|
const bones = skeleton.bones;
|
|
for (let i = 0, n = bones.length; i < n; i++) {
|
|
const bone = bones[i];
|
|
if (!bone.parent) continue;
|
|
const boneApplied = bone.applied;
|
|
const { x: x1, y: y1 } = this.skeletonToC3WorldCoordinates(boneApplied.worldX, boneApplied.worldY);
|
|
const x2 = bone.data.length * boneApplied.a + x1;
|
|
const y2 = bone.data.length * boneApplied.c + y1;
|
|
|
|
renderer.setColor([1, 0, 0, 1]);
|
|
renderer.setColorFillMode();
|
|
|
|
const t = this.tempPoint.set(y2 - y1, x1 - x2);
|
|
t.normalize();
|
|
const width = 1 * 0.5;
|
|
const tx = t.x * width;
|
|
const ty = t.y * width;
|
|
renderer.convexPoly([
|
|
x1 + tx, y1 + ty,
|
|
x1 - tx, y1 - ty,
|
|
x2 + tx, y2 + ty,
|
|
x2 - tx, y2 - ty,
|
|
x2 + tx, y2 + ty,
|
|
x1 - tx, y1 - ty,
|
|
]);
|
|
|
|
renderer.setColor([0, 1, 0, 1]);
|
|
renderer.convexPoly(this.circle(x1, y1, 2));
|
|
}
|
|
}
|
|
|
|
private circle (x: number, y: number, radius: number) {
|
|
let segments = Math.max(1, (6 * spine.MathUtils.cbrt(radius)) | 0);
|
|
if (segments <= 0) throw new Error("segments must be > 0.");
|
|
const angle = 2 * spine.MathUtils.PI / segments;
|
|
const cos = Math.cos(angle);
|
|
const sin = Math.sin(angle);
|
|
let cx = radius, cy = 0;
|
|
segments--;
|
|
const poly = [];
|
|
for (let i = 0; i < segments; i++) {
|
|
poly.push(x, y);
|
|
poly.push(x + cx, y + cy);
|
|
const temp = cx;
|
|
cx = cos * cx - sin * cy;
|
|
cy = sin * temp + cos * cy;
|
|
poly.push(x + cx, y + cy);
|
|
}
|
|
poly.push(x, y, x + cx, y + cy);
|
|
cx = radius;
|
|
cy = 0;
|
|
poly.push(x + cx, y + cy);
|
|
return poly;
|
|
}
|
|
|
|
/**********/
|
|
|
|
|
|
/*
|
|
* Drag handles
|
|
*/
|
|
|
|
public addDragHandle (type: 0 | 1, name: string, radius = 10, debug = false) {
|
|
if (type === 0) {
|
|
const bone = this.getBone(name);
|
|
if (!bone) return;
|
|
this.dragHandles.add({ bone, debug, radius, offsetX: 0, offsetY: 0 });
|
|
} else {
|
|
const slot = this.getSlot(name);
|
|
if (!slot) return;
|
|
this.dragHandles.add({ slot, bone: slot.bone, debug, radius, offsetX: 0, offsetY: 0 });
|
|
}
|
|
}
|
|
|
|
public removeDragHandle (type: 0 | 1, name: string) {
|
|
if (type === 0) {
|
|
const bone = this.getBone(name);
|
|
if (!bone) return;
|
|
|
|
for (const handle of this.dragHandles) {
|
|
if (handle.bone === bone && !handle.slot) {
|
|
this.dragHandles.delete(handle);
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
const slot = this.getSlot(name);
|
|
if (!slot) return;
|
|
|
|
for (const handle of this.dragHandles) {
|
|
if (handle.slot === slot) {
|
|
this.dragHandles.delete(handle);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateHandles (skeleton: Skeleton) {
|
|
const mouse = this.runtime.mouse;
|
|
const isLeftClickDown = mouse.isMouseButtonDown(0);
|
|
if (this.dragHandles.size <= 0) {
|
|
this.prevLeftClickDown = isLeftClickDown;
|
|
return;
|
|
}
|
|
|
|
if (!isLeftClickDown) {
|
|
this.prevLeftClickDown = false;
|
|
for (const handleObject of this.dragHandles) handleObject.dragging = false;
|
|
return;
|
|
}
|
|
|
|
const [mx, my] = mouse.getMousePosition();
|
|
for (const handleObject of this.dragHandles) {
|
|
const bone = handleObject.bone;
|
|
const boneApplied = bone.applied;
|
|
|
|
if (handleObject.dragging) {
|
|
if (bone.parent) {
|
|
const { x, y } = this.c3WorldCoordinatesToBone(mx - handleObject.offsetX, my - handleObject.offsetY, bone);
|
|
boneApplied.x = x;
|
|
boneApplied.y = y;
|
|
} else {
|
|
const { x, y } = this.c3WorldCoordinatesToSkeleton(mx - handleObject.offsetX, my - handleObject.offsetY);
|
|
boneApplied.x = x / skeleton.scaleX;
|
|
boneApplied.y = -y / skeleton.scaleY * spine.Skeleton.yDir;
|
|
}
|
|
} else if (!this.prevLeftClickDown) {
|
|
const { x, y } = this.c3WorldCoordinatesToSkeleton(mx, my);
|
|
const inside = handleObject.slot
|
|
? this.isInsideSlot(x, y, handleObject.slot, true)
|
|
: this.inRadius(x, y, boneApplied.worldX, boneApplied.worldY, handleObject.radius);
|
|
|
|
if (inside) {
|
|
handleObject.dragging = true;
|
|
handleObject.offsetX = x - boneApplied.worldX;
|
|
handleObject.offsetY = y - boneApplied.worldY;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.prevLeftClickDown = true;
|
|
}
|
|
|
|
public isInsideSlot (x: number, y: number, slotName: string | Slot, skeletonCoordinate = false) {
|
|
const slot = this.getSlot(slotName);
|
|
if (!slot || !slot.bone.active) return false;
|
|
|
|
const attachment = slot.applied.attachment;
|
|
if (!(attachment instanceof spine.RegionAttachment || attachment instanceof spine.MeshAttachment)) return false;
|
|
|
|
const vertices = this.verticesTemp;
|
|
let hullLength = 8;
|
|
|
|
if (attachment instanceof spine.RegionAttachment) {
|
|
const regionAttachment = <RegionAttachment>attachment;
|
|
regionAttachment.computeWorldVertices(slot, vertices, 0, 2);
|
|
} else if (attachment instanceof spine.MeshAttachment) {
|
|
attachment.computeWorldVertices(this.skeleton as Skeleton, slot, 0, attachment.worldVerticesLength, vertices, 0, 2);
|
|
hullLength = attachment.hullLength;
|
|
}
|
|
|
|
if (skeletonCoordinate) return this.isPointInPolygon(vertices, hullLength, x, y);
|
|
|
|
const coords = this.c3WorldCoordinatesToSkeleton(x, y);
|
|
return this.isPointInPolygon(vertices, hullLength, coords.x, coords.y);
|
|
}
|
|
|
|
private isPointInPolygon (vertices: NumberArrayLike, hullLength: number, px: number, py: number) {
|
|
if (hullLength < 6) {
|
|
throw new Error("A polygon must have at least 3 vertices (6 numbers in the array). ");
|
|
}
|
|
|
|
let isInside = false;
|
|
|
|
for (let i = 0, j = hullLength - 2; i < hullLength; i += 2) {
|
|
const xi = vertices[i], yi = vertices[i + 1];
|
|
const xj = vertices[j], yj = vertices[j + 1];
|
|
|
|
const intersects = ((yi > py) !== (yj > py)) &&
|
|
(px < ((xj - xi) * (py - yi)) / (yj - yi) + xi);
|
|
|
|
if (intersects) isInside = !isInside;
|
|
|
|
j = i;
|
|
}
|
|
|
|
return isInside;
|
|
}
|
|
|
|
private inRadius (x1: number, y1: number, x2: number, y2: number, radius: number) {
|
|
const dx = x1 - x2;
|
|
const dy = y1 - y2;
|
|
const distanceSquared = dx * dx + dy * dy;
|
|
return distanceSquared <= radius * radius;
|
|
}
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* C3 Internals
|
|
*/
|
|
|
|
_saveToJson () {
|
|
return {
|
|
// data to be saved for savegames
|
|
};
|
|
}
|
|
|
|
_loadFromJson (o: JSONValue) {
|
|
// load state for savegames
|
|
}
|
|
|
|
_release () {
|
|
super._release();
|
|
}
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* Spine Internals
|
|
*/
|
|
|
|
private async loadAtlas () {
|
|
if (this.atlasLoading || !this.renderer) return;
|
|
this.atlasLoading = true;
|
|
|
|
const textureAtlas = await this.assetLoader.loadAtlasRuntime(this.propAtlas, this.plugin.runtime, this.renderer);
|
|
if (!textureAtlas) return;
|
|
|
|
this.textureAtlas = textureAtlas;
|
|
this.atlasLoaded = true;
|
|
}
|
|
|
|
private async loadSkeleton () {
|
|
if (this.skeletonLoading || !this.atlasLoaded) return;
|
|
this.skeletonLoading = true;
|
|
|
|
const propValue = this.propSkel;
|
|
|
|
if (this.atlasLoaded && this.textureAtlas) {
|
|
const skeletonData = await this.assetLoader.loadSkeletonRuntime(propValue, this.textureAtlas, this.propLoaderScale, this.plugin.runtime);
|
|
if (!skeletonData) return;
|
|
|
|
this.skeleton = new spine.Skeleton(skeletonData);
|
|
const animationStateData = new spine.AnimationStateData(skeletonData);
|
|
this.state = new spine.AnimationState(animationStateData);
|
|
|
|
if (this.propAnimation) {
|
|
this.setAnimation(0, this.propAnimation, true);
|
|
}
|
|
|
|
this._setSkin();
|
|
|
|
this.update(0);
|
|
|
|
this.skeleton.scaleX = this.isFlippedX ? -this.propScaleX : this.propScaleX;
|
|
this.skeleton.scaleY = this.propScaleY;
|
|
|
|
this.skeletonLoaded = true;
|
|
this._trigger(C3.Plugins.EsotericSoftware_SpineConstruct3.Cnds.OnSkeletonLoaded);
|
|
}
|
|
}
|
|
/**********/
|
|
|
|
/*
|
|
* Animations
|
|
*/
|
|
|
|
public setAnimation (track: number, animation: string, loop = false) {
|
|
const trackEntry = this.state?.setAnimation(track, animation, loop);
|
|
if (!trackEntry) return;
|
|
trackEntry.listener = this.makeTrackListener(track, animation);
|
|
}
|
|
|
|
public addAnimation (track: number, animation: string, loop = false, delay = 0) {
|
|
const trackEntry = this.state?.addAnimation(track, animation, loop, delay);
|
|
if (!trackEntry) return;
|
|
trackEntry.listener = this.makeTrackListener(track, animation);
|
|
}
|
|
|
|
public setEmptyAnimation (track: number, mixDuration = 0) {
|
|
this.state?.setEmptyAnimation(track, mixDuration);
|
|
}
|
|
|
|
public getCurrentAnimation (trackIndex: number): string {
|
|
if (!this.skeleton) return "";
|
|
|
|
const { state } = this;
|
|
if (!state) return "";
|
|
|
|
const track = state.tracks[trackIndex];
|
|
if (!track || !track.animation) return "";
|
|
|
|
return track.animation.name;
|
|
}
|
|
|
|
public setAnimationSpeed (speed: number) {
|
|
this.animationSpeed = speed;
|
|
}
|
|
|
|
public setAnimationTime (units: 0 | 1, time: number, track: number) {
|
|
if (!this.state) return;
|
|
|
|
const trackEntry = this.state.tracks[track];
|
|
if (!trackEntry) return;
|
|
|
|
if (units === 0) {
|
|
if (time < trackEntry.animationStart || time > trackEntry.animationEnd) {
|
|
console.warn(`[Spine] Animation time ${time} is out of bounds [${trackEntry.animationStart}, ${trackEntry.animationEnd}]`);
|
|
return;
|
|
}
|
|
trackEntry.trackTime = time;
|
|
} else {
|
|
if (time < 0 || time > 1) {
|
|
console.warn(`[Spine] Animation time ratio ${time} is out of bounds [0, 1]`);
|
|
return;
|
|
}
|
|
trackEntry.trackTime = time * (trackEntry.animationEnd - trackEntry.animationStart);
|
|
}
|
|
}
|
|
|
|
public setAnimationMix (fromName: string, toName: string, duration: number) {
|
|
const stateData = this.state?.data;
|
|
if (!stateData) return;
|
|
|
|
try {
|
|
stateData.setMix(fromName, toName, duration);
|
|
} catch (error) {
|
|
console.error('[Spine] setAnimationMix error:', error);
|
|
}
|
|
}
|
|
|
|
public setTrackAlpha (alpha: number, trackIndex: number) {
|
|
const { state } = this;
|
|
if (!state) {
|
|
console.warn('[Spine] setAlpha: no state');
|
|
return;
|
|
}
|
|
|
|
const track = state.tracks[trackIndex];
|
|
if (!track) {
|
|
console.warn(`[Spine] setAlpha: track ${trackIndex} not found`);
|
|
return;
|
|
}
|
|
|
|
track.alpha = spine.MathUtils.clamp(0, 1, alpha);
|
|
}
|
|
|
|
public setTrackMixBlend (mixBlend: 0 | 1 | 2 | 3, trackIndex: number) {
|
|
const { state } = this;
|
|
if (!state) {
|
|
console.warn('[Spine] setMixBlend: no state');
|
|
return;
|
|
}
|
|
|
|
const track = state.tracks[trackIndex];
|
|
if (!track) {
|
|
console.warn(`[Spine] setMixBlend: track ${trackIndex} not found`);
|
|
return;
|
|
}
|
|
|
|
switch (mixBlend) {
|
|
case 0: track.mixBlend = spine.MixBlend.setup; break;
|
|
case 1: track.mixBlend = spine.MixBlend.first; break;
|
|
case 2: track.mixBlend = spine.MixBlend.replace; break;
|
|
case 3: track.mixBlend = spine.MixBlend.add; break;
|
|
default: console.warn('[Spine] Invalid mix blend mode:', mixBlend);
|
|
}
|
|
}
|
|
|
|
private triggetAnimationEvent (eventName: string, track: number, animation: string, event?: Event) {
|
|
this.triggeredEventTrack = track;
|
|
this.triggeredEventAnimation = animation;
|
|
this.triggeredEventName = eventName;
|
|
this.triggeredEventData = event;
|
|
this._trigger(C3.Plugins.EsotericSoftware_SpineConstruct3.Cnds.OnAnimationEvent);
|
|
}
|
|
|
|
private makeTrackListener = (track: number, animation: string): AnimationStateListener => ({
|
|
start: () => this.triggetAnimationEvent("start", track, animation),
|
|
dispose: () => this.triggetAnimationEvent("dispose", track, animation),
|
|
event: (_, event) => this.triggetAnimationEvent("event", track, animation, event),
|
|
interrupt: () => this.triggetAnimationEvent("interrupt", track, animation),
|
|
end: () => this.triggetAnimationEvent("end", track, animation),
|
|
complete: () => this.triggetAnimationEvent("complete", track, animation),
|
|
})
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* Skins
|
|
*/
|
|
|
|
public setSkin (skins: string[]) {
|
|
this.propSkin = skins;
|
|
this._setSkin();
|
|
}
|
|
|
|
public getCurrentSkin (): string {
|
|
if (!this.skeleton) return "";
|
|
|
|
const skin = this.skeleton.skin;
|
|
if (!skin) return "";
|
|
|
|
return skin.name;
|
|
}
|
|
|
|
private _setSkin () {
|
|
const { skeleton } = this;
|
|
if (!skeleton) return;
|
|
|
|
const skins = this.propSkin;
|
|
|
|
if (skins.length === 0) {
|
|
skeleton.skin = null;
|
|
} else if (skins.length === 1) {
|
|
const skinName = skins[0];
|
|
const skin = skeleton.data.findSkin(skinName);
|
|
if (!skin) {
|
|
// TODO: signal error
|
|
return;
|
|
}
|
|
skeleton.setSkin(skins[0]);
|
|
} else {
|
|
const customSkin = new spine.Skin(skins.join(","));
|
|
for (const s of skins) {
|
|
const skin = skeleton.data.findSkin(s)
|
|
if (!skin) {
|
|
// TODO: signal error
|
|
return;
|
|
}
|
|
customSkin.addSkin(skin);
|
|
}
|
|
skeleton.setSkin(customSkin);
|
|
}
|
|
|
|
skeleton.setupPose();
|
|
this.update(0);
|
|
}
|
|
|
|
public createCustomSkin (skinName: string) {
|
|
if (!this.skeleton) return;
|
|
|
|
if (this.customSkins[skinName])
|
|
this.customSkins[skinName].clear();
|
|
else
|
|
this.customSkins[skinName] = new spine.Skin(skinName);
|
|
}
|
|
|
|
public addCustomSkin (customSkinName: string, skinToAddName: string) {
|
|
if (!this.skeleton) return;
|
|
|
|
if (!this.customSkins[customSkinName]) {
|
|
console.warn(`[Spine] Custom skin "${customSkinName}" does not exist. Create it first.`);
|
|
return;
|
|
}
|
|
|
|
const skinToAdd = this.skeleton.data.findSkin(skinToAddName);
|
|
if (!skinToAdd) {
|
|
console.warn(`[Spine] Skin "${skinToAddName}" not found in skeleton data.`);
|
|
return;
|
|
}
|
|
|
|
this.customSkins[customSkinName].addSkin(skinToAdd);
|
|
}
|
|
|
|
public setCustomSkin (skinName: string) {
|
|
if (!this.skeleton) return;
|
|
|
|
if (!this.customSkins[skinName]) {
|
|
console.warn(`[Spine] Custom skin "${skinName}" does not exist.`);
|
|
return;
|
|
}
|
|
|
|
this.skeleton.setSkin(this.customSkins[skinName]);
|
|
this.skeleton.setupPose();
|
|
}
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* Slot, skeleton color
|
|
*/
|
|
|
|
public setSkeletonColor (color: string) {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] setSkeletonColor: no skeleton');
|
|
return;
|
|
}
|
|
|
|
skeleton.color.setFromString(color);
|
|
}
|
|
|
|
public setSlotColor (slotName: string, color: string) {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] setSlotColor: no skeleton');
|
|
return;
|
|
}
|
|
|
|
const slot = skeleton.findSlot(slotName);
|
|
if (!slot) {
|
|
console.warn(`[Spine] setSlotColor: slot not found: ${slotName}`);
|
|
return;
|
|
}
|
|
|
|
slot.pose.color.setFromString(color);
|
|
}
|
|
|
|
public resetSlotColors (slotName: string = "") {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] resetSlotColors: no skeleton');
|
|
return;
|
|
}
|
|
|
|
if (slotName === "") {
|
|
for (const slot of skeleton.slots)
|
|
slot.pose.color.setFromColor(slot.data.setup.color);
|
|
} else {
|
|
const slot = skeleton.findSlot(slotName);
|
|
if (!slot) {
|
|
console.warn(`[Spine] resetSlotColors: slot not found: ${slotName}`);
|
|
return;
|
|
}
|
|
slot.pose.color.setFromColor(slot.data.setup.color);
|
|
}
|
|
}
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* Bone follower
|
|
*/
|
|
|
|
public attachInstanceToBone (uid: number, boneName: string, offsetX = 0, offsetY = 0, offsetAngle = 0) {
|
|
if (!this.skeleton) return;
|
|
|
|
const bone = this.skeleton.findBone(boneName);
|
|
if (!bone) {
|
|
console.warn(`[Spine] attachInstanceToBone: bone not found: ${boneName}`);
|
|
return;
|
|
}
|
|
|
|
this.boneFollowers.set(boneName, { uid, offsetX, offsetY, offsetAngle });
|
|
}
|
|
|
|
public detachInstanceFromBone (boneName: string) {
|
|
this.boneFollowers.delete(boneName);
|
|
}
|
|
|
|
private updateBoneFollowers () {
|
|
if (this.boneFollowers.size === 0) return;
|
|
|
|
for (const [boneName, follower] of this.boneFollowers) {
|
|
const bone = this.skeleton?.findBone(boneName);
|
|
if (!bone) continue;
|
|
|
|
const instance = this.runtime.getInstanceByUid(follower.uid) as IWorldInstance;
|
|
if (!instance) continue;
|
|
|
|
const { x, y } = this.boneToC3WorldCoordinates(bone);
|
|
const boneRotation = bone.applied.getWorldRotationX();
|
|
|
|
// Apply rotation to offset
|
|
const rotationRadians = boneRotation * Math.PI / 180;
|
|
const cos = Math.cos(rotationRadians);
|
|
const sin = Math.sin(rotationRadians);
|
|
const rotatedOffsetX = follower.offsetX * cos - follower.offsetY * sin;
|
|
const rotatedOffsetY = follower.offsetX * sin + follower.offsetY * cos;
|
|
|
|
instance.x = x + rotatedOffsetX;
|
|
instance.y = y + rotatedOffsetY;
|
|
instance.angleDegrees = boneRotation + follower.offsetAngle;
|
|
}
|
|
}
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* Coordinates transformation
|
|
*/
|
|
|
|
private c3WorldCoordinatesToSkeleton (x: number, y: number) {
|
|
const tx = x - (this.x + this.propOffsetX);
|
|
const ty = y - (this.y + this.propOffsetY);
|
|
const { a: ta, b: tb, c: tc, d: td, tempPoint } = this;
|
|
const delta = ta * td - tb * tc;
|
|
tempPoint.x = (td * tx - tc * ty) / delta;
|
|
tempPoint.y = (ta * ty - tb * tx) / delta;
|
|
return this.tempPoint;
|
|
}
|
|
|
|
private c3WorldCoordinatesToBone (x: number, y: number, bone: Bone) {
|
|
const point = this.c3WorldCoordinatesToSkeleton(x, y);
|
|
if (bone.parent)
|
|
return bone.parent.applied.worldToLocal(point);
|
|
return bone.applied.worldToLocal(point);
|
|
}
|
|
|
|
private skeletonToC3WorldCoordinates (skeletonX: number, skeletonY: number) {
|
|
const { a, b, c, d, tempPoint } = this;
|
|
tempPoint.x = a * skeletonX + c * skeletonY + this.x + this.propOffsetX;
|
|
tempPoint.y = b * skeletonX + d * skeletonY + this.y + this.propOffsetY;
|
|
return tempPoint;
|
|
}
|
|
|
|
private boneToC3WorldCoordinates (bone: Bone) {
|
|
const { applied } = bone;
|
|
return this.skeletonToC3WorldCoordinates(applied.worldX, applied.worldY);
|
|
}
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* Bone
|
|
*/
|
|
|
|
public getBoneX (boneName: string): number {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] getBoneX: no skeleton');
|
|
return 0;
|
|
}
|
|
|
|
const bone = skeleton.findBone(boneName);
|
|
if (!bone) {
|
|
console.warn(`[Spine] getBoneX: bone not found: ${boneName}`);
|
|
return 0;
|
|
}
|
|
|
|
const x = bone.applied.worldX;
|
|
const y = bone.applied.worldY;
|
|
const offsetX = this.x + this.propOffsetX;
|
|
const offsetAngle = this.angle + this.propOffsetAngle;
|
|
|
|
if (offsetAngle) {
|
|
const cos = Math.cos(offsetAngle);
|
|
const sin = Math.sin(offsetAngle);
|
|
return x * cos - y * sin + offsetX;
|
|
}
|
|
return x + offsetX;
|
|
}
|
|
|
|
public getBoneY (boneName: string): number {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] getBoneY: no skeleton');
|
|
return 0;
|
|
}
|
|
|
|
const bone = skeleton.findBone(boneName);
|
|
if (!bone) {
|
|
console.warn(`[Spine] getBoneY: bone not found: ${boneName}`);
|
|
return 0;
|
|
}
|
|
|
|
const x = bone.applied.worldX;
|
|
const y = bone.applied.worldY;
|
|
const offsetY = this.y + this.propOffsetY;
|
|
const offsetAngle = this.angle + this.propOffsetAngle;
|
|
|
|
if (offsetAngle) {
|
|
const cos = Math.cos(offsetAngle);
|
|
const sin = Math.sin(offsetAngle);
|
|
return x * sin + y * cos + offsetY;
|
|
}
|
|
return y + offsetY;
|
|
}
|
|
|
|
public getBoneRotation (boneName: string): number {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] getBoneRotation: no skeleton');
|
|
return 0;
|
|
}
|
|
|
|
const bone = skeleton.findBone(boneName);
|
|
if (!bone) {
|
|
console.warn(`[Spine] getBoneRotation: bone not found: ${boneName}`);
|
|
return 0;
|
|
}
|
|
|
|
const boneRotation = bone.applied.getWorldRotationX();
|
|
const offsetAngle = this.angle + this.propOffsetAngle;
|
|
|
|
return boneRotation + (offsetAngle * 180 / Math.PI);
|
|
}
|
|
|
|
public getBoneWorldX (boneName: string): number {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] getBoneWorldX: no skeleton');
|
|
return 0;
|
|
}
|
|
|
|
const bone = skeleton.findBone(boneName);
|
|
if (!bone) {
|
|
console.warn(`[Spine] getBoneWorldX: bone not found: ${boneName}`);
|
|
return 0;
|
|
}
|
|
|
|
const point = this.boneToC3WorldCoordinates(bone);
|
|
return point.x;
|
|
}
|
|
|
|
public getBoneWorldY (boneName: string): number {
|
|
const { skeleton } = this;
|
|
if (!skeleton) {
|
|
console.warn('[Spine] getBoneWorldY: no skeleton');
|
|
return 0;
|
|
}
|
|
|
|
const bone = skeleton.findBone(boneName);
|
|
if (!bone) {
|
|
console.warn(`[Spine] getBoneWorldY: bone not found: ${boneName}`);
|
|
return 0;
|
|
}
|
|
|
|
const point = this.boneToC3WorldCoordinates(bone);
|
|
return point.y;
|
|
}
|
|
|
|
public updateBonePose (c3X: number, c3Y: number, boneName: string) {
|
|
const bone = this.getBone(boneName);
|
|
if (!bone) return;
|
|
|
|
const { x, y } = this.c3WorldCoordinatesToBone(c3X, c3Y, bone);
|
|
bone.applied.x = x;
|
|
bone.applied.y = y;
|
|
}
|
|
|
|
private getBone (boneName: string | Bone) {
|
|
if (boneName instanceof spine.Bone) return boneName;
|
|
|
|
const { skeleton } = this;
|
|
if (!skeleton) return;
|
|
|
|
return skeleton.findBone(boneName);
|
|
}
|
|
|
|
/**********/
|
|
|
|
|
|
/*
|
|
* Slot, attachments
|
|
*/
|
|
|
|
private getSlot (slotName: string | Slot) {
|
|
if (slotName instanceof spine.Slot) return slotName;
|
|
|
|
const { skeleton } = this;
|
|
if (!skeleton) return;
|
|
|
|
return skeleton.findSlot(slotName);
|
|
}
|
|
|
|
public setAttachment (slotName: string, attachmentName: string | null) {
|
|
this.skeleton?.setAttachment(slotName, attachmentName);
|
|
}
|
|
|
|
/**********/
|
|
|
|
/*
|
|
* Skeleton
|
|
*/
|
|
|
|
public flipX (isFlippedX: boolean) {
|
|
this.isFlippedX = isFlippedX;
|
|
|
|
const { skeleton } = this;
|
|
if (skeleton) {
|
|
skeleton.scaleX = isFlippedX ? -this.propScaleX : this.propScaleX;
|
|
}
|
|
}
|
|
|
|
public setPhysicsMode (mode: 0 | 1 | 2 | 3) {
|
|
switch (mode) {
|
|
case 0: this.physicsMode = spine.Physics.none; break;
|
|
case 1: this.physicsMode = spine.Physics.reset; break;
|
|
case 2: this.physicsMode = spine.Physics.update; break;
|
|
case 3: this.physicsMode = spine.Physics.pose; break;
|
|
default: console.warn('[Spine] Invalid physics mode:', mode);
|
|
}
|
|
}
|
|
|
|
/**********/
|
|
|
|
};
|
|
|
|
C3.Plugins.EsotericSoftware_SpineConstruct3.Instance = SpineC3Instance;
|
|
|
|
export type { SpineC3Instance as SDKInstanceClass };
|