Add enable collision property and logic. Add AddEmptyAnimation and ClearTrack actions.

This commit is contained in:
Davide Tantillo 2026-01-23 17:17:29 +01:00
parent 24e8a86081
commit 7dd315e2dd
10 changed files with 467 additions and 15 deletions

View File

@ -261,7 +261,7 @@ abstract class C3SkeletonRenderer<
}
}
this.renderGameObjectBounds(x, y, quad);
this.renderGameObjectBounds(x, y, quad, false);
}
protected abstract setColor (r: number, g: number, b: number, a: number): void;
@ -274,7 +274,7 @@ abstract class C3SkeletonRenderer<
};
protected abstract renderSkeleton (vertices: Float32Array, uvs: Float32Array, indices: Uint16Array, colors: Float32Array, texture: Texture, blendMode: BlendMode): void;
public abstract renderGameObjectBounds (x: number, y: number, quad: DOMQuad | SDK.Quad): void;
public abstract renderGameObjectBounds (x: number, y: number, quad: DOMQuad | SDK.Quad, spriteBody: boolean): void;
protected circle (x: number, y: number, radius: number) {
let segments = Math.max(1, (6 * MathUtils.cbrt(radius)) | 0);
@ -432,11 +432,14 @@ export class C3RendererEditor extends C3SkeletonRenderer<SDK.Gfx.IWebGLRenderer,
this.renderer.DrawMesh(vertices, uvs, indices, colors);
};
public renderGameObjectBounds (x: number, y: number, quad: SDK.Quad): void {
public renderGameObjectBounds (x: number, y: number, quad: SDK.Quad, spriteBody: boolean): void {
const { renderer, matrix } = this;
renderer.SetAlphaBlend();
renderer.SetColorFillMode();
renderer.SetColorRgba(0.25, 0, 0, 0.25);
if (spriteBody)
renderer.SetColorRgba(0, 0, 0.25, 0.25);
else
renderer.SetColorRgba(0.25, 0, 0, 0.25);
renderer.LineQuad(quad);
renderer.Line(x, y, matrix.tx, matrix.ty);
}

View File

@ -0,0 +1,210 @@
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated April 5, 2025. Replaces all prior versions.
*
* Copyright (c) 2013-2025, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
interface ModalButton<T> {
text: string;
color?: string;
value?: T;
style?: 'primary' | 'secondary';
}
interface ModalOptions<T> {
darkMode: boolean;
title: string;
text: string;
buttons: ModalButton<T>[];
maxWidth?: number;
}
export function showModal<T> (options: ModalOptions<T>): Promise<T | undefined> {
return new Promise((resolve) => {
const { title, text, buttons, darkMode = false } = options;
const theme = darkMode ? {
overlayBg: 'rgba(0, 0, 0, 0.5)',
captionBg: 'rgb(71, 71, 71)', // gray9
captionText: 'rgb(214, 214, 214)', // gray27
contentBg: 'rgb(87, 87, 87)', // gray11
contentText: 'rgb(214, 214, 214)', // gray27
buttonBg: 'rgb(71, 71, 71)', // gray9
buttonText: 'rgb(214, 214, 214)', // gray27
buttonBorder: 'rgb(56, 56, 56)', // gray7
buttonHoverBg: 'rgb(79, 79, 79)', // gray10
closeColor: 'rgb(168, 168, 168)', // gray21
} : {
overlayBg: 'rgba(0, 0, 0, 0.3)',
captionBg: 'rgb(247, 247, 247)', // gray31
captionText: 'rgb(94, 94, 94)', // gray12
contentBg: 'rgb(232, 232, 232)', // gray29
contentText: 'rgb(94, 94, 94)', // gray12
buttonBg: 'rgb(222, 222, 222)', // gray28
buttonText: 'rgb(94, 94, 94)', // gray12
buttonBorder: 'rgb(199, 199, 199)', // gray25
buttonHoverBg: 'rgb(214, 214, 214)', // gray27
closeColor: 'rgb(94, 94, 94)', // gray12
};
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: ${theme.overlayBg};
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
font-size: 14px;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: ${theme.contentBg};
border-radius: 6px;
min-width: 200px;
max-width: 550px;
display: flex;
flex-direction: column;
filter: drop-shadow(0 4px 5px rgba(10,10,10,0.35)) drop-shadow(0 2px 1px rgba(10,10,10,0.5));
`;
const caption = document.createElement('div');
caption.style.cssText = `
background: ${theme.captionBg};
color: ${theme.captionText};
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
border-radius: 6px 6px 0 0;
`;
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="${theme.closeColor}"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`;
closeBtn.style.cssText = `
background: transparent;
border: none;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
opacity: 0.7;
`;
closeBtn.onmouseover = () => { closeBtn.style.opacity = '1'; };
closeBtn.onmouseout = () => { closeBtn.style.opacity = '0.7'; };
caption.appendChild(titleSpan);
caption.appendChild(closeBtn);
const contents = document.createElement('div');
contents.style.cssText = `
padding: 12px 14px;
color: ${theme.contentText};
line-height: 1.4;
`;
contents.textContent = text;
const footer = document.createElement('div');
footer.style.cssText = `
padding: 8px 14px 12px;
display: flex;
justify-content: flex-end;
gap: 6px;
`;
const cleanup = () => {
document.removeEventListener('keydown', handleKeyDown);
overlay.remove();
};
closeBtn.addEventListener('click', () => {
cleanup();
resolve(undefined);
});
buttons.forEach((buttonConfig, index) => {
const btn = document.createElement('button');
btn.textContent = buttonConfig.text;
btn.style.cssText = `
padding: 4px 14px;
border: 1px solid ${theme.buttonBorder};
border-radius: 3px;
background: ${theme.buttonBg};
color: ${theme.buttonText};
font-size: 14px;
font-family: inherit;
cursor: pointer;
`;
btn.onmouseover = () => { btn.style.background = theme.buttonHoverBg; };
btn.onmouseout = () => { btn.style.background = theme.buttonBg; };
btn.addEventListener('click', () => {
cleanup();
resolve(buttonConfig.value);
});
footer.appendChild(btn);
if (index === buttons.length - 1) {
setTimeout(() => btn.focus(), 0);
}
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
cleanup();
resolve(undefined);
}
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
cleanup();
resolve(undefined);
}
};
document.addEventListener('keydown', handleKeyDown);
dialog.appendChild(caption);
dialog.appendChild(contents);
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
});
}

