mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-12 02:02:42 +00:00
- Replace the third-party `[email protected]` with the official Esoteric Software runtime for PixiJS v7 (`@esotericsoftware/[email protected]`). - The new runtime only fully supports skeletons exported from **Spine 4.2**. Older exports (e.g. 4.0) may partially render but mesh deformations won't work due to a Spine JSON schema change (`deform` → `attachments`).
407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
namespace gdjs {
|
|
const isSpine = (obj: any): obj is spine.Spine =>
|
|
typeof spine !== 'undefined' && obj instanceof spine.Spine;
|
|
|
|
const isPointAttachment = (
|
|
attachment: spine.Attachment | null
|
|
): attachment is spine.PointAttachment =>
|
|
typeof spine !== 'undefined' &&
|
|
!!attachment &&
|
|
attachment instanceof spine.PointAttachment;
|
|
|
|
/**
|
|
* @category Renderers > Spine
|
|
*/
|
|
export class SpineRuntimeObjectPixiRenderer {
|
|
private _object: gdjs.SpineRuntimeObject;
|
|
private _rendererObject: spine.Spine | PIXI.Container;
|
|
private _isAnimationComplete = true;
|
|
|
|
/**
|
|
* @param runtimeObject The object to render
|
|
* @param instanceContainer The container in which the object is
|
|
*/
|
|
constructor(
|
|
runtimeObject: gdjs.SpineRuntimeObject,
|
|
private instanceContainer: gdjs.RuntimeInstanceContainer
|
|
) {
|
|
this._object = runtimeObject;
|
|
this._rendererObject = this.constructRendererObject();
|
|
if (isSpine(this._rendererObject)) {
|
|
this._rendererObject.autoUpdate = false;
|
|
}
|
|
|
|
this.updatePosition();
|
|
this.updateAngle();
|
|
this.updateOpacity();
|
|
this.updateScale();
|
|
|
|
instanceContainer
|
|
.getLayer('')
|
|
.getRenderer()
|
|
.addRendererObject(this._rendererObject, runtimeObject.getZOrder());
|
|
}
|
|
|
|
updateAnimation(timeDelta: float) {
|
|
if (!isSpine(this._rendererObject)) {
|
|
return;
|
|
}
|
|
this._rendererObject.update(timeDelta);
|
|
}
|
|
|
|
getRendererObject(): spine.Spine | PIXI.Container {
|
|
return this._rendererObject;
|
|
}
|
|
|
|
getOriginOffset(): PIXI.Point {
|
|
if (!isSpine(this._rendererObject)) return new PIXI.Point(0, 0);
|
|
|
|
const localBounds = this._rendererObject.getLocalBounds(undefined, true);
|
|
|
|
return new PIXI.Point(
|
|
localBounds.x * this._rendererObject.scale.x,
|
|
localBounds.y * this._rendererObject.scale.y
|
|
);
|
|
}
|
|
|
|
onDestroy(): void {
|
|
this._rendererObject.destroy();
|
|
}
|
|
|
|
updateScale(): void {
|
|
const scaleX = Math.max(
|
|
this._object._originalScale * this._object.getScaleX(),
|
|
0
|
|
);
|
|
const scaleY = Math.max(
|
|
this._object._originalScale * this._object.getScaleY(),
|
|
0
|
|
);
|
|
this._rendererObject.scale.x = this._object.isFlippedX()
|
|
? -scaleX
|
|
: scaleX;
|
|
this._rendererObject.scale.y = this._object.isFlippedY()
|
|
? -scaleY
|
|
: scaleY;
|
|
}
|
|
|
|
updatePosition(): void {
|
|
this._rendererObject.position.x = this._object.x;
|
|
this._rendererObject.position.y = this._object.y;
|
|
}
|
|
|
|
updateAngle(): void {
|
|
this._rendererObject.rotation = gdjs.toRad(this._object.angle);
|
|
}
|
|
|
|
updateOpacity(): void {
|
|
this._rendererObject.alpha = this._object.getOpacity() / 255;
|
|
}
|
|
|
|
getWidth(): float {
|
|
return this._rendererObject.width;
|
|
}
|
|
|
|
getHeight(): float {
|
|
return this._rendererObject.height;
|
|
}
|
|
|
|
setWidth(width: float): void {
|
|
this._rendererObject.width = width;
|
|
}
|
|
|
|
setHeight(height: float): void {
|
|
this._rendererObject.height = height;
|
|
}
|
|
|
|
getUnscaledWidth(): float {
|
|
return Math.abs(
|
|
(this._rendererObject.width * this._object._originalScale) /
|
|
this._rendererObject.scale.x
|
|
);
|
|
}
|
|
|
|
getUnscaledHeight(): float {
|
|
return Math.abs(
|
|
(this._rendererObject.height * this._object._originalScale) /
|
|
this._rendererObject.scale.y
|
|
);
|
|
}
|
|
|
|
setMixing(from: string, to: string, duration: number): void {
|
|
if (!isSpine(this._rendererObject)) return;
|
|
|
|
this._rendererObject.state.data.setMix(from, to, duration);
|
|
}
|
|
|
|
setAnimation(animation: string, loop: boolean): void {
|
|
if (!isSpine(this._rendererObject)) return;
|
|
|
|
const onCompleteListener: spine.AnimationStateListener = {
|
|
complete: () => {
|
|
this._isAnimationComplete = true;
|
|
(this._rendererObject as spine.Spine).state.removeListener(
|
|
onCompleteListener
|
|
);
|
|
},
|
|
};
|
|
|
|
this._isAnimationComplete = false;
|
|
this._rendererObject.state.addListener(onCompleteListener);
|
|
this._rendererObject.state.setAnimation(0, animation, loop);
|
|
this._rendererObject.update(0);
|
|
}
|
|
|
|
getAnimationDuration(sourceAnimationName: string): number {
|
|
if (!isSpine(this._rendererObject)) {
|
|
return 0;
|
|
}
|
|
const animation =
|
|
this._rendererObject.skeleton.data.findAnimation(sourceAnimationName);
|
|
return animation ? animation.duration : 0;
|
|
}
|
|
|
|
getAnimationElapsedTime(): number {
|
|
if (!isSpine(this._rendererObject)) {
|
|
return 0;
|
|
}
|
|
const tracks = this._rendererObject.state.tracks;
|
|
if (tracks.length === 0) {
|
|
return 0;
|
|
}
|
|
// Only one track is used.
|
|
const track = tracks[0];
|
|
if (!track) {
|
|
return 0;
|
|
}
|
|
return track.getAnimationTime();
|
|
}
|
|
|
|
setAnimationElapsedTime(time: number): void {
|
|
if (!isSpine(this._rendererObject)) {
|
|
return;
|
|
}
|
|
const tracks = this._rendererObject.state.tracks;
|
|
if (tracks.length === 0) {
|
|
return;
|
|
}
|
|
const track = tracks[0];
|
|
if (!track) return;
|
|
track.trackTime = time;
|
|
}
|
|
|
|
isAnimationComplete(): boolean {
|
|
if (!isSpine(this._rendererObject)) {
|
|
return true;
|
|
}
|
|
const track = this._rendererObject.state.tracks[0];
|
|
if (!track) {
|
|
return true;
|
|
}
|
|
return this._isAnimationComplete && !track.loop;
|
|
}
|
|
|
|
getPointAttachmentPosition(
|
|
attachmentName: string,
|
|
slotName?: string
|
|
): spine.Vector2 {
|
|
if (!slotName) {
|
|
slotName = attachmentName;
|
|
}
|
|
if (!isSpine(this._rendererObject)) {
|
|
return new spine.Vector2(
|
|
this._rendererObject.x,
|
|
this._rendererObject.y
|
|
);
|
|
}
|
|
|
|
const { slot, attachment } =
|
|
SpineRuntimeObjectPixiRenderer.getSlotAndAttachmentFromRenderObject(
|
|
attachmentName,
|
|
slotName,
|
|
this._rendererObject
|
|
);
|
|
|
|
const worldPoint = attachment.computeWorldPosition(
|
|
slot.bone,
|
|
new spine.Vector2()
|
|
);
|
|
const transformed = new PIXI.Matrix()
|
|
.rotate(this._rendererObject.rotation)
|
|
.scale(this._rendererObject.scale.x, this._rendererObject.scale.y)
|
|
.translate(this._rendererObject.x, this._rendererObject.y)
|
|
.apply({ x: worldPoint.x, y: worldPoint.y });
|
|
|
|
return new spine.Vector2(transformed.x, transformed.y);
|
|
}
|
|
|
|
getPointAttachmentRotation(
|
|
attachmentName: string,
|
|
slotName?: string,
|
|
isWorld?: boolean
|
|
): number {
|
|
if (!slotName) {
|
|
slotName = attachmentName;
|
|
}
|
|
if (!isSpine(this._rendererObject)) {
|
|
return this._object.angle;
|
|
}
|
|
|
|
const { slot, attachment } =
|
|
SpineRuntimeObjectPixiRenderer.getSlotAndAttachmentFromRenderObject(
|
|
attachmentName,
|
|
slotName,
|
|
this._rendererObject
|
|
);
|
|
|
|
if (isWorld) {
|
|
return (
|
|
gdjs.toDegrees(this._rendererObject.rotation) +
|
|
attachment.computeWorldRotation(slot.bone)
|
|
);
|
|
}
|
|
|
|
return slot.bone.rotation;
|
|
}
|
|
|
|
getPointAttachmentScale(
|
|
attachmentName: string,
|
|
slotName?: string,
|
|
isWorld?: boolean
|
|
): spine.Vector2 {
|
|
if (!slotName) {
|
|
slotName = attachmentName;
|
|
}
|
|
if (!isSpine(this._rendererObject)) {
|
|
return new spine.Vector2(
|
|
this._rendererObject.scale.x,
|
|
this._rendererObject.scale.y
|
|
);
|
|
}
|
|
|
|
const { slot } =
|
|
SpineRuntimeObjectPixiRenderer.getSlotAndAttachmentFromRenderObject(
|
|
attachmentName,
|
|
slotName,
|
|
this._rendererObject
|
|
);
|
|
|
|
let scaleX = slot.bone.scaleX;
|
|
let scaleY = slot.bone.scaleY;
|
|
|
|
if (isWorld) {
|
|
let parent = slot.bone.parent;
|
|
while (parent) {
|
|
scaleX *= parent.scaleX;
|
|
scaleY *= parent.scaleY;
|
|
parent = parent.parent;
|
|
}
|
|
|
|
scaleX *= this._rendererObject.scale.x;
|
|
scaleY *= this._rendererObject.scale.y;
|
|
}
|
|
|
|
return new spine.Vector2(scaleX, scaleY);
|
|
}
|
|
|
|
setSkin(skinName: string): void {
|
|
if (!isSpine(this._rendererObject)) return;
|
|
|
|
const skeleton = this._rendererObject.skeleton;
|
|
|
|
const skin = skeleton.data.findSkin(skinName);
|
|
if (!skin) {
|
|
console.warn(
|
|
`[Spine] Skin "${skinName}" not found. Available skins: ${this.getAvailableSkins().join(', ')}`
|
|
);
|
|
return;
|
|
}
|
|
skeleton.setSkinByName(skinName);
|
|
skeleton.setSlotsToSetupPose();
|
|
this._rendererObject.update(0);
|
|
}
|
|
|
|
getSkin(): string {
|
|
if (!isSpine(this._rendererObject)) return '';
|
|
return this._rendererObject.skeleton.skin
|
|
? this._rendererObject.skeleton.skin.name
|
|
: '';
|
|
}
|
|
|
|
getAvailableSkins(): string[] {
|
|
if (!isSpine(this._rendererObject)) return [];
|
|
return this._rendererObject.skeleton.data.skins.map((s) => s.name);
|
|
}
|
|
|
|
private constructRendererObject(): spine.Spine | PIXI.Container {
|
|
const game = this.instanceContainer.getGame();
|
|
const spineManager = game.getSpineManager();
|
|
|
|
if (
|
|
!spineManager ||
|
|
!spineManager.isSpineLoaded(this._object.spineResourceName)
|
|
) {
|
|
return new PIXI.Container();
|
|
}
|
|
|
|
const aliases = spineManager.getSpineAliases(
|
|
this._object.spineResourceName
|
|
);
|
|
if (!aliases) {
|
|
return new PIXI.Container();
|
|
}
|
|
|
|
try {
|
|
const spineObject = spine.Spine.from({
|
|
skeleton: aliases.skeletonAlias,
|
|
atlas: aliases.atlasAlias,
|
|
autoUpdate: false,
|
|
});
|
|
|
|
const version = spineObject.skeleton.data.version;
|
|
if (version && !version.startsWith('4.2')) {
|
|
console.warn(
|
|
`[Spine] Resource '${this._object.spineResourceName}' was exported with Spine ${version}. ` +
|
|
`The runtime requires Spine 4.2. Animations may not work correctly.`
|
|
);
|
|
}
|
|
|
|
return spineObject;
|
|
} catch (error) {
|
|
console.error(
|
|
`Unable to instantiate Spine container for resource '${this._object.spineResourceName}':`,
|
|
error
|
|
);
|
|
return new PIXI.Container();
|
|
}
|
|
}
|
|
|
|
private static getSlotAndAttachmentFromRenderObject(
|
|
attachmentName: string,
|
|
slotName: string,
|
|
renderObject: spine.Spine
|
|
): { slot: spine.Slot; attachment: spine.PointAttachment } {
|
|
const slot = renderObject.skeleton.findSlot(slotName);
|
|
if (!slot) {
|
|
throw new Error(
|
|
`Unable to find ${slotName} slot name for ${attachmentName} point attachment.`
|
|
);
|
|
}
|
|
const attachment = renderObject.skeleton.getAttachmentByName(
|
|
slotName,
|
|
attachmentName
|
|
);
|
|
if (!isPointAttachment(attachment)) {
|
|
throw new Error(
|
|
`Unable to find ${attachmentName} point attachment with ${slotName} slot name.`
|
|
);
|
|
}
|
|
return { slot, attachment };
|
|
}
|
|
}
|
|
/**
|
|
* @category Renderers > Spine
|
|
*/
|
|
export const SpineRuntimeObjectRenderer = SpineRuntimeObjectPixiRenderer;
|
|
}
|