From 3fa9330c83a26d7df5008ec14bf2c695447f54cc Mon Sep 17 00:00:00 2001 From: Davide Tantillo Date: Wed, 28 Jan 2026 12:03:19 +0100 Subject: [PATCH] Add link property for animation and skin selection. --- .../spine-construct3-lib/src/CustomUI.ts | 620 ++++++++++++++---- spine-ts/spine-construct3/src/instance.ts | 61 +- spine-ts/spine-construct3/src/lang/en-US.json | 5 + spine-ts/spine-construct3/src/lang/zh-CN.json | 5 + spine-ts/spine-construct3/src/plugin.ts | 7 + 5 files changed, 565 insertions(+), 133 deletions(-) diff --git a/spine-ts/spine-construct3/spine-construct3-lib/src/CustomUI.ts b/spine-ts/spine-construct3/spine-construct3-lib/src/CustomUI.ts index 0ffb3e643..2596dc444 100644 --- a/spine-ts/spine-construct3/spine-construct3-lib/src/CustomUI.ts +++ b/spine-ts/spine-construct3/spine-construct3-lib/src/CustomUI.ts @@ -27,6 +27,187 @@ * THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ +// Common theme type +interface ModalTheme { + overlayBg: string; + captionBg: string; + captionText: string; + contentBg: string; + contentText: string; + buttonBg: string; + buttonText: string; + buttonBorder: string; + buttonHoverBg: string; + closeColor: string; + itemHoverBg: string; + itemSelectedBg: string; +} + +// Helper: Create theme +function getTheme (darkMode: boolean): ModalTheme { + return 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 + itemHoverBg: 'rgb(79, 79, 79)', // gray10 + itemSelectedBg: 'rgb(56, 56, 56)', // gray7 + } : { + 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 + itemHoverBg: 'rgb(214, 214, 214)', // gray27 + itemSelectedBg: 'rgb(199, 199, 199)', // gray25 + }; +} + +// Helper: Create overlay +function createOverlay (theme: ModalTheme): HTMLDivElement { + 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; + `; + return overlay; +} + +// Helper: Create dialog +function createDialog (theme: ModalTheme, minWidth: number): HTMLDivElement { + const dialog = document.createElement('div'); + dialog.style.cssText = ` + background: ${theme.contentBg}; + border-radius: 6px; + min-width: ${minWidth}px; + 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)); + `; + return dialog; +} + +// Helper: Create caption with close button +function createCaption (title: string, theme: ModalTheme, onClose: () => void): HTMLDivElement { + 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 = ``; + 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'; }; + closeBtn.addEventListener('click', onClose); + + caption.appendChild(titleSpan); + caption.appendChild(closeBtn); + + return caption; +} + +// Helper: Create footer +function createFooter (): HTMLDivElement { + const footer = document.createElement('div'); + footer.style.cssText = ` + padding: 8px 14px 12px; + display: flex; + justify-content: flex-end; + gap: 6px; + `; + return footer; +} + +// Helper: Create button +function createButton (text: string, theme: ModalTheme, onClick: () => void): HTMLButtonElement { + const btn = document.createElement('button'); + btn.textContent = 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', onClick); + return btn; +} + +// Helper: Setup modal event handlers +function setupModalHandlers (overlay: HTMLDivElement, onCancel: () => void, extraKeyHandler?: (e: KeyboardEvent) => boolean) { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + return; + } + if (extraKeyHandler?.(e)) { + return; + } + }; + + document.addEventListener('keydown', handleKeyDown); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + onCancel(); + } + }); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + overlay.remove(); + }; +} + +// Original interfaces interface ModalButton { text: string; color?: string; @@ -46,138 +227,35 @@ export function showModal (options: ModalOptions): Promise 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 theme = getTheme(darkMode); + const overlay = createOverlay(theme); + const dialog = createDialog(theme, 200); - 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 cleanup = setupModalHandlers(overlay, () => { + cleanup(); + resolve(undefined); + }); - 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 = ``; - 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 caption = createCaption(title, theme, () => { + cleanup(); + resolve(undefined); + }); 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); - }); + const footer = createFooter(); 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', () => { + const btn = createButton(buttonConfig.text, theme, () => { cleanup(); resolve(buttonConfig.value); }); - footer.appendChild(btn); if (index === buttons.length - 1) { @@ -185,21 +263,6 @@ export function showModal (options: ModalOptions): Promise } }); - 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); @@ -208,3 +271,296 @@ export function showModal (options: ModalOptions): Promise }); } +interface ListSelectionOptions { + darkMode: boolean; + title: string; + items: string[]; + maxWidth?: number; + maxHeight?: number; +} + +export function showListSelectionModal (options: ListSelectionOptions): Promise { + return new Promise((resolve) => { + const { title, items, darkMode = false, maxHeight = 400 } = options; + + const theme = getTheme(darkMode); + const overlay = createOverlay(theme); + const dialog = createDialog(theme, 300); + + let selectedItem: string | undefined; + + const cleanup = setupModalHandlers(overlay, () => { + cleanup(); + resolve(undefined); + }, (e) => { + if (e.key === 'Enter' && selectedItem) { + cleanup(); + resolve(selectedItem); + return true; + } + return false; + }); + + const caption = createCaption(title, theme, () => { + cleanup(); + resolve(undefined); + }); + + const contents = document.createElement('div'); + contents.style.cssText = ` + padding: 12px 0; + color: ${theme.contentText}; + max-height: ${maxHeight}px; + overflow-y: auto; + `; + + items.forEach((item) => { + const itemDiv = document.createElement('div'); + itemDiv.textContent = item; + itemDiv.style.cssText = ` + padding: 8px 14px; + cursor: pointer; + user-select: none; + `; + + itemDiv.addEventListener('click', () => { + // Deselect all items + contents.querySelectorAll('div').forEach(div => { + div.style.background = 'transparent'; + }); + // Select this item + itemDiv.style.background = theme.itemSelectedBg; + selectedItem = item; + }); + + itemDiv.addEventListener('mouseover', () => { + if (itemDiv.style.background !== theme.itemSelectedBg) { + itemDiv.style.background = theme.itemHoverBg; + } + }); + + itemDiv.addEventListener('mouseout', () => { + if (itemDiv.style.background !== theme.itemSelectedBg) { + itemDiv.style.background = 'transparent'; + } + }); + + contents.appendChild(itemDiv); + }); + + const footer = createFooter(); + + const cancelBtn = createButton('Cancel', theme, () => { + cleanup(); + resolve(undefined); + }); + + const okBtn = createButton('OK', theme, () => { + cleanup(); + resolve(selectedItem); + }); + + footer.appendChild(cancelBtn); + footer.appendChild(okBtn); + + dialog.appendChild(caption); + dialog.appendChild(contents); + dialog.appendChild(footer); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + setTimeout(() => okBtn.focus(), 0); + }); +} + +interface AlertOptions { + darkMode: boolean; + title: string; + message: string; +} + +export function showAlertModal (options: AlertOptions): Promise { + return new Promise((resolve) => { + const { title, message, darkMode = false } = options; + + const theme = getTheme(darkMode); + const overlay = createOverlay(theme); + const dialog = createDialog(theme, 300); + + const cleanup = setupModalHandlers(overlay, () => { + cleanup(); + resolve(); + }, (e) => { + if (e.key === 'Enter') { + cleanup(); + resolve(); + return true; + } + return false; + }); + + const caption = createCaption(title, theme, () => { + cleanup(); + resolve(); + }); + + const contents = document.createElement('div'); + contents.style.cssText = ` + padding: 12px 14px; + color: ${theme.contentText}; + line-height: 1.4; + `; + contents.textContent = message; + + const footer = createFooter(); + + const okBtn = createButton('OK', theme, () => { + cleanup(); + resolve(); + }); + + footer.appendChild(okBtn); + + dialog.appendChild(caption); + dialog.appendChild(contents); + dialog.appendChild(footer); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + setTimeout(() => okBtn.focus(), 0); + }); +} + +interface MultiListSelectionOptions { + darkMode: boolean; + title: string; + items: string[]; + selectedItems?: string[]; + maxWidth?: number; + maxHeight?: number; +} + +export function showMultiListSelectionModal (options: MultiListSelectionOptions): Promise { + return new Promise((resolve) => { + const { title, items, selectedItems = [], darkMode = false, maxHeight = 400 } = options; + + const theme = getTheme(darkMode); + const overlay = createOverlay(theme); + const dialog = createDialog(theme, 300); + + const selectedSet = new Set(selectedItems); + + const cleanup = setupModalHandlers(overlay, () => { + cleanup(); + resolve(undefined); + }, (e) => { + if (e.key === 'Enter') { + cleanup(); + resolve(Array.from(selectedSet)); + return true; + } + return false; + }); + + const caption = createCaption(title, theme, () => { + cleanup(); + resolve(undefined); + }); + + const contents = document.createElement('div'); + contents.style.cssText = ` + padding: 12px 0; + color: ${theme.contentText}; + max-height: ${maxHeight}px; + overflow-y: auto; + `; + + items.forEach((item) => { + const itemDiv = document.createElement('div'); + itemDiv.style.cssText = ` + padding: 8px 14px; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 8px; + `; + + // Create checkbox + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = selectedSet.has(item); + checkbox.style.cssText = ` + cursor: pointer; + `; + + const label = document.createElement('span'); + label.textContent = item; + label.style.cssText = ` + flex: 1; + cursor: pointer; + `; + + itemDiv.appendChild(checkbox); + itemDiv.appendChild(label); + + const toggleSelection = () => { + if (selectedSet.has(item)) { + selectedSet.delete(item); + checkbox.checked = false; + } else { + selectedSet.add(item); + checkbox.checked = true; + } + }; + + itemDiv.addEventListener('click', (e) => { + // Don't toggle if clicking directly on checkbox (it handles itself) + if (e.target !== checkbox) { + toggleSelection(); + } + }); + + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + selectedSet.add(item); + } else { + selectedSet.delete(item); + } + }); + + itemDiv.addEventListener('mouseover', () => { + itemDiv.style.background = theme.itemHoverBg; + }); + + itemDiv.addEventListener('mouseout', () => { + itemDiv.style.background = 'transparent'; + }); + + contents.appendChild(itemDiv); + }); + + const footer = createFooter(); + + const cancelBtn = createButton('Cancel', theme, () => { + cleanup(); + resolve(undefined); + }); + + const okBtn = createButton('OK', theme, () => { + cleanup(); + resolve(Array.from(selectedSet)); + }); + + footer.appendChild(cancelBtn); + footer.appendChild(okBtn); + + dialog.appendChild(caption); + dialog.appendChild(contents); + dialog.appendChild(footer); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + setTimeout(() => okBtn.focus(), 0); + }); +} diff --git a/spine-ts/spine-construct3/src/instance.ts b/spine-ts/spine-construct3/src/instance.ts index 192f46868..465c52238 100644 --- a/spine-ts/spine-construct3/src/instance.ts +++ b/spine-ts/spine-construct3/src/instance.ts @@ -579,7 +579,66 @@ class SpineC3PluginInstance extends SDK.IWorldInstanceBase { } public async selectAnimation () { - console.log('[Spine] Select animation dialog called'); + if (!this.skeleton) { + await spine.showAlertModal({ + darkMode: false, + title: 'Error', + message: 'Skeleton not loaded. Please ensure atlas and skeleton files are set.', + }); + return; + } + + const animations = this.skeleton.data.animations.map(anim => anim.name); + if (animations.length === 0) { + await spine.showAlertModal({ + darkMode: false, + title: 'No Animations', + message: 'No animations found in the skeleton.', + }); + return; + } + + const selectedAnimation = await spine.showListSelectionModal({ + darkMode: false, + title: 'Select Animation', + items: animations, + }); + + if (selectedAnimation) { + this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_ANIMATION, selectedAnimation); + } + } + + public async selectSkin () { + if (!this.skeleton) { + await spine.showAlertModal({ + darkMode: false, + title: 'Error', + message: 'Skeleton not loaded. Please ensure atlas and skeleton files are set.', + }); + return; + } + + const skins = this.skeleton.data.skins.map(skin => skin.name).filter(s => s !== "default"); + if (skins.length === 0) { + await spine.showAlertModal({ + darkMode: false, + title: 'No Skins', + message: 'No skins found in the skeleton.', + }); + return; + } + + const selectedSkins = await spine.showMultiListSelectionModal({ + darkMode: false, + title: 'Select Skins', + items: skins, + selectedItems: this.skins, + }); + + if (selectedSkins !== undefined) { + this._inst.SetPropertyValue(PLUGIN_CLASS.PROP_SKIN, selectedSkins.join(",")); + } } private lang (stringKey: string, interpolate: (string | number)[] = []): string { diff --git a/spine-ts/spine-construct3/src/lang/en-US.json b/spine-ts/spine-construct3/src/lang/en-US.json index f0ec763aa..b2a6437a3 100644 --- a/spine-ts/spine-construct3/src/lang/en-US.json +++ b/spine-ts/spine-construct3/src/lang/en-US.json @@ -22,6 +22,11 @@ "name": "Loader scale", "desc": "Loader scale" }, + "select-skin": { + "name": "Select skin", + "desc": "Open a dialog to select skins from the skeleton", + "link-text": "Select" + }, "select-animation": { "name": "Select animation", "desc": "Open a dialog to select an animation from the skeleton", diff --git a/spine-ts/spine-construct3/src/lang/zh-CN.json b/spine-ts/spine-construct3/src/lang/zh-CN.json index 61db94f3c..e4b86dbb5 100644 --- a/spine-ts/spine-construct3/src/lang/zh-CN.json +++ b/spine-ts/spine-construct3/src/lang/zh-CN.json @@ -22,6 +22,11 @@ "name": "加载比例", "desc": "加载比例" }, + "select-skin": { + "name": "选择皮肤", + "desc": "打开对话框从骨架中选择皮肤", + "link-text": "选择" + }, "select-animation": { "name": "选择动画", "desc": "打开对话框从骨架中选择动画", diff --git a/spine-ts/spine-construct3/src/plugin.ts b/spine-ts/spine-construct3/src/plugin.ts index 0397e0d29..de3ac30fc 100644 --- a/spine-ts/spine-construct3/src/plugin.ts +++ b/spine-ts/spine-construct3/src/plugin.ts @@ -78,6 +78,13 @@ const PLUGIN_CLASS = class SpineC3Plugin extends SDK.IPluginBase { new SDK.PluginProperty("projectfile", SpineC3Plugin.PROP_ATLAS, { initialValue: "", filter: ".atlas" }), new SDK.PluginProperty("projectfile", SpineC3Plugin.PROP_SKELETON, { initialValue: "", filter: ".json,.skel" }), new SDK.PluginProperty("float", SpineC3Plugin.PROP_LOADER_SCALE, 1), + new SDK.PluginProperty("link", "select-skin", { + linkCallback: async (instance) => { + const sdkInst = instance as SDKEditorInstanceClass; + await sdkInst.selectSkin(); + }, + callbackType: "for-each-instance" + }), new SDK.PluginProperty("text", SpineC3Plugin.PROP_SKIN, ""), new SDK.PluginProperty("link", "select-animation", { linkCallback: async (instance) => {