View File

@ -3,4 +3,5 @@ export * from './AssetLoader.js';
export * from './C3Matrix.js';
export * from './C3SkeletonRenderer.js';
export * from './C3Texture.js';
export * from './CustomUI.js';
export * from './SpineBoundsProvider.js';

View File

@ -172,6 +172,25 @@
}
]
},
{
"id": "add-empty-animation",
"scriptName": "AddEmptyAnimation",
"highlight": false,
"params": [
{
"id": "track",
"type": "number"
},
{
"id": "mix-duration",
"type": "number"
},
{
"id": "delay",
"type": "number"
}
]
},
{
"id": "set-attachment",
"scriptName": "SetAttachment",
@ -328,6 +347,17 @@
}
]
},
{
"id": "clear-track",
"scriptName": "ClearTrack",
"highlight": false,
"params": [
{
"id": "track-index",
"type": "number"
}
]
},
{
"id": "set-slot-color",
"scriptName": "SetSlotColor",

View File

@ -33,6 +33,10 @@ C3.Plugins.EsotericSoftware_SpineConstruct3.Acts =
this.stop();
},
AddEmptyAnimation (this: SDKInstanceClass, track: number, mixDuration: number, delay: number) {
this.addEmptyAnimation(track, mixDuration, delay);
},
SetEmptyAnimation (this: SDKInstanceClass, track: number, mixDuration: number) {
this.setEmptyAnimation(track, mixDuration);
},
@ -81,6 +85,10 @@ C3.Plugins.EsotericSoftware_SpineConstruct3.Acts =
this.setTrackMixBlend(mixBlend, trackIndex);
},
ClearTrack (this: SDKInstanceClass, trackIndex: number) {
this.clearTrack(trackIndex);
},
SetSlotColor (this: SDKInstanceClass, slotName: string, color: string) {
this.setSlotColor(slotName, color);
},

View File

