2025-11-17 15:52:39 +01:00

490 lines
14 KiB
TypeScript

// / <reference types="editor/sdk" />
import type { AnimationState, AssetLoader, Skeleton, SkeletonRendererCore, SpineBoundsProvider, TextureAtlas } from "@esotericsoftware/spine-construct3-lib";
import type { SpineC3PluginType } from "./type";
const SDK = globalThis.SDK;
const PLUGIN_CLASS = SDK.Plugins.EsotericSoftware_SpineConstruct3;
let spine: typeof globalThis.spine;
type SpineBoundsProviderType = "setup" | "animation-skin" | "AABB";
class SpineC3PluginInstance extends SDK.IWorldInstanceBase {
private layoutView?: SDK.UI.ILayoutView;
private renderer?: SDK.Gfx.IWebGLRenderer;
private currentAtlasFileSID = -1;
private textureAtlas?: TextureAtlas;
skeleton?: Skeleton;
state?: AnimationState;
skins: string[] = [];
animation?: string;
_inst!: SDK.IWorldInstance & { errors: SpineC3EditorError };
private assetLoader: AssetLoader;
private skeletonRenderer: SkeletonRendererCore;
// position mode
private positioningBounds = false;
private positionModePrevX = 0;
private positionModePrevY = 0;
private positionModePrevAngle = 0;
private spineBounds = {
x: 0,
y: 0,
width: 100,
height: 100,
};
// utils for drawing
private tempVertices = new Float32Array(4096);
private tempColors = new Float32Array(4096);
// errors
private errors: SpineC3EditorError = {};
constructor (sdkType: SDK.ITypeBase, inst: SDK.IWorldInstance) {
super(sdkType, inst);
if (!spine) spine = globalThis.spine;
spine.Skeleton.yDown = true;
this.assetLoader = new spine.AssetLoader();
this.skeletonRenderer = new spine.SkeletonRendererCore();
this._inst.errors = this.errors;
}
Release () {
}
OnCreate () {
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_PROVIDER_MOVE, false);
}
OnPlacedInLayout () {
this.OnMakeOriginalSize();
}
Draw (iRenderer: SDK.Gfx.IWebGLRenderer, iDrawParams: SDK.Gfx.IDrawParams) {
this.layoutView ||= iDrawParams.GetLayoutView();
this.renderer ||= iRenderer;
this.loadAtlas();
this.loadSkeleton();
const hasErrors = this.hasErrors();
if (this.skeleton && !hasErrors) {
this.setAnimation();
this.setSkin();
const rectX = this._inst.GetX();
const rectY = this._inst.GetY();
const rectAngle = this._inst.GetAngle();
let offsetX = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_X) as number;
let offsetY = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_Y) as number;
let offsetAngle = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_ANGLE) as number;
if (!this.positioningBounds) {
offsetX += rectX;
offsetY += rectY;
offsetAngle += rectAngle;
const baseScaleX = this._inst.GetWidth() / this.spineBounds.width;
const baseScaleY = this._inst.GetHeight() / this.spineBounds.height;
this.skeleton.scaleX = baseScaleX;
this.skeleton.scaleY = baseScaleY;
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_X, baseScaleX);
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_Y, baseScaleY);
} else {
offsetX += this.positionModePrevX;
offsetY += this.positionModePrevY;
offsetAngle += this.positionModePrevAngle;
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_X, offsetX - rectX);
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_Y, offsetY - rectY);
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_ANGLE, offsetAngle - rectAngle);
this.positionModePrevX = rectX;
this.positionModePrevY = rectY;
this.positionModePrevAngle = rectAngle;
this.skeleton.scaleX = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_X) as number;
this.skeleton.scaleY = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_Y) as number;
}
const cos = Math.cos(offsetAngle);
const sin = Math.sin(offsetAngle);
const inv255 = 1 / 255;
this.update(0);
let command = this.skeletonRenderer.render(this.skeleton);
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 dstIndex = i * 3;
const x = positions[srcIndex];
const y = positions[srcIndex + 1];
vertices[dstIndex] = x * cos - y * sin + offsetX;
vertices[dstIndex + 1] = x * sin + y * cos + offsetY;
vertices[dstIndex + 2] = 0;
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;
}
iRenderer.ResetColor();
iRenderer.SetBlendMode(spine.BlendingModeSpineToC3[blendMode]);
iRenderer.SetTextureFillMode();
iRenderer.SetTexture(command.texture.texture);
iRenderer.DrawMesh(
vertices.subarray(0, numVertices * 3),
uvs.subarray(0, numVertices * 2),
indices.subarray(0, numIndices),
c3colors,
);
command = command.next;
}
iRenderer.SetAlphaBlend();
iRenderer.SetColorFillMode();
iRenderer.SetColorRgba(0.25, 0, 0, 0.25);
iRenderer.LineQuad(this._inst.GetQuad());
iRenderer.Line(rectX, rectY, offsetX, offsetY);
// if (this.hasErrors()) {
// iRenderer.SetColorFillMode();
// iRenderer.SetColorRgba(1, 0, 0, .5);
// iRenderer.Quad(this._inst.GetQuad());
// }
} else {
const sdkType = this._sdkType as SpineC3PluginType;
const logo = sdkType.getSpineLogo(iRenderer);
if (logo) {
iRenderer.ResetColor();
iRenderer.SetAlphaBlend();
iRenderer.SetTexture(logo);
if (hasErrors) {
iRenderer.SetColorRgba(1, 0, 0, 1);
}
iRenderer.Quad(this._inst.GetQuad());
} else {
iRenderer.SetAlphaBlend();
iRenderer.SetColorFillMode();
if (this.HadTextureError())
iRenderer.SetColorRgba(0.25, 0, 0, 0.25);
else
iRenderer.SetColorRgba(0, 0, 0.1, 0.1);
iRenderer.Quad(this._inst.GetQuad());
}
}
}
async OnPropertyChanged (id: string, value: EditorPropertyValueType) {
console.log(`Prop change - Name: ${id} - Value: ${value}`);
if (id === PLUGIN_CLASS.PROP_ATLAS) {
this.textureAtlas?.dispose();
this.textureAtlas = undefined;
this.skins = [];
this.layoutView?.Refresh();
return;
}
if (id === PLUGIN_CLASS.PROP_SKELETON) {
this.skeleton = undefined;
this.skins = [];
this.layoutView?.Refresh();
return;
}
if (id === PLUGIN_CLASS.PROP_LOADER_SCALE) {
this.skeleton = undefined;
this.skins = [];
this.layoutView?.Refresh();
return;
}
if (id === PLUGIN_CLASS.PROP_SKIN) {
this.skins = [];
this.setSkin();
this.resetBounds();
this.layoutView?.Refresh();
return;
}
if (id === PLUGIN_CLASS.PROP_ANIMATION) {
this.setAnimation();
this.layoutView?.Refresh();
return;
}
if (id === PLUGIN_CLASS.PROP_BOUNDS_PROVIDER) {
this.resetBounds();
this.layoutView?.Refresh();
return
}
if (id === PLUGIN_CLASS.PROP_BOUNDS_PROVIDER_MOVE) {
value = value as boolean
if (value) {
this.positionModePrevX = this._inst.GetX();
this.positionModePrevY = this._inst.GetY();
this.positionModePrevAngle = this._inst.GetAngle();
} else {
const scaleX = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_X) as number;
const scaleY = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_Y) as number;
this.spineBounds.width = this._inst.GetWidth() / scaleX;
this.spineBounds.height = this._inst.GetHeight() / scaleY;
}
this.positioningBounds = value;
return
}
console.log("Prop change end");
}
private setAnimation () {
this.animation = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_ANIMATION) as string;
}
private setSkin () {
const { skeleton } = this;
if (!skeleton) return;
const propValue = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKIN) as string;
const skins = propValue === "" ? [] : propValue.split(",");
this.skins = skins;
if (skins.length === 0) {
skeleton.setSkin(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(propValue);
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);
}
private async loadSkeleton () {
if (!this.renderer || !this.textureAtlas) return;
if (this.skeleton) return;
console.log("Loading skeleton");
const propValue = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKELETON) as number;
const loaderScale = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_LOADER_SCALE) as number;
const skeletonData = await this.assetLoader.loadSkeletonEditor(propValue, this.textureAtlas, loaderScale, this._inst)
.catch((error) => {
console.log("ATLAS AND SKELETON NOT CORRESPONDING", error);
});
if (!skeletonData) return;
this.skeleton = new spine.Skeleton(skeletonData);
const animationStateData = new spine.AnimationStateData(skeletonData);
this.state = new spine.AnimationState(animationStateData);
this.setSkin();
this.update(0);
this.setBoundsFromBoundsProvider();
this.initBounds();
this.layoutView?.Refresh();
console.log("SKELETON LOADED");
}
private async loadAtlas () {
if (!this.renderer) return;
const propValue = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_ATLAS) as number;
if (this.currentAtlasFileSID === propValue) return;
this.currentAtlasFileSID = propValue;
console.log("Loading atlas");
const textureAtlas = await this.assetLoader.loadAtlasEditor(propValue, this._inst, this.renderer);
if (!textureAtlas) return;
this.textureAtlas = textureAtlas;
this.layoutView?.Refresh();
}
private setBoundsFromBoundsProvider () {
const propValue = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_PROVIDER) as SpineBoundsProviderType;
let spineBoundsProvider: SpineBoundsProvider;
if (propValue === "animation-skin") {
const { skins, animation } = this;
if ((skins && skins.length > 0) || animation) {
spineBoundsProvider = new spine.SkinsAndAnimationBoundsProvider(animation, skins);
} else {
return false;
}
} else if (propValue === "setup") {
spineBoundsProvider = new spine.SetupPoseBoundsProvider();
} else {
spineBoundsProvider = new spine.AABBRectangleBoundsProvider(0, 0, 100, 100);
}
this.spineBounds = spineBoundsProvider.calculateBounds(this);
return true;
}
private resetBounds () {
this.setBoundsFromBoundsProvider();
if (this.hasErrors()) return;
const { x, y, width, height } = this.spineBounds;
this._inst.SetOrigin(-x / width, -y / height);
this._inst.SetSize(width, height);
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_X, 0);
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_Y, 0);
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_ANGLE, 0);
return;
}
private initBounds () {
const offsetX = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_X) as number;
const offsetY = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_Y) as number;
const offsetAngle = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_ANGLE) as number;
const shiftedBounds = offsetX !== 0 || offsetY !== 0 || offsetAngle !== 0;
const scaleX = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_X) as number;
const scaleY = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_SKELETON_SCALE_Y) as number;
const scaledBounds = scaleX !== 1 || scaleY !== 1;
if (shiftedBounds || scaledBounds) {
this.spineBounds.width = this._inst.GetWidth() / scaleX;
this.spineBounds.height = this._inst.GetHeight() / scaleY;
return;
}
this.resetBounds();
}
private update (delta: number) {
const { state, skeleton } = this;
if (!skeleton || !state) return;
state.update(delta);
skeleton.update(delta);
state.apply(skeleton);
skeleton.updateWorldTransform(spine.Physics.update);
}
private setError (key: SpineC3EditorErrorType, condition: boolean, message: string) {
if (condition) {
this.errors[key] = message;
return;
}
delete this.errors[key];
}
private hasErrors () {
const { errors, skins, animation, spineBounds } = this;
const boundsType = this._inst.GetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_PROVIDER) as SpineBoundsProviderType;
this.setError(
"boundsAnimationSkinType",
boundsType === "animation-skin" && ((!skins || skins.length === 0) && !animation),
"Animation/Skin bounds provider requires one between skin and animation to be set."
);
this.setError(
"nonExistingAnimation",
Boolean(!animation || this.skeleton?.data.findAnimation(animation)) === false,
"Not existing animation"
);
const { width, height } = spineBounds;
this.setError(
"boundsNoDimension",
width <= 0 || height <= 0,
"A bounds cannot have negative dimensions. This might happen when the setup pose is empty. Try to set a skin and the Animation/Skin bounds provider."
);
return Object.keys(errors).length > 0;
}
GetTexture () {
const image = this.GetObjectType().GetImage();
return super.GetTexture(image);
}
IsOriginalSizeKnown () {
return true;
}
GetOriginalWidth () {
return this.spineBounds.width;
}
GetOriginalHeight () {
return this.spineBounds.height;
}
OnMakeOriginalSize () {
this._inst.SetSize(this.spineBounds.width, this.spineBounds.height);
}
HasDoubleTapHandler () {
return false;
}
OnDoubleTap () { }
LoadC2Property (_name: string, _valueString: string) {
return false;
}
};
type SpineC3EditorErrorType = "boundsAnimationSkinType" | "nonExistingAnimation" | "boundsNoDimension";
type SpineC3EditorError = Partial<Record<SpineC3EditorErrorType, string>>;
PLUGIN_CLASS.Instance = SpineC3PluginInstance;
export type { SpineC3PluginInstance as SDKEditorInstanceClass };