@ -18,8 +18,11 @@ class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
propScaleY = 1;
propDebugSkeleton = false;
propBoundsProvider: SpineBoundsProviderType = "setup";
propEnableCollision = false;
isFlippedX = false;
collisionSpriteInstance?: IWorldInstance;
collisionSpriteClassName = "";
isPlaying = true;
animationSpeed = 1.0;
physicsMode = spine.Physics.update;
@ -79,16 +82,19 @@ class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
this.propSkin = skinProp === "" ? [] : skinProp.split(",");
this.propAnimation = properties[4] as string;
this.propDebugSkeleton = properties[5] as boolean;
const boundsProviderIndex = properties[6] as number;
this.propEnableCollision = properties[6] as boolean;
const boundsProviderIndex = properties[7] as number;
this.propBoundsProvider = boundsProviderIndex === 0 ? "setup" : "animation-skin";
// properties[7] is PROP_BOUNDS_PROVIDER_MOVE
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;
// properties[8] is PROP_BOUNDS_PROVIDER_MOVE
this.propOffsetX = properties[9] as number;
this.propOffsetY = properties[10] as number;
this.propOffsetAngle = properties[11] as number;
this.propScaleX = properties[12] as number;
this.propScaleY = properties[13] as number;
}
this.collisionSpriteClassName = `${this.objectType.name}_CollisionBody`;
this.assetLoader = new spine.AssetLoader();
this.matrix = new spine.C3Matrix();
@ -129,6 +135,8 @@ class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
this.width / this.spineBounds.width * this.propScaleX * (this.isFlippedX ? -1 : 1),
this.height / this.spineBounds.height * this.propScaleY);
this.updateCollisionSprite();
if (this.isPlaying) this.update(this.dt);
}
@ -394,6 +402,11 @@ class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
this.skeleton = undefined;
this.state = undefined;
this.dragHandleDispose();
if (this.collisionSpriteInstance) {
this.collisionSpriteInstance.destroy();
this.collisionSpriteInstance = undefined;
}
}
/**********/
@ -443,11 +456,34 @@ class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
this.update(0);
this.createCollisionSprite();
this.skeletonLoaded = true;
this._trigger(C3.Plugins.EsotericSoftware_SpineConstruct3.Cnds.OnSkeletonLoaded);
}
}
private createCollisionSprite () {
if (!this.propEnableCollision) return;
const objectType = (this.runtime.objects as Record<string, IObjectType<IWorldInstance>>)[this.collisionSpriteClassName];
if (!objectType)
throw new Error(`[Spine] Collision sprite object type "${this.collisionSpriteClassName}" not found`);
this.collisionSpriteInstance = objectType.createInstance(this.layer.name, this.x, this.y);
this.collisionSpriteInstance.setOrigin(this.originX, this.originY);
}
private updateCollisionSprite () {
if (!this.collisionSpriteInstance) return;
this.collisionSpriteInstance.x = this.x;
this.collisionSpriteInstance.y = this.y;
this.collisionSpriteInstance.width = this.width;
this.collisionSpriteInstance.height = this.height;
this.collisionSpriteInstance.angleDegrees = this.angleDegrees;
}
private calculateBounds () {
const { skeleton } = this;
if (!skeleton) return;
@ -489,7 +525,11 @@ class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
this.isPlaying = true;
}
public setEmptyAnimation (track: number, mixDuration = 0) {
public addEmptyAnimation (track: number, mixDuration: number, delay: number) {
this.state?.addEmptyAnimation(track, mixDuration, delay);
}
public setEmptyAnimation (track: number, mixDuration: number) {
this.state?.setEmptyAnimation(track, mixDuration);
}
@ -579,6 +619,15 @@ class SpineC3Instance extends globalThis.ISDKWorldInstanceBase {
}
}
public clearTrack (track: number) {
const { state } = this;
if (!state) return;
if (track === -1)
state.clearTracks();
else
state.clearTrack(track);
}
private triggerAnimationEvent (eventName: string, track: number, animation: string, event?: Event) {
this.triggeredEventTrack = track;
this.triggeredEventAnimation = animation;

View File

@ -141,7 +141,7 @@ class SpineC3PluginInstance extends SDK.IWorldInstanceBase {
this.positioningBounds = false;
this.resetBounds(true);
this.layoutView?.Refresh();
return
return;
}
if (id === PLUGIN_CLASS.PROP_BOUNDS_PROVIDER_MOVE) {
@ -154,7 +154,12 @@ class SpineC3PluginInstance extends SDK.IWorldInstanceBase {
this.positionModePrevHeight = this._inst.GetHeight();
}
this.positioningBounds = value;
return
return;
}
if (id === PLUGIN_CLASS.PROP_ENABLE_COLLISION) {
this.managePropCollision(value as boolean);
return;
}
}
@ -203,7 +208,7 @@ class SpineC3PluginInstance extends SDK.IWorldInstanceBase {
const quad = _inst.GetQuad();
if (_inst.GetPropertyValue(PLUGIN_CLASS.PROP_DEBUG_SKELETON) as boolean)
this.skeletonRenderer.drawDebug(skeleton, rectX, rectY, quad);
this.skeletonRenderer.renderGameObjectBounds(rectX, rectY, quad);
this.skeletonRenderer.renderGameObjectBounds(rectX, rectY, quad, _inst.GetPropertyValue(PLUGIN_CLASS.PROP_ENABLE_COLLISION) as boolean);
} else {
iRenderer.SetAlphaBlend();
@ -513,6 +518,58 @@ class SpineC3PluginInstance extends SDK.IWorldInstanceBase {
this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_BOUNDS_OFFSET_ANGLE, value);
}
private async managePropCollision (value: boolean) {
const project = this._inst.GetProject();
const objectType = this._inst.GetObjectType();
const objectTypeName = objectType.GetName();
const collisionSpriteName = `${objectTypeName}_CollisionBody`;
const spriteType = project.GetObjectTypeByName(collisionSpriteName);
const type = PLUGIN_CLASS.PROP_ENABLE_COLLISION;
if (!value) {
for (const inst of objectType.GetAllInstances()) {
if (inst !== this._inst && inst.GetPropertyValue(PLUGIN_CLASS.PROP_ENABLE_COLLISION) as boolean) return;
}
if (spriteType) {
const action = await spine.showModal({
darkMode: false,
maxWidth: 500,
title: this.lang(`showModal.${type}.title`),
text: this.lang(`showModal.${type}.text`, [collisionSpriteName]),
buttons: [
{
text: this.lang(`showModal.${type}.buttons.${0}`),
value: 'disable'
},
{
text: this.lang(`showModal.${type}.buttons.${1}`, [collisionSpriteName]),
color: '#d9534f',
value: 'delete'
}
]
});
switch (action) {
case 'disable': break;
case 'delete': spriteType.Delete(); break;
default: this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_ENABLE_COLLISION, true); break;
}
}
return;
}
if (!spriteType) await project.CreateObjectType("Sprite", collisionSpriteName);
}
private lang (stringKey: string, interpolate: (string | number)[] = []): string {
const pluginContext = "plugins.esotericsoftware_spineconstruct3.custom_ui.";
let intlString = globalThis.lang(`${pluginContext}${stringKey}`);
interpolate.forEach((toInterpolate, index) => {
intlString = intlString.replace(`{${index}}`, `${toInterpolate}`);
})
return intlString;
}
GetTexture () {
const image = this.GetObjectType().GetImage();
return super.GetTexture(image);

View File

@ -58,6 +58,10 @@
"name": "Debug skeleton",
"desc": "Draw debug visualization of the skeleton bones"
},
"spine-enable-collision": {
"name": "Enable collision",
"desc": "Enable collision detection by creating a Sprite collision body"
},
"spine-bounds-offset-x": {
"name": "Offset X",
"desc": "Offset X"
@ -249,6 +253,25 @@
}
}
},
"add-empty-animation": {
"list-name": "Add empty animation",
"display-text": "Add empty animation on track {0} with mix duration {1} and delay {2}",
"description": "Add an empty animation to the animation queue on a specific track",
"params": {
"track": {
"name": "Track",
"desc": "Track index"
},
"mix-duration": {
"name": "Mix duration",
"desc": "Duration in seconds to mix from the current animation to empty"
},
"delay": {
"name": "Delay",
"desc": "Delay in seconds before the animation starts"
}
}
},
"set-attachment": {
"list-name": "Set attachment",
"display-text": "Set slot {0} attachment to {1}",
@ -418,6 +441,17 @@
}
}
},
"clear-track": {
"list-name": "Clear track(s)",
"display-text": "Clear track {0}",
"description": "Clear te given track, or all tracks if -1",
"params": {
"track-index": {
"name": "Track index",
"desc": "Track index to clear. -1 to clear all tracks."
}
}
},
"set-slot-color": {
"list-name": "Set slot color",
"display-text": "Set slot {0} color to {1}",
@ -643,6 +677,18 @@
}
}
}
},
"custom_ui": {
"showModal": {
"spine-enable-collision": {
"title": "Delete Sprite Object Type",
"text": "This is the last instance of {0}. You can delete the {0} or just disable the Sprite collision body. Deleting the Sprite collision body will remove all related ACEs in the event sheet, if there's any. How do you want to proceed?",
"buttons": {
"0": "Just disable",
"1": "Disable and delete {0}"
}
}
}
}
}
}

View File

@ -58,6 +58,10 @@
"name": "调试骨架",
"desc": "绘制骨架骨骼的调试可视化"
},
"spine-enable-collision": {
"name": "启用碰撞",
"desc": "通过创建精灵碰撞体启用碰撞检测"
},
"spine-bounds-offset-x": {
"name": "X偏移",
"desc": "X偏移"
@ -249,6 +253,25 @@
}
}
},
"add-empty-animation": {
"list-name": "添加空动画",
"display-text": "在轨道{0}上添加空动画,混合时长{1},延迟{2}",
"description": "将空动画添加到特定轨道的动画队列中",
"params": {
"track": {
"name": "轨道",
"desc": "轨道索引"
},
"mix-duration": {
"name": "混合时长",
"desc": "从当前动画混合到空动画的持续时间(秒)"
},
"delay": {
"name": "延迟",
"desc": "动画开始前的延迟时间(秒)"
}
}
},
"set-attachment": {
"list-name": "设置附件",
"display-text": "将插槽{0}的附件设置为{1}",
@ -418,6 +441,17 @@
}
}
},
"clear-track": {
"list-name": "清除轨道",
"display-text": "清除轨道{0}",
"description": "清除给定的轨道,如果为-1则清除所有轨道",
"params": {
"track-index": {
"name": "轨道索引",
"desc": "要清除的轨道索引。-1表示清除所有轨道。"
}
}
},
"set-slot-color": {
"list-name": "设置插槽颜色",
"display-text": "设置插槽{0}的颜色为{1}",
@ -643,6 +677,18 @@
}
}
}
},
"custom_ui": {
"showModal": {
"spine-enable-collision": {
"title": "删除精灵对象类型",
"text": "这是{0}的最后一个实例。您可以删除{0}或仅禁用精灵碰撞体。删除精灵碰撞体将移除事件表中所有相关的ACE如果有。您想如何继续",
"buttons": {
"0": "仅禁用",
"1": "禁用并删除{0}"
}
}
}
}
}
}

View File

@ -34,6 +34,7 @@ const PLUGIN_CLASS = class SpineC3Plugin extends SDK.IPluginBase {
static PROP_SKELETON_OFFSET_SCALE_X = "spine-offset-scale-x";
static PROP_SKELETON_OFFSET_SCALE_Y = "spine-offset-scale-y";
static PROP_DEBUG_SKELETON = "spine-debug-skeleton";
static PROP_ENABLE_COLLISION = "spine-enable-collision";
static TYPE_BOUNDS_SETUP: SpineBoundsProviderType = "setup";
static TYPE_BOUNDS_ANIMATION_SKIN: SpineBoundsProviderType = "animation-skin";
@ -80,6 +81,7 @@ const PLUGIN_CLASS = class SpineC3Plugin extends SDK.IPluginBase {
new SDK.PluginProperty("text", SpineC3Plugin.PROP_SKIN, ""),
new SDK.PluginProperty("text", SpineC3Plugin.PROP_ANIMATION, ""),
new SDK.PluginProperty("check", SpineC3Plugin.PROP_DEBUG_SKELETON, false),
new SDK.PluginProperty("check", SpineC3Plugin.PROP_ENABLE_COLLISION, false),
new SDK.PluginProperty("group", SpineC3Plugin.PROP_BOUNDS_PROVIDER_GROUP),
new SDK.PluginProperty("combo", SpineC3Plugin.PROP_BOUNDS_PROVIDER, {