From 1f3dd3b0557530ae5eb46c8406a347d3695a8c9d Mon Sep 17 00:00:00 2001 From: Ale Date: Mon, 16 Mar 2026 22:37:38 +0900 Subject: [PATCH 01/17] [Aseprite] Enable Effective Group Visibility During Export - Propagated group visibility downward during recursive traversal. - Combined layer collection and effective-visibility recording into a single recursive pass to improve efficiency. --- aseprite/Prepare-For-Spine.lua | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index b796470..e92e94d 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -6,21 +6,24 @@ https://github.com/jordanbleu/aseprite-to-spine -----------------------------------------------[[ Functions ]]----------------------------------------------- --[[ -Returns a flattened view of -the layers and groups of the sprite. +Flattens the layers of a sprite while considering their effective visibility. parent: The sprite or parent layer group -arr: The array to append to +outLayers: The array to append the flattened layers totally ignoring groups +outVis: The array to append the effective visibility of each layer (true / false) +inheritedVisible: The visibility inherited from parent groups (true / false) ]] -function getLayers(parent, arr) - for i, layer in ipairs(parent.layers) do - if (layer.isGroup) then - arr[#arr + 1] = layer - arr = getLayers(layer, arr) - else - arr[#arr + 1] = layer +function flattenWithEffectiveVisibility(parent, outLayers, outVis, inheritedVisible) + for _, layer in ipairs(parent.layers) do + -- A layer is effectively visible if it is visible and all of its parent groups are also visible + local effectiveVisible = inheritedVisible and layer.isVisible + + outLayers[#outLayers + 1] = layer + outVis[#outVis + 1] = effectiveVisible + + if layer.isGroup then + flattenWithEffectiveVisibility(layer, outLayers, outVis, effectiveVisible) end end - return arr end --[[ @@ -76,6 +79,7 @@ outputDir: the directory the sprite is saved in visibilityStates: the prior state of each layer's visibility (true / false) ]] function captureLayers(layers, sprite, visibilityStates) + -- First hide all layers so we can selectively show them when we capture them hideAllLayers(layers) local outputDir = app.fs.filePath(sprite.filename) @@ -103,6 +107,7 @@ function captureLayers(layers, sprite, visibilityStates) for i, layer in ipairs(layers) do -- Ignore groups and non-visible layers if (not layer.isGroup and visibilityStates[i] == true) then + -- Set the layer to visible so we can capture it, then set it back to hidden after layer.isVisible = true local cel = layer.cels[1] local cropped = Sprite(sprite) @@ -161,7 +166,9 @@ elseif (activeSprite.filename == "") then return end -local flattenedLayers = getLayers(activeSprite, {}) +local flattenedLayers = {} -- This will be the flattened view of the sprite layers, ignoring groups +local effectiveVisibilities = {} -- This will be the effective visibility of each layer (true / false) +flattenWithEffectiveVisibility(activeSprite, flattenedLayers, effectiveVisibilities, true) if (containsDuplicates(flattenedLayers)) then return @@ -172,7 +179,7 @@ local visibilities = captureVisibilityStates(flattenedLayers) -- Saves each sprite layer as a separate .png under the 'images' subdirectory -- and write out the json file for importing into spine. -captureLayers(flattenedLayers, activeSprite, visibilities) +captureLayers(flattenedLayers, activeSprite, effectiveVisibilities) -- Restore the layer's visibilities to how they were before restoreVisibilities(flattenedLayers, visibilities) \ No newline at end of file From 2001af8e378371c5668d945f3ec8eee8fdcad974 Mon Sep 17 00:00:00 2001 From: Ale Date: Tue, 17 Mar 2026 00:46:49 +0900 Subject: [PATCH 02/17] [Aseprite] Added a new UI options panel - Toggle for Ignore Group Visibility. - export path setting for the output JSON file. --- aseprite/Prepare-For-Spine.lua | 205 ++++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 31 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index e92e94d..d8b5f0b 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -6,22 +6,31 @@ https://github.com/jordanbleu/aseprite-to-spine -----------------------------------------------[[ Functions ]]----------------------------------------------- --[[ -Flattens the layers of a sprite while considering their effective visibility. +Flattens the layers of a sprite while allowing optional ignore of parent group visibility. parent: The sprite or parent layer group -outLayers: The array to append the flattened layers totally ignoring groups +outLayers: The array to append the flattened layers outVis: The array to append the effective visibility of each layer (true / false) inheritedVisible: The visibility inherited from parent groups (true / false) +ignoreGroupVisibility: If true, visibility only depends on the layer's own isVisible value ]] -function flattenWithEffectiveVisibility(parent, outLayers, outVis, inheritedVisible) +function flattenWithEffectiveVisibility(parent, outLayers, outVis, inheritedVisible, ignoreGroupVisibility) for _, layer in ipairs(parent.layers) do - -- A layer is effectively visible if it is visible and all of its parent groups are also visible - local effectiveVisible = inheritedVisible and layer.isVisible - + -- Determine the effective visibility of the layer based on its own visibility and the inherited visibility from parent groups + local effectiveVisible + if (ignoreGroupVisibility) then + effectiveVisible = layer.isVisible + else + effectiveVisible = inheritedVisible and layer.isVisible + end + + -- Append the layer and its effective visibility to the output arrays outLayers[#outLayers + 1] = layer outVis[#outVis + 1] = effectiveVisible - + + -- If this layer is a group, recursively flatten its children, passing down the effective visibilityStates if layer.isGroup then - flattenWithEffectiveVisibility(layer, outLayers, outVis, effectiveVisible) + local nextInherited = ignoreGroupVisibility and true or effectiveVisible + flattenWithEffectiveVisibility(layer, outLayers, outVis, nextInherited, ignoreGroupVisibility) end end end @@ -30,12 +39,12 @@ end Checks for duplicate layer names, and returns true if any exist (also shows an error to the user) layers: The flattened view of the sprite layers ]] -function containsDuplicates(layers) +function containsDuplicates(layers, visibilities) for i, layer in ipairs(layers) do - if (layer.isVisible) then + if (not layer.isGroup and visibilities[i] == true) then for j, otherLayer in ipairs(layers) do -- if we find a duplicate in the list that is not our index - if (j ~= i) and (otherLayer.name == layer.name) and (otherLayer.isVisible) then + if (j ~= i) and (not otherLayer.isGroup) and (otherLayer.name == layer.name) and (visibilities[j] == true) then app.alert("Found multiple visible layers named '" .. layer.name .. "'. Please use unique layer names or hide one of these layers.") return true end @@ -75,24 +84,32 @@ end Captures each layer as a separate PNG. Ignores hidden layers. layers: The flattened view of the sprite layers sprite: The active sprite -outputDir: the directory the sprite is saved in -visibilityStates: the prior state of each layer's visibility (true / false) +outputPath: the output json file path +effectiveVisibilities: the prior state of each layer's effectiveVisible visibility (true / false) ]] -function captureLayers(layers, sprite, visibilityStates) +function captureLayers(layers, sprite, effectiveVisibilities, outputPath) + -- Default output path to the sprite-name json in the sprite's directory. + if (outputPath == nil or outputPath == "") then + local defaultOutputDir = app.fs.filePath(sprite.filename) + local defaultSpriteName = app.fs.fileTitle(sprite.filename) + outputPath = defaultOutputDir .. app.fs.pathSeparator .. defaultSpriteName .. ".json" + end + + local outputDir = app.fs.filePath(outputPath) + + -- Create the output directory if it doesn't exist + local separator = app.fs.pathSeparator + local imagesDir = outputDir .. separator .. "images" + app.fs.makeDirectory(imagesDir) + -- First hide all layers so we can selectively show them when we capture them hideAllLayers(layers) - local outputDir = app.fs.filePath(sprite.filename) - local spriteFileName = app.fs.fileTitle(sprite.filename) - - local jsonFileName = outputDir .. app.fs.pathSeparator .. spriteFileName .. ".json" + local jsonFileName = outputPath json = io.open(jsonFileName, "w") - json:write('{') - -- skeleton json:write([[ "skeleton": { "images": "images/" }, ]]) - -- bones json:write([[ "bones": [ { "name": "root" } ], ]]) @@ -102,17 +119,15 @@ function captureLayers(layers, sprite, visibilityStates) local skinsJson = {} local index = 1 - local separator = app.fs.pathSeparator - for i, layer in ipairs(layers) do -- Ignore groups and non-visible layers - if (not layer.isGroup and visibilityStates[i] == true) then + if (not layer.isGroup and effectiveVisibilities[i] == true) then -- Set the layer to visible so we can capture it, then set it back to hidden after layer.isVisible = true local cel = layer.cels[1] local cropped = Sprite(sprite) cropped:crop(cel.position.x, cel.position.y, cel.bounds.width, cel.bounds.height) - cropped:saveCopyAs(outputDir .. separator .. "images" .. separator .. layer.name .. ".png") + cropped:saveCopyAs(imagesDir .. separator .. layer.name .. ".png") cropped:close() layer.isVisible = false local name = layer.name @@ -126,7 +141,6 @@ function captureLayers(layers, sprite, visibilityStates) json:write('"slots": [') json:write(table.concat(slotsJson, ",")) json:write("],") - -- skins json:write('"skins": {') json:write('"default": {') @@ -136,7 +150,6 @@ function captureLayers(layers, sprite, visibilityStates) -- close the json json:write("}") - json:close() app.alert("Export completed! Use file '" .. jsonFileName .. "' for importing into Spine.") @@ -153,6 +166,128 @@ function restoreVisibilities(layers, visibilityStates) end end + +-----------------------------------------------[[ UI ]]----------------------------------------------- +--[[ +Shows the export options dialog and returns the selected options. +defaultOutputPath: The default json output path +]] +function showExportOptionsDialog(defaultOutputPath) + -- Create a dialog to show export optionsDialog + local optionsDialog = Dialog({ title = "Export To Spine" }) + + -- check: Option to ignore group visibility when determining layer visibilityStates + optionsDialog:check({ + id = "ignoreGroupVisibility", + label = "Ignore Group Visibility", + tooltip = "If checked, layer visibility will only depend on the layer's own visibility setting, and not the visibility of any parent groups.", + selected = false + }) + + -- entry: Output json file path + optionsDialog:entry({ + id = "outputPath", + label = "Output Path", + text = defaultOutputPath + }) + -- button: Open file picker to select output path + optionsDialog:button({ + text = "Select Output Path", + onclick = function() + local currentPath = optionsDialog.data.outputPath + if (currentPath == nil or currentPath == "") then + currentPath = defaultOutputPath + end + + local selectedPath = chooseOutputPath(currentPath) + if (selectedPath ~= nil and selectedPath ~= "") then + optionsDialog:modify({ id = "outputPath", text = selectedPath }) + + -- Nested dialogs can leave stale paint artifacts in some builds; force a redraw. + pcall(function() + app.refresh() + end) + end + end + }) + optionsDialog:newrow() + + -- button: Confirm export + local confirmed = false + optionsDialog:button({ + text = "Export", + focus = true, + onclick = function() + confirmed = true + optionsDialog:close() + end + }) + + -- button: Cancel export + optionsDialog:button({ + text = "Cancel", + onclick = function() + optionsDialog:close() + end + }) + + -- Show the dialog with an initial width of 800px. + optionsDialog:show({ wait = true, bounds = Rectangle(0, 0, 800, 220) }) + if (not confirmed) then + return nil + end + + -- Get the selected options from the dialog + local options = optionsDialog.data + -- Fallback to default path when input is empty. + if (options.outputPath == nil or options.outputPath == "") then + options.outputPath = defaultOutputPath + end + + return options +end + +--[[ +Shows a file picker dialog to choose the output json path. +initialPath: The initial json path to show in the file picker +]] +function chooseOutputPath(initialPath) + -- Default the filename to the same name as the sprite file, but with a .json extension + local spriteFileName = "export" + if (app.activeSprite ~= nil and app.activeSprite.filename ~= "") then + spriteFileName = app.fs.fileTitle(app.activeSprite.filename) + end + local defaultPath = initialPath + if (defaultPath == nil or defaultPath == "") then + local defaultOutputDir = app.fs.filePath(app.activeSprite.filename) + defaultPath = defaultOutputDir .. app.fs.pathSeparator .. spriteFileName .. ".json" + end + + -- Create a file picker dialog to select the output directory and filename + local picker = Dialog("Select Output Path") + picker:file({ + id = "path", + label = "Path", + filename = defaultPath, + save = true + }) + + -- button: Confirm selection + picker:button({ id = "confirm", text = "Confirm" }) + -- button: Cancel selection + picker:button({ id = "cancel", text = "Cancel" }) + picker:show({ wait = true }) + + -- If the user confirmed and provided a path, return it. Otherwise return nil. + local data = picker.data + if (data.confirm and data.path ~= nil and data.path ~= "") then + return data.path + end + + return nil +end + + -----------------------------------------------[[ Main Execution ]]----------------------------------------------- local activeSprite = app.activeSprite @@ -166,11 +301,20 @@ elseif (activeSprite.filename == "") then return end +-- Show the export options dialog UI and get the user's selected options. +local spriteOutputDir = app.fs.filePath(activeSprite.filename) +local spriteOutputName = app.fs.fileTitle(activeSprite.filename) +local defaultOutputPath = spriteOutputDir .. app.fs.pathSeparator .. spriteOutputName .. ".json" +local options = showExportOptionsDialog(defaultOutputPath) +if (options == nil) then + return +end + local flattenedLayers = {} -- This will be the flattened view of the sprite layers, ignoring groups local effectiveVisibilities = {} -- This will be the effective visibility of each layer (true / false) -flattenWithEffectiveVisibility(activeSprite, flattenedLayers, effectiveVisibilities, true) +flattenWithEffectiveVisibility(activeSprite, flattenedLayers, effectiveVisibilities, true, options.ignoreGroupVisibility) -if (containsDuplicates(flattenedLayers)) then +if (containsDuplicates(flattenedLayers, effectiveVisibilities)) then return end @@ -178,8 +322,7 @@ end local visibilities = captureVisibilityStates(flattenedLayers) -- Saves each sprite layer as a separate .png under the 'images' subdirectory --- and write out the json file for importing into spine. -captureLayers(flattenedLayers, activeSprite, effectiveVisibilities) +captureLayers(flattenedLayers, activeSprite, effectiveVisibilities, options.outputPath) -- Restore the layer's visibilities to how they were before restoreVisibilities(flattenedLayers, visibilities) \ No newline at end of file From 0c3f81aab0b3cf622d843ba54a25f0b4a22f1970 Mon Sep 17 00:00:00 2001 From: Ale Date: Tue, 17 Mar 2026 13:47:28 +0900 Subject: [PATCH 03/17] [Aseprite] Added updates to the UI options panel - Toggle for Clear Old Images before export. - Simplified output path selection workflow. - Improved overall UI layout and spacing. --- aseprite/Prepare-For-Spine.lua | 110 ++++++++++++++------------------- 1 file changed, 48 insertions(+), 62 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index d8b5f0b..883a570 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -85,9 +85,10 @@ Captures each layer as a separate PNG. Ignores hidden layers. layers: The flattened view of the sprite layers sprite: The active sprite outputPath: the output json file path +clearOldImages: if true, clear existing images folder before export effectiveVisibilities: the prior state of each layer's effectiveVisible visibility (true / false) ]] -function captureLayers(layers, sprite, effectiveVisibilities, outputPath) +function captureLayers(layers, sprite, effectiveVisibilities, outputPath, clearOldImages) -- Default output path to the sprite-name json in the sprite's directory. if (outputPath == nil or outputPath == "") then local defaultOutputDir = app.fs.filePath(sprite.filename) @@ -100,6 +101,10 @@ function captureLayers(layers, sprite, effectiveVisibilities, outputPath) -- Create the output directory if it doesn't exist local separator = app.fs.pathSeparator local imagesDir = outputDir .. separator .. "images" + -- If the user chose to clear old images, delete the existing images directory and its contents before creating a new one + if (clearOldImages == true) then + deleteDirectoryRecursive(imagesDir) + end app.fs.makeDirectory(imagesDir) -- First hide all layers so we can selectively show them when we capture them @@ -166,6 +171,22 @@ function restoreVisibilities(layers, visibilityStates) end end +--[[ +Deletes a directory and its contents recursively. +path: The path of the directory to delete +]] +function deleteDirectoryRecursive(path) + if (path == nil or path == "") then + return + end + + if (app.fs.pathSeparator == "\\") then + os.execute('rmdir /S /Q "' .. path .. '"') + else + os.execute('rm -rf "' .. path .. '"') + end +end + -----------------------------------------------[[ UI ]]----------------------------------------------- --[[ @@ -180,37 +201,38 @@ function showExportOptionsDialog(defaultOutputPath) optionsDialog:check({ id = "ignoreGroupVisibility", label = "Ignore Group Visibility", - tooltip = "If checked, layer visibility will only depend on the layer's own visibility setting, and not the visibility of any parent groups.", + text = "Use layer visibility only.", selected = false }) - -- entry: Output json file path + -- check: Option to clear old images in the output images directory before export + optionsDialog:check({ + id = "clearOldImages", + label = "Clear Old Images", + text = "Delete existing images first.", + selected = false + }) + optionsDialog:separator({}) + + -- entry: Output json path optionsDialog:entry({ id = "outputPath", label = "Output Path", text = defaultOutputPath }) - -- button: Open file picker to select output path - optionsDialog:button({ - text = "Select Output Path", - onclick = function() - local currentPath = optionsDialog.data.outputPath - if (currentPath == nil or currentPath == "") then - currentPath = defaultOutputPath - end - - local selectedPath = chooseOutputPath(currentPath) + -- file: File picker to select output json path (syncs with entry) + optionsDialog:file({ + id = "outputPathPicker", + title = "Select Output Path", + save = true, + onchange = function() + local selectedPath = optionsDialog.data.outputPathPicker if (selectedPath ~= nil and selectedPath ~= "") then optionsDialog:modify({ id = "outputPath", text = selectedPath }) - - -- Nested dialogs can leave stale paint artifacts in some builds; force a redraw. - pcall(function() - app.refresh() - end) end end }) - optionsDialog:newrow() + optionsDialog:separator({}) -- button: Confirm export local confirmed = false @@ -231,8 +253,12 @@ function showExportOptionsDialog(defaultOutputPath) end }) - -- Show the dialog with an initial width of 800px. - optionsDialog:show({ wait = true, bounds = Rectangle(0, 0, 800, 220) }) + -- Show the dialog with width 500 and centered position. + local dialogWidth = 500 + local dialogHeight = 125 + local x = (app.window.width - dialogWidth) / 2 + local y = (app.window.height - dialogHeight) / 2 + optionsDialog:show({ wait = true, bounds = Rectangle(x, y, dialogWidth, dialogHeight) }) if (not confirmed) then return nil end @@ -247,46 +273,6 @@ function showExportOptionsDialog(defaultOutputPath) return options end ---[[ -Shows a file picker dialog to choose the output json path. -initialPath: The initial json path to show in the file picker -]] -function chooseOutputPath(initialPath) - -- Default the filename to the same name as the sprite file, but with a .json extension - local spriteFileName = "export" - if (app.activeSprite ~= nil and app.activeSprite.filename ~= "") then - spriteFileName = app.fs.fileTitle(app.activeSprite.filename) - end - local defaultPath = initialPath - if (defaultPath == nil or defaultPath == "") then - local defaultOutputDir = app.fs.filePath(app.activeSprite.filename) - defaultPath = defaultOutputDir .. app.fs.pathSeparator .. spriteFileName .. ".json" - end - - -- Create a file picker dialog to select the output directory and filename - local picker = Dialog("Select Output Path") - picker:file({ - id = "path", - label = "Path", - filename = defaultPath, - save = true - }) - - -- button: Confirm selection - picker:button({ id = "confirm", text = "Confirm" }) - -- button: Cancel selection - picker:button({ id = "cancel", text = "Cancel" }) - picker:show({ wait = true }) - - -- If the user confirmed and provided a path, return it. Otherwise return nil. - local data = picker.data - if (data.confirm and data.path ~= nil and data.path ~= "") then - return data.path - end - - return nil -end - -----------------------------------------------[[ Main Execution ]]----------------------------------------------- local activeSprite = app.activeSprite @@ -322,7 +308,7 @@ end local visibilities = captureVisibilityStates(flattenedLayers) -- Saves each sprite layer as a separate .png under the 'images' subdirectory -captureLayers(flattenedLayers, activeSprite, effectiveVisibilities, options.outputPath) +captureLayers(flattenedLayers, activeSprite, effectiveVisibilities, options.outputPath, options.clearOldImages) -- Restore the layer's visibilities to how they were before restoreVisibilities(flattenedLayers, visibilities) \ No newline at end of file From 1d60a2421f2340d9659cb47118baadb229f3c6e5 Mon Sep 17 00:00:00 2001 From: Ale Date: Tue, 17 Mar 2026 19:55:41 +0900 Subject: [PATCH 04/17] [Aseprite] Added updates to the UI options panel - Coordinate origin is now configurable (X/Y), with range support for [0,1]. - Added a toggle to keep coordinate values as integers (drop decimal part). - Added quick access to open the exported file location after export completion. --- aseprite/Prepare-For-Spine.lua | 142 +++++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 17 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index 883a570..cd5c9c2 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -84,11 +84,13 @@ end Captures each layer as a separate PNG. Ignores hidden layers. layers: The flattened view of the sprite layers sprite: The active sprite +effectiveVisibilities: the prior state of each layer's effectiveVisible visibility (true / false) outputPath: the output json file path clearOldImages: if true, clear existing images folder before export -effectiveVisibilities: the prior state of each layer's effectiveVisible visibility (true / false) +originX, originY: the user-defined origin point for the exported Spine skeleton, as a percentage of the sprite's width and height (range 0-1) +roundCoordinatesToInteger: if true, rounds the attachment coordinates to the nearest integer instead of keeping decimals (not recommended for pixel art) ]] -function captureLayers(layers, sprite, effectiveVisibilities, outputPath, clearOldImages) +function captureLayers(layers, sprite, effectiveVisibilities, outputPath, clearOldImages, originX, originY, roundCoordinatesToInteger) -- Default output path to the sprite-name json in the sprite's directory. if (outputPath == nil or outputPath == "") then local defaultOutputDir = app.fs.filePath(sprite.filename) @@ -136,8 +138,18 @@ function captureLayers(layers, sprite, effectiveVisibilities, outputPath, clearO cropped:close() layer.isVisible = false local name = layer.name + -- Calculate the attachment position based on the cel position, cel bounds, sprite bounds, and the user-defined originX and originY. + local attachmentX = cel.bounds.width / 2 + cel.position.x - sprite.bounds.width * originX + local attachmentY = sprite.bounds.height * (1 - originY) - cel.position.y - cel.bounds.height / 2 slotsJson[index] = string.format([[ { "name": "%s", "bone": "%s", "attachment": "%s" } ]], name, "root", name) - skinsJson[index] = string.format([[ "%s": { "%s": { "x": %d, "y": %d, "width": 1, "height": 1 } } ]], name, name, cel.bounds.width/2 + cel.position.x - sprite.bounds.width/2, sprite.bounds.height - cel.position.y - cel.bounds.height/2) + -- If roundCoordinatesToInteger is true, round the attachmentX and attachmentY to the nearest integer using math.modf. Otherwise, keep the decimal values with 3 decimal places. + if (roundCoordinatesToInteger == true) then + attachmentX = math.modf(attachmentX) + attachmentY = math.modf(attachmentY) + skinsJson[index] = string.format([[ "%s": { "%s": { "x": %d, "y": %d, "width": 1, "height": 1 } } ]], name, name, attachmentX, attachmentY) + else + skinsJson[index] = string.format([[ "%s": { "%s": { "x": %.3f, "y": %.3f, "width": 1, "height": 1 } } ]], name, name, attachmentX, attachmentY) + end index = index + 1 end end @@ -157,7 +169,8 @@ function captureLayers(layers, sprite, effectiveVisibilities, outputPath, clearO json:write("}") json:close() - app.alert("Export completed! Use file '" .. jsonFileName .. "' for importing into Spine.") + -- Show export completion dialog + showExportCompletedDialog(jsonFileName) end --[[ @@ -187,6 +200,25 @@ function deleteDirectoryRecursive(path) end end +--[[ +Opens the OS file explorer and selects the exported file when possible. +filePath: The full path of the exported file +]] +function openFileLocation(filePath) + if (filePath == nil or filePath == "") then + return + end + + if (app.fs.pathSeparator == "\\") then + os.execute('explorer /select,"' .. filePath .. '"') + else + local dirPath = app.fs.filePath(filePath) + if (app.fs.pathSeparator == "/") then + os.execute('xdg-open "' .. dirPath .. '"') + end + end +end + -----------------------------------------------[[ UI ]]----------------------------------------------- --[[ @@ -197,19 +229,29 @@ function showExportOptionsDialog(defaultOutputPath) -- Create a dialog to show export optionsDialog local optionsDialog = Dialog({ title = "Export To Spine" }) - -- check: Option to ignore group visibility when determining layer visibilityStates - optionsDialog:check({ - id = "ignoreGroupVisibility", - label = "Ignore Group Visibility", - text = "Use layer visibility only.", - selected = false + -- label: Coordinate settings section + optionsDialog:label({ + id = "coordinateSettings", + label = "Coordinate Settings", + text = "Set which position is used as the Spine origin (0,0). Range: [0,1]." }) - - -- check: Option to clear old images in the output images directory before export + -- number: Coordinate origin X and Y (0-1). + optionsDialog:number({ + id = "originX", + label = "Origin (X,Y)", + text = "0.5", + decimals = 3, + }) + :number({ + id = "originY", + text = "0", + decimals = 3, + }) + -- check: Option to round attachment coordinates to integers instead of keeping decimals optionsDialog:check({ - id = "clearOldImages", - label = "Clear Old Images", - text = "Delete existing images first.", + id = "roundCoordinatesToInteger", + label = "Round Coordinates To Integer", + text = "Drop decimal pixels, May misalign pixels; not recommended for pixel art.", selected = false }) optionsDialog:separator({}) @@ -224,6 +266,8 @@ function showExportOptionsDialog(defaultOutputPath) optionsDialog:file({ id = "outputPathPicker", title = "Select Output Path", + filename = defaultOutputPath, + text = "Select Output Path", save = true, onchange = function() local selectedPath = optionsDialog.data.outputPathPicker @@ -234,6 +278,23 @@ function showExportOptionsDialog(defaultOutputPath) }) optionsDialog:separator({}) + -- check: Option to ignore group visibility when determining layer visibilityStates + optionsDialog:check({ + id = "ignoreGroupVisibility", + label = "Ignore Group Visibility", + text = "Use layer visibility only.", + selected = false + }) + + -- check: Option to clear old images in the output images directory before export + optionsDialog:check({ + id = "clearOldImages", + label = "Clear Old Images", + text = "Delete existing images first.", + selected = false + }) + optionsDialog:separator({}) + -- button: Confirm export local confirmed = false optionsDialog:button({ @@ -255,7 +316,7 @@ function showExportOptionsDialog(defaultOutputPath) -- Show the dialog with width 500 and centered position. local dialogWidth = 500 - local dialogHeight = 125 + local dialogHeight = 190 local x = (app.window.width - dialogWidth) / 2 local y = (app.window.height - dialogHeight) / 2 optionsDialog:show({ wait = true, bounds = Rectangle(x, y, dialogWidth, dialogHeight) }) @@ -269,10 +330,48 @@ function showExportOptionsDialog(defaultOutputPath) if (options.outputPath == nil or options.outputPath == "") then options.outputPath = defaultOutputPath end + + -- Parse originX and originY as numbers, and fallback to defaults if parsing fails or values are out of range + local parsedOriginX = tonumber(options.originX) + local parsedOriginY = tonumber(options.originY) + options.originX = (parsedOriginX ~= nil and parsedOriginX >= 0 and parsedOriginX <= 1) and parsedOriginX or 0.5 + options.originY = (parsedOriginY ~= nil and parsedOriginY >= 0 and parsedOriginY <= 1) and parsedOriginY or 0 return options end +--[[ +Shows export completion dialog with action to open the exported file location. +jsonFileName: The exported json file path +]] +function showExportCompletedDialog(jsonFileName) + local completedDialog = Dialog({ title = "Export Completed" }) + + completedDialog:label({ + id = "message", + text = "Export completed! Use this file for importing into Spine:\n" .. jsonFileName + }) + + completedDialog:newrow() + completedDialog:button({ + text = "Open File Folder", + onclick = function() + openFileLocation(jsonFileName) + completedDialog:close() + end + }) + + completedDialog:button({ + text = "OK", + focus = true, + onclick = function() + completedDialog:close() + end + }) + + completedDialog:show({ wait = true }) +end + -----------------------------------------------[[ Main Execution ]]----------------------------------------------- local activeSprite = app.activeSprite @@ -308,7 +407,16 @@ end local visibilities = captureVisibilityStates(flattenedLayers) -- Saves each sprite layer as a separate .png under the 'images' subdirectory -captureLayers(flattenedLayers, activeSprite, effectiveVisibilities, options.outputPath, options.clearOldImages) +captureLayers( + flattenedLayers, + activeSprite, + effectiveVisibilities, + options.outputPath, + options.clearOldImages, + options.originX, + options.originY, + options.roundCoordinatesToInteger +) -- Restore the layer's visibilities to how they were before restoreVisibilities(flattenedLayers, visibilities) \ No newline at end of file From a6c494b350d84254cc671440647860eea991f925 Mon Sep 17 00:00:00 2001 From: Ale Date: Tue, 17 Mar 2026 22:21:23 +0900 Subject: [PATCH 05/17] [Aseprite] Added export workflow and coordinate UI improvements - Added origin coordinate preset buttons for quick setup (Center, Bottom-Center, Bottom-Left, Top-Left). - Added real-time clamping for origin coordinate inputs, limiting values to the [0,1] range. - Added export completion dialog warnings that list any file paths that failed to write during export. --- aseprite/Prepare-For-Spine.lua | 276 +++++++++++++++++++++++++-------- 1 file changed, 215 insertions(+), 61 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index cd5c9c2..745a263 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -40,17 +40,46 @@ Checks for duplicate layer names, and returns true if any exist (also shows an e layers: The flattened view of the sprite layers ]] function containsDuplicates(layers, visibilities) + local nameCounts = {} -- Map of layer name to count + local duplicateNames = {} -- List of layer duplicates names + -- Iterate through the layers and count the occurrences of each name among visible layers for i, layer in ipairs(layers) do if (not layer.isGroup and visibilities[i] == true) then - for j, otherLayer in ipairs(layers) do - -- if we find a duplicate in the list that is not our index - if (j ~= i) and (not otherLayer.isGroup) and (otherLayer.name == layer.name) and (visibilities[j] == true) then - app.alert("Found multiple visible layers named '" .. layer.name .. "'. Please use unique layer names or hide one of these layers.") - return true - end + local name = layer.name + local count = (nameCounts[name] or 0) + 1 + nameCounts[name] = count + if (count == 2) then + duplicateNames[#duplicateNames + 1] = name end end end + + -- If any duplicates were found, show one dialog listing all duplicate names. + if (#duplicateNames > 0) then + table.sort(duplicateNames) + local duplicateDialog = Dialog({ title = "Duplicate Layer Names" }) + duplicateDialog:label({ + id = "message", + text = "Found duplicate visible layer names, Please use unique names:" + }) + for _, duplicateName in ipairs(duplicateNames) do + duplicateDialog:newrow() + duplicateDialog:label({ + text = duplicateName .. " ▸ Count: " .. nameCounts[duplicateName] + }) + end + duplicateDialog:newrow() + duplicateDialog:button({ + text = "OK", + focus = true, + onclick = function() + duplicateDialog:close() + end + }) + duplicateDialog:show({ wait = true }) + return true + end + return false end @@ -90,52 +119,91 @@ clearOldImages: if true, clear existing images folder before export originX, originY: the user-defined origin point for the exported Spine skeleton, as a percentage of the sprite's width and height (range 0-1) roundCoordinatesToInteger: if true, rounds the attachment coordinates to the nearest integer instead of keeping decimals (not recommended for pixel art) ]] -function captureLayers(layers, sprite, effectiveVisibilities, outputPath, clearOldImages, originX, originY, roundCoordinatesToInteger) +function captureLayers( + layers, + sprite, + effectiveVisibilities, + outputPath, + clearOldImages, + originX, + originY, + roundCoordinatesToInteger) -- Default output path to the sprite-name json in the sprite's directory. if (outputPath == nil or outputPath == "") then local defaultOutputDir = app.fs.filePath(sprite.filename) local defaultSpriteName = app.fs.fileTitle(sprite.filename) outputPath = defaultOutputDir .. app.fs.pathSeparator .. defaultSpriteName .. ".json" end - local outputDir = app.fs.filePath(outputPath) - - -- Create the output directory if it doesn't exist + -- Create the output images directory if it doesn't exist local separator = app.fs.pathSeparator local imagesDir = outputDir .. separator .. "images" - -- If the user chose to clear old images, delete the existing images directory and its contents before creating a new one + -- If the user chose to clear old images, delete the existing images directory if (clearOldImages == true) then deleteDirectoryRecursive(imagesDir) end app.fs.makeDirectory(imagesDir) + -- record any failed paths so we can show an error to the user at the end. + local failedPaths = {} + local function addFailedPath(path) + if (path == nil or path == "") then + return + end + for _, existing in ipairs(failedPaths) do + if (existing == path) then + return + end + end + failedPaths[#failedPaths + 1] = path + end + -- Probe images directory write permission. + local probePath = imagesDir .. separator .. ".aseprite_write_probe.tmp" + local probeFile = io.open(probePath, "w") + if (probeFile ~= nil) then + probeFile:close() + os.remove(probePath) + else + addFailedPath(imagesDir) + end + -- First hide all layers so we can selectively show them when we capture them hideAllLayers(layers) + -- Create and open the output json file for writing local jsonFileName = outputPath - json = io.open(jsonFileName, "w") - json:write('{') - -- skeleton - json:write([[ "skeleton": { "images": "images/" }, ]]) - -- bones - json:write([[ "bones": [ { "name": "root" } ], ]]) - + local json = io.open(jsonFileName, "w") + if (json == nil) then + addFailedPath(jsonFileName) + else + json:write('{') + -- skeleton + json:write([[ "skeleton": { "images": "images/" }, ]]) + -- bones + json:write([[ "bones": [ { "name": "root" } ], ]]) + end -- build arrays of json properties for skins and slots -- we only include layers, not groups local slotsJson = {} local skinsJson = {} local index = 1 - for i, layer in ipairs(layers) do -- Ignore groups and non-visible layers if (not layer.isGroup and effectiveVisibilities[i] == true) then -- Set the layer to visible so we can capture it, then set it back to hidden after layer.isVisible = true local cel = layer.cels[1] - local cropped = Sprite(sprite) - cropped:crop(cel.position.x, cel.position.y, cel.bounds.width, cel.bounds.height) - cropped:saveCopyAs(imagesDir .. separator .. layer.name .. ".png") - cropped:close() + local imagePath = imagesDir .. separator .. layer.name .. ".png" + local savedOk = false + savedOk = pcall(function() + local cropped = Sprite(sprite) + cropped:crop(cel.position.x, cel.position.y, cel.bounds.width, cel.bounds.height) + cropped:saveCopyAs(imagePath) + cropped:close() + end) + if (savedOk ~= true) then + addFailedPath(imagePath) + end layer.isVisible = false local name = layer.name -- Calculate the attachment position based on the cel position, cel bounds, sprite bounds, and the user-defined originX and originY. @@ -155,22 +223,24 @@ function captureLayers(layers, sprite, effectiveVisibilities, outputPath, clearO end -- slots - json:write('"slots": [') - json:write(table.concat(slotsJson, ",")) - json:write("],") - -- skins - json:write('"skins": {') - json:write('"default": {') - json:write(table.concat(skinsJson, ",")) - json:write('}') - json:write('}') - - -- close the json - json:write("}") - json:close() + if (json ~= nil) then + json:write('"slots": [') + json:write(table.concat(slotsJson, ",")) + json:write("],") + -- skins + json:write('"skins": {') + json:write('"default": {') + json:write(table.concat(skinsJson, ",")) + json:write('}') + json:write('}') + + -- close the json + json:write("}") + json:close() + end -- Show export completion dialog - showExportCompletedDialog(jsonFileName) + showExportCompletedDialog(jsonFileName, failedPaths) end --[[ @@ -220,7 +290,7 @@ function openFileLocation(filePath) end ------------------------------------------------[[ UI ]]----------------------------------------------- +-----------------------------------------------[[ UI Functions ]]----------------------------------------------- --[[ Shows the export options dialog and returns the selected options. defaultOutputPath: The default json output path @@ -229,7 +299,26 @@ function showExportOptionsDialog(defaultOutputPath) -- Create a dialog to show export optionsDialog local optionsDialog = Dialog({ title = "Export To Spine" }) - -- label: Coordinate settings section + --#region Coordinate Settings + + -- function: Clamps a number to the range [0,1]. + local function clampTo01(value) + if (value < 0) then + return 0 + elseif (value > 1) then + return 1 + end + return value + end + -- function: Clamps the input field for originX and originY to the range [0,1]. + local function clampOriginField(fieldId, fallback) + local parsed = tonumber(optionsDialog.data[fieldId]) + if (parsed == nil) then + parsed = fallback + end + optionsDialog:modify({ id = fieldId, text = string.format("%.3f", clampTo01(parsed)) }) + end + optionsDialog:label({ id = "coordinateSettings", label = "Coordinate Settings", @@ -239,14 +328,53 @@ function showExportOptionsDialog(defaultOutputPath) optionsDialog:number({ id = "originX", label = "Origin (X,Y)", - text = "0.5", + text = "0.500", decimals = 3, + onchange = function() + clampOriginField("originX", 0.5) + end }) :number({ id = "originY", - text = "0", + text = "0.000", decimals = 3, + onchange = function() + clampOriginField("originY", 0) + end }) + + -- button: Presets for common origin settings (center, bottom-center, bottom-left, top-left) + local function setOriginPreset(x, y) + optionsDialog:modify({ id = "originX", text = string.format("%.3f", x) }) + optionsDialog:modify({ id = "originY", text = string.format("%.3f", y) }) + end + optionsDialog:newrow() + optionsDialog:button({ + text = "Center", + onclick = function() + setOriginPreset(0.5, 0.5) + end + }) + optionsDialog:button({ + text = "Bottom-Center", + onclick = function() + setOriginPreset(0.5, 0) + end + }) + optionsDialog:button({ + text = "Bottom-Left", + onclick = function() + setOriginPreset(0, 0) + end + }) + optionsDialog:button({ + text = "Top-Left", + onclick = function() + setOriginPreset(0, 1) + end + }) + optionsDialog:newrow() + -- check: Option to round attachment coordinates to integers instead of keeping decimals optionsDialog:check({ id = "roundCoordinatesToInteger", @@ -255,7 +383,9 @@ function showExportOptionsDialog(defaultOutputPath) selected = false }) optionsDialog:separator({}) - +--#endregion + + --#region Output Path Settings -- entry: Output json path optionsDialog:entry({ id = "outputPath", @@ -277,7 +407,9 @@ function showExportOptionsDialog(defaultOutputPath) end }) optionsDialog:separator({}) + --#endregion + --#region Other Settings -- check: Option to ignore group visibility when determining layer visibilityStates optionsDialog:check({ id = "ignoreGroupVisibility", @@ -294,7 +426,9 @@ function showExportOptionsDialog(defaultOutputPath) selected = false }) optionsDialog:separator({}) - + --#endregion + + --#region Execution Buttons -- button: Confirm export local confirmed = false optionsDialog:button({ @@ -313,29 +447,27 @@ function showExportOptionsDialog(defaultOutputPath) optionsDialog:close() end }) + --#endregion - -- Show the dialog with width 500 and centered position. - local dialogWidth = 500 - local dialogHeight = 190 - local x = (app.window.width - dialogWidth) / 2 - local y = (app.window.height - dialogHeight) / 2 - optionsDialog:show({ wait = true, bounds = Rectangle(x, y, dialogWidth, dialogHeight) }) + -- Show the dialog and wait for user input. + optionsDialog:show({ wait = true}) if (not confirmed) then return nil end + --#region options Data Extraction -- Get the selected options from the dialog local options = optionsDialog.data -- Fallback to default path when input is empty. if (options.outputPath == nil or options.outputPath == "") then options.outputPath = defaultOutputPath end - -- Parse originX and originY as numbers, and fallback to defaults if parsing fails or values are out of range local parsedOriginX = tonumber(options.originX) local parsedOriginY = tonumber(options.originY) - options.originX = (parsedOriginX ~= nil and parsedOriginX >= 0 and parsedOriginX <= 1) and parsedOriginX or 0.5 - options.originY = (parsedOriginY ~= nil and parsedOriginY >= 0 and parsedOriginY <= 1) and parsedOriginY or 0 + options.originX = clampTo01(parsedOriginX or 0.5) + options.originY = clampTo01(parsedOriginY or 0) + --#endregion return options end @@ -343,16 +475,38 @@ end --[[ Shows export completion dialog with action to open the exported file location. jsonFileName: The exported json file path +failedPaths: The list of file/directory paths that failed to write ]] -function showExportCompletedDialog(jsonFileName) +function showExportCompletedDialog(jsonFileName, failedPaths) local completedDialog = Dialog({ title = "Export Completed" }) + -- Show the exported file path completedDialog:label({ id = "message", - text = "Export completed! Use this file for importing into Spine:\n" .. jsonFileName + text = "Export completed! Use this file for importing into Spine:" }) + completedDialog:newrow() + completedDialog:label({ + text = jsonFileName + }) + + -- If there were any failed paths, show an error message and list the failed paths. + if failedPaths ~= nil and #failedPaths > 0 then + completedDialog:newrow() + completedDialog:label({ + text = "Failed to write:" + }) + -- List each failed path + for _, path in ipairs(failedPaths) do + completedDialog:newrow() + completedDialog:label({ + text = path + }) + end + end completedDialog:newrow() + -- Button to open the file location in the OS file explorer completedDialog:button({ text = "Open File Folder", onclick = function() @@ -360,7 +514,7 @@ function showExportCompletedDialog(jsonFileName) completedDialog:close() end }) - + -- Button to close the dialog completedDialog:button({ text = "OK", focus = true, @@ -408,12 +562,12 @@ local visibilities = captureVisibilityStates(flattenedLayers) -- Saves each sprite layer as a separate .png under the 'images' subdirectory captureLayers( - flattenedLayers, - activeSprite, - effectiveVisibilities, - options.outputPath, - options.clearOldImages, - options.originX, + flattenedLayers, + activeSprite, + effectiveVisibilities, + options.outputPath, + options.clearOldImages, + options.originX, options.originY, options.roundCoordinatesToInteger ) From df59492d89eaad6fd8ac272721ecc94939e928f1 Mon Sep 17 00:00:00 2001 From: Ale Date: Tue, 17 Mar 2026 23:10:37 +0900 Subject: [PATCH 06/17] [Aseprite] Added persistent UI configuration cache - Added configuration caching for all export options, so settings are restored automatically on next launch. - Added a Reset Config button to restore default values and clear cached settings. --- aseprite/Prepare-For-Spine.lua | 138 +++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 17 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index 745a263..9b03d40 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -293,12 +293,35 @@ end -----------------------------------------------[[ UI Functions ]]----------------------------------------------- --[[ Shows the export options dialog and returns the selected options. -defaultOutputPath: The default json output path ]] -function showExportOptionsDialog(defaultOutputPath) +function showExportOptionsDialog() -- Create a dialog to show export optionsDialog local optionsDialog = Dialog({ title = "Export To Spine" }) + -- Load cached options or use defaults if no cache exists + local activeSprite = app.activeSprite + local spriteOutputDir = app.fs.filePath(activeSprite.filename) + local spriteOutputName = app.fs.fileTitle(activeSprite.filename) + local defaultOutputPath = spriteOutputDir .. app.fs.pathSeparator .. spriteOutputName .. ".json" + local cachedOptions, configPath = loadCachedOptions(defaultOutputPath) + + --#region Other Buttons + -- button: Resets all options to their default values + optionsDialog:button({ + text = "Reset Config", + onclick = function() + optionsDialog:modify({ id = "originX", text = string.format("%.3f", 0.5) }) + optionsDialog:modify({ id = "originY", text = string.format("%.3f", 0) }) + optionsDialog:modify({ id = "roundCoordinatesToInteger", selected = false }) + optionsDialog:modify({ id = "outputPath", text = defaultOutputPath }) + optionsDialog:modify({ id = "ignoreGroupVisibility", selected = false }) + optionsDialog:modify({ id = "clearOldImages", selected = false }) + end + }) + + optionsDialog:separator({}) + --#endregion + --#region Coordinate Settings -- function: Clamps a number to the range [0,1]. @@ -328,7 +351,7 @@ function showExportOptionsDialog(defaultOutputPath) optionsDialog:number({ id = "originX", label = "Origin (X,Y)", - text = "0.500", + text = string.format("%.3f", cachedOptions.originX), decimals = 3, onchange = function() clampOriginField("originX", 0.5) @@ -336,7 +359,7 @@ function showExportOptionsDialog(defaultOutputPath) }) :number({ id = "originY", - text = "0.000", + text = string.format("%.3f", cachedOptions.originY), decimals = 3, onchange = function() clampOriginField("originY", 0) @@ -380,7 +403,7 @@ function showExportOptionsDialog(defaultOutputPath) id = "roundCoordinatesToInteger", label = "Round Coordinates To Integer", text = "Drop decimal pixels, May misalign pixels; not recommended for pixel art.", - selected = false + selected = cachedOptions.roundCoordinatesToInteger }) optionsDialog:separator({}) --#endregion @@ -390,13 +413,13 @@ function showExportOptionsDialog(defaultOutputPath) optionsDialog:entry({ id = "outputPath", label = "Output Path", - text = defaultOutputPath + text = cachedOptions.outputPath }) -- file: File picker to select output json path (syncs with entry) optionsDialog:file({ id = "outputPathPicker", title = "Select Output Path", - filename = defaultOutputPath, + filename = cachedOptions.outputPath, text = "Select Output Path", save = true, onchange = function() @@ -415,7 +438,7 @@ function showExportOptionsDialog(defaultOutputPath) id = "ignoreGroupVisibility", label = "Ignore Group Visibility", text = "Use layer visibility only.", - selected = false + selected = cachedOptions.ignoreGroupVisibility }) -- check: Option to clear old images in the output images directory before export @@ -423,7 +446,7 @@ function showExportOptionsDialog(defaultOutputPath) id = "clearOldImages", label = "Clear Old Images", text = "Delete existing images first.", - selected = false + selected = cachedOptions.clearOldImages }) optionsDialog:separator({}) --#endregion @@ -451,9 +474,6 @@ function showExportOptionsDialog(defaultOutputPath) -- Show the dialog and wait for user input. optionsDialog:show({ wait = true}) - if (not confirmed) then - return nil - end --#region options Data Extraction -- Get the selected options from the dialog @@ -467,8 +487,16 @@ function showExportOptionsDialog(defaultOutputPath) local parsedOriginY = tonumber(options.originY) options.originX = clampTo01(parsedOriginX or 0.5) options.originY = clampTo01(parsedOriginY or 0) + + -- Save the options to cache so they will be remembered next time the dialog is opened. + saveCachedOptions(configPath, options) --#endregion + -- If the user did not confirm the export (clicked Cancel or closed the dialog), return nil. + if (not confirmed) then + return nil + end + return options end @@ -526,10 +554,89 @@ function showExportCompletedDialog(jsonFileName, failedPaths) completedDialog:show({ wait = true }) end +--#region Config Caching Functions + +--[[ +Parses a string boolean value. +value: The string to parse ("true" or "false") +fallback: The value to return if parsing fails (not "true" or "false") +]] +function parseBool(value, fallback) + if (value == "true") then + return true + elseif (value == "false") then + return false + end + return fallback +end + +--[[ +Loads cached UI options from disk. +defaultOutputPath: The default output path to use if no cached path is found +]] +function loadCachedOptions(defaultOutputPath) + local cached = { + originX = 0.5, + originY = 0, + roundCoordinatesToInteger = false, + outputPath = defaultOutputPath, + ignoreGroupVisibility = false, + clearOldImages = false + } + -- Create a config directory under the user's Aseprite config path, and define the config file path + local configDir = app.fs.joinPath(app.fs.filePath(app.fs.userConfigPath), "Cache") + app.fs.makeDirectory(configDir) + local configPath = app.fs.joinPath(configDir, "Prepare-For-Spine-Config.json") + local configFile = io.open(configPath, "r") + if (configFile == nil) then + return cached, configPath + end + + local raw = {} + for line in configFile:lines() do + local key, value = string.match(line, "^([^=]+)=(.*)$") + if (key ~= nil and value ~= nil) then + raw[key] = value + end + end + configFile:close() + + cached.originX = tonumber(raw.originX) or cached.originX + cached.originY = tonumber(raw.originY) or cached.originY + cached.roundCoordinatesToInteger = parseBool(raw.roundCoordinatesToInteger, cached.roundCoordinatesToInteger) + cached.ignoreGroupVisibility = parseBool(raw.ignoreGroupVisibility, cached.ignoreGroupVisibility) + cached.clearOldImages = parseBool(raw.clearOldImages, cached.clearOldImages) + if (raw.outputPath ~= nil and raw.outputPath ~= "") then + cached.outputPath = raw.outputPath + end + + return cached, configPath +end + +--[[ +Saves UI options to cache file. +configPath: The path to the config file to save +options: The options to save +]] +function saveCachedOptions(configPath, options) + local configFile = io.open(configPath, "w") + if (configFile == nil) then + return + end + + configFile:write("originX=" .. string.format("%.3f", options.originX) .. "\n") + configFile:write("originY=" .. string.format("%.3f", options.originY) .. "\n") + configFile:write("roundCoordinatesToInteger=" .. tostring(options.roundCoordinatesToInteger == true) .. "\n") + configFile:write("outputPath=" .. (options.outputPath or "") .. "\n") + configFile:write("ignoreGroupVisibility=" .. tostring(options.ignoreGroupVisibility == true) .. "\n") + configFile:write("clearOldImages=" .. tostring(options.clearOldImages == true) .. "\n") + configFile:close() +end +--#endregion + -----------------------------------------------[[ Main Execution ]]----------------------------------------------- local activeSprite = app.activeSprite - if (activeSprite == nil) then -- If user has no active sprite selected in the UI app.alert("Please click the sprite you'd like to export") @@ -541,10 +648,7 @@ elseif (activeSprite.filename == "") then end -- Show the export options dialog UI and get the user's selected options. -local spriteOutputDir = app.fs.filePath(activeSprite.filename) -local spriteOutputName = app.fs.fileTitle(activeSprite.filename) -local defaultOutputPath = spriteOutputDir .. app.fs.pathSeparator .. spriteOutputName .. ".json" -local options = showExportOptionsDialog(defaultOutputPath) +local options = showExportOptionsDialog() if (options == nil) then return end From ef2f7dd8e02e72f1ae7625c35ff07a2b82d29407 Mon Sep 17 00:00:00 2001 From: Ale Date: Wed, 18 Mar 2026 00:53:11 +0900 Subject: [PATCH 07/17] [Aseprite] Update README to v1.2 - Update version to v1.2. - Add configuration UI screenshots and usage guide. - Add Chinese (ZH) README. --- aseprite/Images/image.png | Bin 0 -> 9005 bytes aseprite/README.md | 97 ++++++++++++++++++++++++++++----- aseprite/README_cn.md | 112 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 aseprite/Images/image.png create mode 100644 aseprite/README_cn.md diff --git a/aseprite/Images/image.png b/aseprite/Images/image.png new file mode 100644 index 0000000000000000000000000000000000000000..6e2e6647735cb253cb0d2264e3d6cdf3bd507034 GIT binary patch literal 9005 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 Mv%AxcPfmRCzsF^8A^-pY literal 0 HcmV?d00001 diff --git a/aseprite/README.md b/aseprite/README.md index 90154d2..e821a78 100644 --- a/aseprite/README.md +++ b/aseprite/README.md @@ -1,14 +1,15 @@ -### Update - This has been added to the official Spine Scripts repository: +### Update - This has been added to the official Spine Scripts repository - https://github.com/EsotericSoftware/spine-scripts + ___ -# aseprite-to-spine +# aseprite-to-spine +[中文版 文档](README_cn.md) ## Lua Script for importing Aseprite projects into Spine -## v1.1 +## v1.2 ### Installation @@ -19,26 +20,92 @@ ___ After following these steps, the "Prepare-For-Spine" script should show up in the list. -### Usage +### Usage -1. Create your sprite just like you would in Photoshop. Each "bone" should be on its own layer. -2. Keep in mind that layer "groups" are ignored when exporting. -3. When you're ready to bring your art into Spine, save your project and run the ```Prepare-For-Spine``` script. This will create a .json file as well as an "images" folder in the directory your aseprite project is saved in. -4. If you get a dialogue requesting permissions for the script, click "give full trust" (it's just requesting permission for the export script to save files). -5. Open Spine and create a new project -6. Click the Spine Logo in the top left to open the file menu, and click **Import Data**. -7. Set up your Skeleton and start creating animations! +#### 「Aseprite Export」 + +1. Create your sprite just like you would in Photoshop. Each "bone" should be on its own layer. +2. When you're ready to bring your art into Spine, save your project and run the ```Prepare-For-Spine``` script. You can find it under **File > Scripts > Prepare-For-Spine**. +3. Configure the export options as needed, then click the "Export" button. By default, the script will export a JSON file and a folder of PNG images to the same directory as your Aseprite project file. + * The default configuration is suitable for most users, so you can simply click the Export button to use the default settings. + +![alt text](Images/image.png) + +* Reset Config Button: Resets all options to their default values. + * This will also clear any cached settings, so the next time you open the options dialog it will be restored to the default values. +* Origin (X/Y): Sets the coordinate origin for the exported images. + * This coordinate origin will align with the coordinate origin in Spine, affecting the default position of the images when imported into Spine. + * The origin coordinates are normalized to the range [0,1], where (0,0) represents the bottom-left corner of the image and (1,1) represents the top-right corner. + * There are also quick preset buttons for common origin configurations (Center, Bottom-Center, Bottom-Left, Top-Left) that will automatically set the X and Y values accordingly. +* Round Coordinates to Integer: When enabled, the script will round all coordinate values to the nearest integer, dropping any decimal part. + * This may cause pixel misalignment. For example, if the origin is set to center and the image has odd pixel dimensions, the true center lies at the center of the middle pixel rather than on an edge. Forcing integer coordinates can therefore introduce a half-pixel offset. + * Pixel art usually requires perfect pixel alignment, so this option is not recommended unless you have a specific need. +* Output Path: Allows you to specify a custom output path for the exported JSON file. + * By default, it will be saved in the same directory as your Aseprite project file. + * You can type a path directly into the text field, or click the button below to open a file picker dialog. After selecting a location, the path is filled into the text field automatically. +* Ignore Group Visibility: When enabled, the script will ignore the visibility of groups during export. + * This only considers each layer's own visibility and ignores the visibility of its parent group. That means a layer can still be exported even if its group is hidden, as long as the layer itself is visible. +* Clear Old Images: When enabled, the script will automatically delete any previously exported images in the output directory before exporting new ones. + * This helps to prevent confusion and clutter from old files that are no longer relevant to the current export. +* Export Button: Starts the export process with the configured options. + * After export completes, click the [Open File Folder] button to open the directory containing the exported files. +* Cancel Button: Closes the options dialog without exporting. + +If you get a dialogue requesting permissions for the script, click "give full trust" (it's just requesting permission for the export script to save files). + +#### 「Spine Import」 + +1. Open Spine and create a new project. +2. Click the Spine Logo in the top left to open the file menu, and click **Import Data**. +3. Set up your Skeleton and start creating animations! + +### Known Issues + +#### v1.2 + +* Opening the exported file location currently relies on `os` library APIs and may cause a brief UI stall (a few seconds). +* Deleting old `images` files also relies on `os` library APIs and may cause a brief UI stall. + +#### v1.1 -### Known Issues * Hiding a group of layers will not exclude it from the export. Each layer needs to be shown or hidden individually (group visibility is ignored) * Not as many options as the Photshop script. Maybe I'll add these in the future but honestly i've never used any of them so we will see. ### Version History +#### v1.2 + +* Enable Effective Group Visibility During Export + * Propagated group visibility downward during recursive traversal. + * Combined layer collection and effective-visibility recording into a single recursive pass to improve efficiency. + +* Added a new UI options panel + * Toggle for Ignore Group Visibility. + * Export path setting for the output JSON file. + +* Added updates to the UI options panel + * Toggle for Clear Old Images before export. + * Simplified output path selection workflow. + * Improved overall UI layout and spacing. + +* Added updates to the UI options panel + * Coordinate origin is now configurable (X/Y), with range support for [0,1]. + * Added a toggle to keep coordinate values as integers (drop decimal part). + * Added quick access to open the exported file location after export completion. + +* Added export workflow and coordinate UI improvements + * Added origin coordinate preset buttons for quick setup (Center, Bottom-Center, Bottom-Left, Top-Left). + * Added real-time clamping for origin coordinate inputs, limiting values to the [0,1] range. + * Added export completion dialog warnings that list any file paths that failed to write during export. + +* Added persistent UI configuration cache + * Added configuration caching for all export options, so settings are restored automatically on next launch. + * Added a Reset Config button to restore default values and clear cached settings. + #### v1.1 -- Changed to export images trimmed to the size of their non-transparent pixels. -- Hidden layers are not included in the json file for importing into Spine. +* Changed to export images trimmed to the size of their non-transparent pixels. +* Hidden layers are not included in the json file for importing into Spine. #### v1.0 diff --git a/aseprite/README_cn.md b/aseprite/README_cn.md new file mode 100644 index 0000000..c1e2622 --- /dev/null +++ b/aseprite/README_cn.md @@ -0,0 +1,112 @@ +### 更新 - 本脚本已收录到官方 Spine Scripts 仓库 + + + +___ + +# aseprite-to-spine +[English README](README.md) + +## 用于将 Aseprite 项目导入 Spine 的 Lua 脚本 + +## v1.2 + +### 安装 + +1. 打开 Aseprite +2. 进入 **File > Scripts > Open Scripts Folder** +3. 将附带的 ```Prepare-For-Spine.lua``` 文件拖入该目录 +4. 在 Aseprite 中点击 **File > Scripts > Rescan Scripts Folder** + +完成以上步骤后,你应该能在脚本列表中看到 "Prepare-For-Spine"。 + +### 使用说明 + +#### 「Aseprite 导出」 + +1. 像在 Photoshop 中那样创建你的 精灵。每个 "bone" 建议单独放在一个图层中。 +2. 当你准备将美术资源导入 Spine 时,先保存项目,然后运行 ```Prepare-For-Spine``` 脚本。你可以在 **File > Scripts > Prepare-For-Spine** 中找到它。 +3. 按需配置导出选项后,点击 "Export" 按钮。默认情况下,脚本会将 JSON 文件和 PNG 图片文件夹导出到 Aseprite 项目文件所在目录。 + * 默认配置 已经适合大多数用户的需求了,所以你可以 直接点击 Export 按钮使用默认配置进行导出。 + +![alt text](Images/image.png) + +* Reset Config 按钮:将所有选项 重置为 默认值。 + * 同时会 清除缓存设置,因此下次 打开选项弹窗时会 恢复默认配置。 +* Origin (X/Y):设置导出图像在 Spine 中使用的 坐标原点。 + * 这个 坐标原点 会与 Spine中的坐标原点 对齐,影响导入后图片在Spine中的 默认位置。 + * 原点坐标 被规范化到 [0,1] 区间,其中 (0,0) 表示图像 左下角,(1,1) 表示图像 右上角。 + * 提供了 常用原点的 预设按钮(Center、Bottom-Center、Bottom-Left、Top-Left),点击后会 自动设置对应的 X、Y 值。 +* Round Coordinates to Integer:启用后,脚本会将所有 坐标值取整,丢弃小数部分。 + * 这可能导致 像素不对齐。例如,将原点设为中心 且 图片像素尺寸为奇数时,几何中心会落在 中间像素中心 而不是边界上,强制整数坐标 可能带来 半像素偏移。 + * 像素风格 通常需要严格的 像素对齐,除非有特殊需求,否则不建议开启该选项。 +* Output Path:允许你为导出的 JSON 文件 指定自定义输出路径。 + * 默认会保存到 Aseprite 项目文件所在目录。 + * 你可以直接在 文本框中输入路径,或者点击 下方按钮 打开文件选择对话框。选择后,路径会 自动填入文本框。 +* Ignore Group Visibility:启用后,导出时将 忽略组可见性。 + * 仅根据 图层自身可见性判断,不考虑其 父组是否可见。这意味着即使 组被隐藏,只要图层本身可见,仍会被导出。 +* Clear Old Images:启用后,导出前会 自动删除 输出目录中旧的图片。 + * 这可以减少 旧文件残留 造成的 混淆和目录杂乱。 +* Export 按钮:使用当前配置 开始导出。 + * 导出完成后,可点击 [Open File Folder] 按钮直接 打开导出目录。 +* Cancel 按钮:关闭选项弹窗并 取消导出。 + +如果脚本 请求权限,请点击 "give full trust"(脚本仅需要文件写入权限以完成导出)。 + +#### 「Spine 导入」 + +1. 打开 Spine 并新建项目。 +2. 点击左上角 Spine 图标打开文件菜单,然后点击 **Import Data**。 +3. 配置 Skeleton 并开始制作动画。 + +### 已知问题 + +#### v1.2 + +* 打开 导出文件位置,目前依赖 `os` 库 API,可能导致短暂 UI 卡顿(几秒)。 +* 删除旧的 `images` 文件,同样依赖 `os` 库 API,也可能导致短暂 UI 卡顿。 + +#### v1.1 + +* 隐藏图层组 不会阻止 组内图层导出。每个图层都需要 单独设置显示/隐藏(组可见性会被忽略)。 +* 选项数量相比 Photoshop 脚本更少。后续可能会补充,但作者目前很少用到这些选项。 + +### 版本历史 + +#### v1.2 + +* 导出时启用组可见性的有效继承 + * 在递归遍历中将组可见性向下传递。 + * 将图层收集与有效可见性记录合并为一次递归遍历,以提升效率。 + +* 新增 UI 选项面板 + * 增加 Ignore Group Visibility 开关。 + * 增加 JSON 输出路径设置。 + +* UI 选项面板更新 + * 增加导出前清理旧图片(Clear Old Images)开关。 + * 简化输出路径选择流程。 + * 优化整体 UI 布局与间距。 + +* UI 选项面板更新 + * 支持配置坐标原点(X/Y),范围为 [0,1]。 + * 增加坐标取整开关(丢弃小数部分)。 + * 导出完成后可快速打开导出文件位置。 + +* 导出流程与坐标配置改进 + * 增加原点坐标预设按钮(Center、Bottom-Center、Bottom-Left、Top-Left)。 + * 增加原点坐标输入实时范围限制,自动约束到 [0,1]。 + * 导出完成弹窗支持列出写入失败的文件路径。 + +* 增加 UI 配置持久化缓存 + * 缓存所有导出选项,下次启动自动恢复。 + * 增加 Reset Config 按钮,可恢复默认值并清除缓存。 + +#### v1.1 + +* 导出的图片会自动裁剪到非透明像素区域大小。 +* 隐藏图层不会被写入用于导入 Spine 的 JSON 文件。 + +#### v1.0 + +初始发布 From e0e1687838333a0ed4f0a72cb7408e8b7bf08e03 Mon Sep 17 00:00:00 2001 From: Ale Date: Wed, 18 Mar 2026 14:52:45 +0900 Subject: [PATCH 08/17] [Aseprite] Update README v1.2 & refine content - Add Spine import configuration guide. - Restructure document hierarchy for better readability. --- aseprite/Images/{image.png => image-1.png} | Bin aseprite/Images/image-2.png | Bin 0 -> 20549 bytes aseprite/README.md | 28 +++++++++-- aseprite/README_cn.md | 56 ++++++++++++++------- 4 files changed, 60 insertions(+), 24 deletions(-) rename aseprite/Images/{image.png => image-1.png} (100%) create mode 100644 aseprite/Images/image-2.png diff --git a/aseprite/Images/image.png b/aseprite/Images/image-1.png similarity index 100% rename from aseprite/Images/image.png rename to aseprite/Images/image-1.png diff --git a/aseprite/Images/image-2.png b/aseprite/Images/image-2.png new file mode 100644 index 0000000000000000000000000000000000000000..1b7c0ff7d9735361c4a6defac9978710a3743343 GIT binary patch literal 20549 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 Scripts > Prepare-For-Spine**. 3. Configure the export options as needed, then click the "Export" button. By default, the script will export a JSON file and a folder of PNG images to the same directory as your Aseprite project file. * The default configuration is suitable for most users, so you can simply click the Export button to use the default settings. +4. If you get a dialogue requesting permissions for the script, click "give full trust" (it's just requesting permission for the export script to save files). -![alt text](Images/image.png) +![alt text](Images/image-1.png) * Reset Config Button: Resets all options to their default values. * This will also clear any cached settings, so the next time you open the options dialog it will be restored to the default values. @@ -51,14 +53,30 @@ After following these steps, the "Prepare-For-Spine" script should show up in th * After export completes, click the [Open File Folder] button to open the directory containing the exported files. * Cancel Button: Closes the options dialog without exporting. -If you get a dialogue requesting permissions for the script, click "give full trust" (it's just requesting permission for the export script to save files). - #### 「Spine Import」 1. Open Spine and create a new project. -2. Click the Spine Logo in the top left to open the file menu, and click **Import Data**. +2. Click the Spine Logo in the top left to open the file menu, and click **[Import Data]**. 3. Set up your Skeleton and start creating animations! +![alt text](Images/image-2.png) + +* Import: Import source. Here, use the default selected option: JSON or binary file. + * JSON or binary file: Import from a JSON file or a binary file. + * Folder: Import from a folder. +* File: Select the JSON file or folder to import. + * Click the folder icon button on the right to open the file picker dialog, then choose the JSON file to import or a folder that contains a JSON file. +* Scale: Import scale. The default value is 1.0, which means no scaling. + * Adjust this value as needed. For example, set it to 0.5 to import assets at half size, or set it to 2.0 to import assets at double size. +* New Project: If checked, a new project will be created during import. Otherwise, imported assets will be added to the currently open project. + * If you already created an empty new project, you do not need to check this option and can import directly. +* Create a new skeleton: If checked, a new skeleton will be created during import. + * If you already created an empty new project, you do not need to check this option and can import directly. +* Import into an existing skeleton: If checked, imported assets will be added to an existing skeleton. + * Replace existing attachments: If checked, attachments with the same name in the existing skeleton will be replaced during import. +* Import button: Start importing with the current configuration. +* Cancel button: Close the dialog and cancel the import. + ### Known Issues #### v1.2 diff --git a/aseprite/README_cn.md b/aseprite/README_cn.md index c1e2622..294ff0a 100644 --- a/aseprite/README_cn.md +++ b/aseprite/README_cn.md @@ -4,9 +4,10 @@ ___ -# aseprite-to-spine [English README](README.md) +# aseprite-to-spine + ## 用于将 Aseprite 项目导入 Spine 的 Lua 脚本 ## v1.2 @@ -28,8 +29,9 @@ ___ 2. 当你准备将美术资源导入 Spine 时,先保存项目,然后运行 ```Prepare-For-Spine``` 脚本。你可以在 **File > Scripts > Prepare-For-Spine** 中找到它。 3. 按需配置导出选项后,点击 "Export" 按钮。默认情况下,脚本会将 JSON 文件和 PNG 图片文件夹导出到 Aseprite 项目文件所在目录。 * 默认配置 已经适合大多数用户的需求了,所以你可以 直接点击 Export 按钮使用默认配置进行导出。 +4. 如果脚本 请求权限,请点击 "give full trust"(脚本仅需要文件写入权限以完成导出)。 -![alt text](Images/image.png) +![alt text](Images/image-1.png) * Reset Config 按钮:将所有选项 重置为 默认值。 * 同时会 清除缓存设置,因此下次 打开选项弹窗时会 恢复默认配置。 @@ -51,14 +53,30 @@ ___ * 导出完成后,可点击 [Open File Folder] 按钮直接 打开导出目录。 * Cancel 按钮:关闭选项弹窗并 取消导出。 -如果脚本 请求权限,请点击 "give full trust"(脚本仅需要文件写入权限以完成导出)。 - #### 「Spine 导入」 1. 打开 Spine 并新建项目。 -2. 点击左上角 Spine 图标打开文件菜单,然后点击 **Import Data**。 +2. 点击左上角 Spine 图标打开文件菜单,然后点击 **[Import Data]**。 3. 配置 Skeleton 并开始制作动画。 +![alt text](Images/image-2.png) + +* Import:导入来源。这里使用 默认选择的 JSON or binary file。 + * JSON or binary file:从 JSON 或二进制文件导入。 + * Folder:从文件夹导入。 +* File:选择要导入的 JSON 文件或文件夹。 + * 点击右侧的 “文件夹”图标按钮,可以打开 文件选择对话框,选择要导入的 JSON 文件或包含 JSON 文件的文件夹。 +* Scale:导入时的缩放比例。默认值为 1.0,表示不缩放。 + * 可以根据需要 调整该值,例如设置为 0.5 将导入资源 缩小一半,设置为 2.0 将导入资源 放大两倍。 +* New Project:如果选中,导入时会 创建一个新项目。否则,导入的资源会被添加到 当前打开的项目中。 + * 如果已经创建了 空的新项目,则不需要选中该选项,直接导入即可。 +* Create a new skeleton:如果选中,导入时会 创建一个新的骨架。 + * 如果已经创建了 空的新项目,则不需要选中该选项,直接导入即可。 +* Import into an existing skeleton:如果选中,导入的资源会被添加到 现有骨架中。 + * Replace existing attachments:如果选中,导入时会 替换现有骨架中 同名的附件。 +* Import 按钮:使用当前配置 开始导入。 +* Cancel 按钮:关闭对话框并 取消导入。 + ### 已知问题 #### v1.2 @@ -76,36 +94,36 @@ ___ #### v1.2 * 导出时启用组可见性的有效继承 - * 在递归遍历中将组可见性向下传递。 - * 将图层收集与有效可见性记录合并为一次递归遍历,以提升效率。 + * 在递归遍历中 将组可见性 向下传递。 + * 将 图层收集 与 有效可见性记录 合并为一次递归遍历,以提升效率。 * 新增 UI 选项面板 * 增加 Ignore Group Visibility 开关。 * 增加 JSON 输出路径设置。 * UI 选项面板更新 - * 增加导出前清理旧图片(Clear Old Images)开关。 - * 简化输出路径选择流程。 + * 增加导出前 清理旧图片(Clear Old Images)开关。 + * 简化 输出路径选择流程。 * 优化整体 UI 布局与间距。 * UI 选项面板更新 - * 支持配置坐标原点(X/Y),范围为 [0,1]。 - * 增加坐标取整开关(丢弃小数部分)。 - * 导出完成后可快速打开导出文件位置。 + * 支持配置 坐标原点(X/Y),范围为 [0,1]。 + * 增加 坐标取整 开关(丢弃小数部分)。 + * 导出完成后 可快速打开 导出文件位置。 * 导出流程与坐标配置改进 - * 增加原点坐标预设按钮(Center、Bottom-Center、Bottom-Left、Top-Left)。 - * 增加原点坐标输入实时范围限制,自动约束到 [0,1]。 - * 导出完成弹窗支持列出写入失败的文件路径。 + * 增加 原点坐标预设按钮(Center、Bottom-Center、Bottom-Left、Top-Left)。 + * 增加 原点坐标输入 实时范围限制,自动约束到 [0,1]。 + * 导出完成弹窗 支持列出 写入失败的文件路径。 * 增加 UI 配置持久化缓存 - * 缓存所有导出选项,下次启动自动恢复。 - * 增加 Reset Config 按钮,可恢复默认值并清除缓存。 + * 缓存所有 导出选项,下次启动 自动恢复。 + * 增加 Reset Config 按钮,可恢复默认值 并清除缓存。 #### v1.1 -* 导出的图片会自动裁剪到非透明像素区域大小。 -* 隐藏图层不会被写入用于导入 Spine 的 JSON 文件。 +* 导出的图片会 自动裁剪 到非透明像素区域大小。 +* 隐藏图层 不会被写入用于导入 Spine 的 JSON 文件。 #### v1.0 From ef79814f36316c21c211010334e568cadad84864 Mon Sep 17 00:00:00 2001 From: Ale Date: Wed, 18 Mar 2026 15:29:01 +0900 Subject: [PATCH 09/17] [Aseprite] Cleanup whitespace and format code --- aseprite/Prepare-For-Spine.lua | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index 9b03d40..b558cfb 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -22,11 +22,11 @@ function flattenWithEffectiveVisibility(parent, outLayers, outVis, inheritedVisi else effectiveVisible = inheritedVisible and layer.isVisible end - + -- Append the layer and its effective visibility to the output arrays outLayers[#outLayers + 1] = layer outVis[#outVis + 1] = effectiveVisible - + -- If this layer is a group, recursively flatten its children, passing down the effective visibilityStates if layer.isGroup then local nextInherited = ignoreGroupVisibility and true or effectiveVisible @@ -120,13 +120,13 @@ originX, originY: the user-defined origin point for the exported Spine skeleton, roundCoordinatesToInteger: if true, rounds the attachment coordinates to the nearest integer instead of keeping decimals (not recommended for pixel art) ]] function captureLayers( - layers, - sprite, - effectiveVisibilities, - outputPath, - clearOldImages, - originX, - originY, + layers, + sprite, + effectiveVisibilities, + outputPath, + clearOldImages, + originX, + originY, roundCoordinatesToInteger) -- Default output path to the sprite-name json in the sprite's directory. if (outputPath == nil or outputPath == "") then @@ -365,7 +365,7 @@ function showExportOptionsDialog() clampOriginField("originY", 0) end }) - + -- button: Presets for common origin settings (center, bottom-center, bottom-left, top-left) local function setOriginPreset(x, y) optionsDialog:modify({ id = "originX", text = string.format("%.3f", x) }) @@ -406,7 +406,7 @@ function showExportOptionsDialog() selected = cachedOptions.roundCoordinatesToInteger }) optionsDialog:separator({}) ---#endregion + --#endregion --#region Output Path Settings -- entry: Output json path @@ -450,7 +450,7 @@ function showExportOptionsDialog() }) optionsDialog:separator({}) --#endregion - + --#region Execution Buttons -- button: Confirm export local confirmed = false From 4a03d7897b96be288f6fc143f752383602bb400c Mon Sep 17 00:00:00 2001 From: Ale Date: Wed, 18 Mar 2026 18:55:11 +0900 Subject: [PATCH 10/17] [Aseprite] Update README v1.2 - Added a Known Issues regarding potential Draw Order issues in Spine when importing new layers from Aseprite. --- aseprite/README.md | 2 ++ aseprite/README_cn.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/aseprite/README.md b/aseprite/README.md index 32e6181..a2f70d2 100644 --- a/aseprite/README.md +++ b/aseprite/README.md @@ -74,6 +74,7 @@ After following these steps, the "Prepare-For-Spine" script should show up in th * If you already created an empty new project, you do not need to check this option and can import directly. * Import into an existing skeleton: If checked, imported assets will be added to an existing skeleton. * Replace existing attachments: If checked, attachments with the same name in the existing skeleton will be replaced during import. + * New layers will generate new attachments and be added to the existing skeleton, but the draw order may be incorrect and needs to be manually adjusted in Spine. * Import button: Start importing with the current configuration. * Cancel button: Close the dialog and cancel the import. @@ -83,6 +84,7 @@ After following these steps, the "Prepare-For-Spine" script should show up in th * Opening the exported file location currently relies on `os` library APIs and may cause a brief UI stall (a few seconds). * Deleting old `images` files also relies on `os` library APIs and may cause a brief UI stall. +* New layers added in Aseprite may have incorrect draw order when imported into an existing Spine skeleton, and need to be adjusted manually in Spine. #### v1.1 diff --git a/aseprite/README_cn.md b/aseprite/README_cn.md index 294ff0a..751b4b7 100644 --- a/aseprite/README_cn.md +++ b/aseprite/README_cn.md @@ -74,6 +74,7 @@ ___ * 如果已经创建了 空的新项目,则不需要选中该选项,直接导入即可。 * Import into an existing skeleton:如果选中,导入的资源会被添加到 现有骨架中。 * Replace existing attachments:如果选中,导入时会 替换现有骨架中 同名的附件。 + * 新增的图层 会生成 新的附件 并添加到 现有的骨架中,但是 绘制顺序 可能会出现问题,需要在 Spine 中手动调整。 * Import 按钮:使用当前配置 开始导入。 * Cancel 按钮:关闭对话框并 取消导入。 @@ -83,6 +84,7 @@ ___ * 打开 导出文件位置,目前依赖 `os` 库 API,可能导致短暂 UI 卡顿(几秒)。 * 删除旧的 `images` 文件,同样依赖 `os` 库 API,也可能导致短暂 UI 卡顿。 +* Aseprite 中新增的图层,在导入到 Spine 的现有骨架时,可能会出现 绘制顺序不正确 的问题,需要在 Spine 中手动调整。 #### v1.1 From 3fed253ce67a18d574d763ab0c1c165a75d20379 Mon Sep 17 00:00:00 2001 From: Ale Date: Thu, 19 Mar 2026 00:49:59 +0900 Subject: [PATCH 11/17] [Aseprite] Add coordinate modes and refine layer visibility options - Added Normalized [0,1] and Pixel modes for origin coordinates. - Added Sliders for Origin (X, Y) to allow more intuitive control. - Added "Ignore Hidden Layers" toggle for more flexible exports. - Removed redundant "Use layer visibility only" option. --- aseprite/Prepare-For-Spine.lua | 514 ++++++++++++++++++++++++++++----- 1 file changed, 448 insertions(+), 66 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index b558cfb..4357054 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -6,31 +6,30 @@ https://github.com/jordanbleu/aseprite-to-spine -----------------------------------------------[[ Functions ]]----------------------------------------------- --[[ -Flattens the layers of a sprite while allowing optional ignore of parent group visibility. +Flattens the layers of a sprite and computes each layer's export visibility. parent: The sprite or parent layer group outLayers: The array to append the flattened layers outVis: The array to append the effective visibility of each layer (true / false) -inheritedVisible: The visibility inherited from parent groups (true / false) -ignoreGroupVisibility: If true, visibility only depends on the layer's own isVisible value +groupIsVisible: The visibility inherited from parent groups (true / false) +ignoreHiddenLayers: If true, hidden layers and layers under hidden groups are excluded ]] -function flattenWithEffectiveVisibility(parent, outLayers, outVis, inheritedVisible, ignoreGroupVisibility) +function flattenWithEffectiveVisibility(parent, outLayers, outVis, groupIsVisible, ignoreHiddenLayers) for _, layer in ipairs(parent.layers) do -- Determine the effective visibility of the layer based on its own visibility and the inherited visibility from parent groups local effectiveVisible - if (ignoreGroupVisibility) then - effectiveVisible = layer.isVisible + if (ignoreHiddenLayers) then + effectiveVisible = groupIsVisible and layer.isVisible else - effectiveVisible = inheritedVisible and layer.isVisible + effectiveVisible = true end - + -- Append the layer and its effective visibility to the output arrays outLayers[#outLayers + 1] = layer outVis[#outVis + 1] = effectiveVisible - - -- If this layer is a group, recursively flatten its children, passing down the effective visibilityStates + + -- If this layer is a group, recursively flatten its children, passing down the effective visibility if layer.isGroup then - local nextInherited = ignoreGroupVisibility and true or effectiveVisible - flattenWithEffectiveVisibility(layer, outLayers, outVis, nextInherited, ignoreGroupVisibility) + flattenWithEffectiveVisibility(layer, outLayers, outVis, effectiveVisible, ignoreHiddenLayers) end end end @@ -120,13 +119,13 @@ originX, originY: the user-defined origin point for the exported Spine skeleton, roundCoordinatesToInteger: if true, rounds the attachment coordinates to the nearest integer instead of keeping decimals (not recommended for pixel art) ]] function captureLayers( - layers, - sprite, - effectiveVisibilities, - outputPath, - clearOldImages, - originX, - originY, + layers, + sprite, + effectiveVisibilities, + outputPath, + clearOldImages, + originX, + originY, roundCoordinatesToInteger) -- Default output path to the sprite-name json in the sprite's directory. if (outputPath == nil or outputPath == "") then @@ -296,25 +295,28 @@ Shows the export options dialog and returns the selected options. ]] function showExportOptionsDialog() -- Create a dialog to show export optionsDialog - local optionsDialog = Dialog({ title = "Export To Spine" }) + local optionsDialog = Dialog({ title = "Export To Spine v1.3" }) -- Load cached options or use defaults if no cache exists local activeSprite = app.activeSprite + local spriteWidth, spriteHeight = getActiveSpriteSize() local spriteOutputDir = app.fs.filePath(activeSprite.filename) local spriteOutputName = app.fs.fileTitle(activeSprite.filename) local defaultOutputPath = spriteOutputDir .. app.fs.pathSeparator .. spriteOutputName .. ".json" local cachedOptions, configPath = loadCachedOptions(defaultOutputPath) + CURRENT_ORIGIN_MODE = cachedOptions.originMode --#region Other Buttons -- button: Resets all options to their default values optionsDialog:button({ text = "Reset Config", onclick = function() + setOriginMode(optionsDialog, ORIGIN_MODE.NORMALIZED) optionsDialog:modify({ id = "originX", text = string.format("%.3f", 0.5) }) optionsDialog:modify({ id = "originY", text = string.format("%.3f", 0) }) optionsDialog:modify({ id = "roundCoordinatesToInteger", selected = false }) optionsDialog:modify({ id = "outputPath", text = defaultOutputPath }) - optionsDialog:modify({ id = "ignoreGroupVisibility", selected = false }) + optionsDialog:modify({ id = "ignoreHiddenLayers", selected = true }) optionsDialog:modify({ id = "clearOldImages", selected = false }) end }) @@ -324,37 +326,40 @@ function showExportOptionsDialog() --#region Coordinate Settings - -- function: Clamps a number to the range [0,1]. - local function clampTo01(value) - if (value < 0) then - return 0 - elseif (value > 1) then - return 1 - end - return value - end - -- function: Clamps the input field for originX and originY to the range [0,1]. - local function clampOriginField(fieldId, fallback) - local parsed = tonumber(optionsDialog.data[fieldId]) - if (parsed == nil) then - parsed = fallback - end - optionsDialog:modify({ id = fieldId, text = string.format("%.3f", clampTo01(parsed)) }) - end - optionsDialog:label({ id = "coordinateSettings", label = "Coordinate Settings", text = "Set which position is used as the Spine origin (0,0). Range: [0,1]." }) - -- number: Coordinate origin X and Y (0-1). + -- radio: Option to choose between normalized coordinates (0-1) or pixel-based coordinates + optionsDialog:radio({ + id = "originModeNormalized", + label = "Origin Mode", + text = ORIGIN_MODE.NORMALIZED, + selected = cachedOptions.originMode == ORIGIN_MODE.NORMALIZED, + onclick = function() + setOriginMode(optionsDialog, ORIGIN_MODE.NORMALIZED) + applyOriginMode(optionsDialog) + end + }) + optionsDialog:radio({ + id = "originModePixel", + text = ORIGIN_MODE.PIXEL, + selected = cachedOptions.originMode == ORIGIN_MODE.PIXEL, + onclick = function() + setOriginMode(optionsDialog, ORIGIN_MODE.PIXEL) + applyOriginMode(optionsDialog) + end + }) + setOriginMode(optionsDialog, cachedOptions.originMode) + -- number + slider: Coordinate origin X and Y. optionsDialog:number({ id = "originX", label = "Origin (X,Y)", text = string.format("%.3f", cachedOptions.originX), decimals = 3, onchange = function() - clampOriginField("originX", 0.5) + clampOriginXyFieldValue(optionsDialog) end }) :number({ @@ -362,38 +367,53 @@ function showExportOptionsDialog() text = string.format("%.3f", cachedOptions.originY), decimals = 3, onchange = function() - clampOriginField("originY", 0) + clampOriginXyFieldValue(optionsDialog) + end + }) + :slider({ + id = "originXSlider", + min = 0, + max = ORIGIN_SLIDER_STEPS, + value = 0, + onchange = function() + applyOriginSliderChange(optionsDialog, "x") + end + }) + :slider({ + id = "originYSlider", + min = 0, + max = ORIGIN_SLIDER_STEPS, + value = 0, + onchange = function() + applyOriginSliderChange(optionsDialog, "y") end }) + applyOriginMode(optionsDialog) -- button: Presets for common origin settings (center, bottom-center, bottom-left, top-left) - local function setOriginPreset(x, y) - optionsDialog:modify({ id = "originX", text = string.format("%.3f", x) }) - optionsDialog:modify({ id = "originY", text = string.format("%.3f", y) }) - end optionsDialog:newrow() optionsDialog:button({ text = "Center", onclick = function() - setOriginPreset(0.5, 0.5) + setOriginPreset(optionsDialog, "center") end }) optionsDialog:button({ text = "Bottom-Center", onclick = function() - setOriginPreset(0.5, 0) + setOriginPreset(optionsDialog, "bottom-center") end }) optionsDialog:button({ text = "Bottom-Left", onclick = function() - setOriginPreset(0, 0) + setOriginPreset(optionsDialog, "bottom-left") end }) optionsDialog:button({ text = "Top-Left", onclick = function() - setOriginPreset(0, 1) + setOriginPreset(optionsDialog, "top-left") end }) optionsDialog:newrow() @@ -406,7 +426,7 @@ function showExportOptionsDialog() selected = cachedOptions.roundCoordinatesToInteger }) optionsDialog:separator({}) - --#endregion +--#endregion --#region Output Path Settings -- entry: Output json path @@ -433,12 +453,12 @@ function showExportOptionsDialog() --#endregion --#region Other Settings - -- check: Option to ignore group visibility when determining layer visibilityStates + -- check: Option to skip exporting hidden layers (including layers under hidden groups) optionsDialog:check({ - id = "ignoreGroupVisibility", - label = "Ignore Group Visibility", - text = "Use layer visibility only.", - selected = cachedOptions.ignoreGroupVisibility + id = "ignoreHiddenLayers", + label = "Ignore Hidden Layers", + text = "Hidden layers and layers under hidden groups will not be output.", + selected = cachedOptions.ignoreHiddenLayers }) -- check: Option to clear old images in the output images directory before export @@ -450,7 +470,7 @@ function showExportOptionsDialog() }) optionsDialog:separator({}) --#endregion - + --#region Execution Buttons -- button: Confirm export local confirmed = false @@ -476,17 +496,26 @@ function showExportOptionsDialog() optionsDialog:show({ wait = true}) --#region options Data Extraction - -- Get the selected options from the dialog + -- Get the selected options local options = optionsDialog.data + options.originMode = getOriginMode() -- Fallback to default path when input is empty. if (options.outputPath == nil or options.outputPath == "") then options.outputPath = defaultOutputPath end -- Parse originX and originY as numbers, and fallback to defaults if parsing fails or values are out of range + if (options.originMode ~= ORIGIN_MODE.PIXEL and options.originMode ~= ORIGIN_MODE.NORMALIZED) then + options.originMode = ORIGIN_MODE.NORMALIZED + end local parsedOriginX = tonumber(options.originX) local parsedOriginY = tonumber(options.originY) - options.originX = clampTo01(parsedOriginX or 0.5) - options.originY = clampTo01(parsedOriginY or 0) + if (options.originMode == ORIGIN_MODE.PIXEL) then + options.originX = clampValue(parsedOriginX or (spriteWidth * 0.5), 0, spriteWidth) + options.originY = clampValue(parsedOriginY or 0, 0, spriteHeight) + elseif (options.originMode == ORIGIN_MODE.NORMALIZED) then + options.originX = clampValue(parsedOriginX or 0.5, 0, 1) + options.originY = clampValue(parsedOriginY or 0, 0, 1) + end -- Save the options to cache so they will be remembered next time the dialog is opened. saveCachedOptions(configPath, options) @@ -554,6 +583,348 @@ function showExportCompletedDialog(jsonFileName, failedPaths) completedDialog:show({ wait = true }) end +--#region Coordinates Origin Mode Functions +ORIGIN_MODE = { + NORMALIZED = "Normalized", -- Normalized origin coordinates in the range [0,1], where (0,0) is the bottom-left. + PIXEL = "Pixel", -- Pixel-based origin coordinates, where (0,0) is the bottom-left of the sprite and values are in pixels. +} +CURRENT_ORIGIN_MODE = ORIGIN_MODE.NORMALIZED +ORIGIN_SLIDER_STEPS = 100 +ORIGIN_SLIDER_IS_SYNCING = false + +--[[ +Sets the selected origin mode radio button in the options dialog based on the given mode. +optionsDialog: The export options dialog instance +mode: The origin mode to select (ORIGIN_MODE.PIXEL or ORIGIN_MODE.NORMALIZED) +]] +function setOriginMode(optionsDialog, mode) + local currentMode = CURRENT_ORIGIN_MODE + + if (currentMode ~= mode) then + convertOriginCoordinatesByMode(optionsDialog, mode) + optionsDialog:modify({ id = "originModeNormalized", selected = mode == ORIGIN_MODE.NORMALIZED }) + optionsDialog:modify({ id = "originModePixel", selected = mode == ORIGIN_MODE.PIXEL }) + CURRENT_ORIGIN_MODE = mode + else + optionsDialog:modify({ id = "originModeNormalized", selected = mode == ORIGIN_MODE.NORMALIZED }) + optionsDialog:modify({ id = "originModePixel", selected = mode == ORIGIN_MODE.PIXEL }) + end +end + +--[[ +Gets the currently selected origin mode from the options dialog. +optionsDialog: The export options dialog instance +]] +function getOriginMode() + return CURRENT_ORIGIN_MODE +end + +--[[ +Applies the selected origin mode to the originX and originY fields. +optionsDialog: The export options dialog instance +]] +function applyOriginMode(optionsDialog) + local spriteWidth, spriteHeight = getActiveSpriteSize() + local mode = getOriginMode() + + local currentX = tonumber(optionsDialog.data.originX) + local currentY = tonumber(optionsDialog.data.originY) + if (mode == ORIGIN_MODE.PIXEL) then + if (currentX == nil) then + currentX = spriteWidth * 0.5 + end + if (currentY == nil) then + currentY = 0 + end + else + if (currentX == nil) then + currentX = 0.5 + end + if (currentY == nil) then + currentY = 0 + end + end + + optionsDialog:modify({ id = "originX", text = tostring(currentX) }) + optionsDialog:modify({ id = "originY", text = tostring(currentY) }) + clampOriginXyFieldValue(optionsDialog) + + -- Update the coordinate settings label to show the valid input range for the selected origin mode + if (mode == ORIGIN_MODE.PIXEL) then + optionsDialog:modify({ + id = "coordinateSettings", + text = string.format("Set Spine origin(0,0) in pixels. Range X:[0,%.0f], Y:[0,%.0f]", spriteWidth, spriteHeight) + }) + elseif (mode == ORIGIN_MODE.NORMALIZED) then + optionsDialog:modify({ + id = "coordinateSettings", + text = "Set Spine origin(0,0) in normalized coordinates. Range: [0,1]" + }) + end +end + +--[[ +Converts current origin coordinates in the options dialog between normalized and pixel modes. +optionsDialog: The export options dialog instance +toMode: The target mode +]] +function convertOriginCoordinatesByMode(optionsDialog, toMode) + local spriteWidth, spriteHeight = getActiveSpriteSize() + if (spriteWidth == nil or spriteWidth <= 0) then + spriteWidth = 1 + end + if (spriteHeight == nil or spriteHeight <= 0) then + spriteHeight = 1 + end + + local originX = tonumber(optionsDialog.data.originX) + local originY = tonumber(optionsDialog.data.originY) + + if (originX == nil) then + if (toMode == ORIGIN_MODE.PIXEL) then + originX = 0.5 + else + originX = spriteWidth * 0.5 + end + end + if (originY == nil) then + originY = 0 + end + + local convertedX + local convertedY + if (toMode == ORIGIN_MODE.PIXEL) then + convertedX = originX * spriteWidth + convertedY = originY * spriteHeight + else + convertedX = originX / spriteWidth + convertedY = originY / spriteHeight + end + + optionsDialog:modify({ id = "originX", text = tostring(convertedX) }) + optionsDialog:modify({ id = "originY", text = tostring(convertedY) }) +end + +--[[ +Clamps the originX and originY fields in the options dialog to valid ranges based on the selected origin mode. +optionsDialog: The export options dialog instance +]] +function clampOriginXyFieldValue(optionsDialog) + -- Determine the valid range for originX and originY based on the selected origin mode + local spriteWidth, spriteHeight = getActiveSpriteSize() + local mode = getOriginMode() + local maxX = mode == ORIGIN_MODE.PIXEL and spriteWidth or 1 + local maxY = mode == ORIGIN_MODE.PIXEL and spriteHeight or 1 + + -- Parse the current values of originX and originY, and fallback to defaults if parsing fails + local parsedX = tonumber(optionsDialog.data.originX) + local parsedY = tonumber(optionsDialog.data.originY) + if (parsedX == nil) then + parsedX = mode == ORIGIN_MODE.PIXEL and (spriteWidth * 0.5) or 0.5 + end + if (parsedY == nil) then + parsedY = 0 + end + + local clampedX = clampValue(parsedX, 0, maxX) + local clampedY = clampValue(parsedY, 0, maxY) + optionsDialog:modify({ id = "originX", text = string.format("%.3f", clampedX) }) + optionsDialog:modify({ id = "originY", text = string.format("%.3f", clampedY) }) + syncOriginSlidersFromFields(optionsDialog) +end + +--[[ +Sets the originX and originY fields in the options dialog to preset values based on common Spine origin settings. +optionsDialog: The export options dialog instance +presetName: The name of the preset to apply ("center", "bottom-center", "top-left", "bottom-left") +]] +function setOriginPreset(optionsDialog, presetName) + local spriteWidth, spriteHeight = getActiveSpriteSize() + local mode = getOriginMode() + + local x = 0 + local y = 0 + if (presetName == "center") then + if (mode == ORIGIN_MODE.PIXEL) then + x = spriteWidth * 0.5 + y = spriteHeight * 0.5 + else + x = 0.5 + y = 0.5 + end + elseif (presetName == "bottom-center") then + if (mode == ORIGIN_MODE.PIXEL) then + x = spriteWidth * 0.5 + y = 0 + else + x = 0.5 + y = 0 + end + elseif (presetName == "top-left") then + if (mode == ORIGIN_MODE.PIXEL) then + x = 0 + y = spriteHeight + else + x = 0 + y = 1 + end + elseif (presetName == "bottom-left") then + if (mode == ORIGIN_MODE.PIXEL) then + x = 0 + y = 0 + else + x = 0 + y = 0 + end + end + + optionsDialog:modify({ id = "originX", text = string.format("%.3f", x) }) + optionsDialog:modify({ id = "originY", text = string.format("%.3f", y) }) + clampOriginXyFieldValue(optionsDialog) +end + +--[[ +Converts origin coordinates to normalized values based on the selected origin mode. +mode: The origin mode (ORIGIN_MODE.PIXEL or ORIGIN_MODE.NORMALIZED) +originX: The X coordinate of the origin +originY: The Y coordinate of the origin +]] +function getNormalizeOriginCoordinates(mode, originX, originY) + -- If the mode is PIXEL, convert the pixel-based originX and originY to normalized coordinates. + if (mode == ORIGIN_MODE.PIXEL) then + local spriteWidth, spriteHeight = getActiveSpriteSize() + if (spriteWidth == nil or spriteWidth <= 0) then + spriteWidth = 1 + end + if (spriteHeight == nil or spriteHeight <= 0) then + spriteHeight = 1 + end + + local normalizedX = clampValue((originX or 0) / spriteWidth, 0, 1) + local normalizedY = clampValue((originY or 0) / spriteHeight, 0, 1) + return normalizedX, normalizedY + end + + return clampValue(originX or 0.5, 0, 1), clampValue(originY or 0, 0, 1) +end + +--[[ +Clamps a value between a minimum and maximum range. +value: The value to clamp +minValue: The minimum allowed value +maxValue: The maximum allowed value +]] +function clampValue(value, minValue, maxValue) + if (value < minValue) then + return minValue + elseif (value > maxValue) then + return maxValue + end + return value +end + +-- Returns the width and height of the active sprite, or 0 if no active sprite is found. +function getActiveSpriteSize() + local activeSprite = app.activeSprite + local spriteWidth = 0 + local spriteHeight = 0 + if (activeSprite ~= nil and activeSprite.bounds ~= nil) then + spriteWidth = activeSprite.bounds.width + spriteHeight = activeSprite.bounds.height + end + return spriteWidth, spriteHeight +end + +--#region Origin Coordinate Sliders Functions + +-- Converts a coordinate value into slider step value based on current mode range. +function toOriginSliderValue(value, maxValue) + if (maxValue == nil or maxValue <= 0) then + maxValue = 1 + end + local normalized = clampValue((value or 0) / maxValue, 0, 1) + return math.floor(normalized * ORIGIN_SLIDER_STEPS + 0.5) +end + +-- Converts a slider step value back into coordinate value based on current mode range. +function fromOriginSliderValue(sliderValue, maxValue) + if (maxValue == nil or maxValue <= 0) then + maxValue = 1 + end + local step = clampValue(sliderValue or 0, 0, ORIGIN_SLIDER_STEPS) + local normalized = step / ORIGIN_SLIDER_STEPS + return normalized * maxValue +end + +-- Syncs the slider positions from current originX/originY input values. +function syncOriginSlidersFromFields(optionsDialog) + if (ORIGIN_SLIDER_IS_SYNCING) then + return + end + + local spriteWidth, spriteHeight = getActiveSpriteSize() + local mode = getOriginMode() + local maxX = mode == ORIGIN_MODE.PIXEL and spriteWidth or 1 + local maxY = mode == ORIGIN_MODE.PIXEL and spriteHeight or 1 + + if (maxX <= 0) then + maxX = 1 + end + if (maxY <= 0) then + maxY = 1 + end + + local x = tonumber(optionsDialog.data.originX) + local y = tonumber(optionsDialog.data.originY) + if (x == nil) then + x = mode == ORIGIN_MODE.PIXEL and (spriteWidth * 0.5) or 0.5 + end + if (y == nil) then + y = 0 + end + + ORIGIN_SLIDER_IS_SYNCING = true + optionsDialog:modify({ id = "originXSlider", value = toOriginSliderValue(x, maxX) }) + optionsDialog:modify({ id = "originYSlider", value = toOriginSliderValue(y, maxY) }) + ORIGIN_SLIDER_IS_SYNCING = false +end + +-- Applies slider movement back to originX/originY inputs. +function applyOriginSliderChange(optionsDialog, axis) + if (ORIGIN_SLIDER_IS_SYNCING) then + return + end + + local spriteWidth, spriteHeight = getActiveSpriteSize() + local mode = getOriginMode() + local maxX = mode == ORIGIN_MODE.PIXEL and spriteWidth or 1 + local maxY = mode == ORIGIN_MODE.PIXEL and spriteHeight or 1 + + if (maxX <= 0) then + maxX = 1 + end + if (maxY <= 0) then + maxY = 1 + end + + local sliderX = tonumber(optionsDialog.data.originXSlider) or 0 + local sliderY = tonumber(optionsDialog.data.originYSlider) or 0 + + ORIGIN_SLIDER_IS_SYNCING = true + if (axis == "x") then + local x = fromOriginSliderValue(sliderX, maxX) + optionsDialog:modify({ id = "originX", text = string.format("%.3f", x) }) + elseif (axis == "y") then + local y = fromOriginSliderValue(sliderY, maxY) + optionsDialog:modify({ id = "originY", text = string.format("%.3f", y) }) + end + ORIGIN_SLIDER_IS_SYNCING = false + + clampOriginXyFieldValue(optionsDialog) +end +--#endregion +--#endregion + --#region Config Caching Functions --[[ @@ -578,9 +949,10 @@ function loadCachedOptions(defaultOutputPath) local cached = { originX = 0.5, originY = 0, + originMode = ORIGIN_MODE.NORMALIZED, roundCoordinatesToInteger = false, outputPath = defaultOutputPath, - ignoreGroupVisibility = false, + ignoreHiddenLayers = true, clearOldImages = false } -- Create a config directory under the user's Aseprite config path, and define the config file path @@ -603,8 +975,11 @@ function loadCachedOptions(defaultOutputPath) cached.originX = tonumber(raw.originX) or cached.originX cached.originY = tonumber(raw.originY) or cached.originY + if (raw.originMode == ORIGIN_MODE.PIXEL or raw.originMode == ORIGIN_MODE.NORMALIZED) then + cached.originMode = raw.originMode + end cached.roundCoordinatesToInteger = parseBool(raw.roundCoordinatesToInteger, cached.roundCoordinatesToInteger) - cached.ignoreGroupVisibility = parseBool(raw.ignoreGroupVisibility, cached.ignoreGroupVisibility) + cached.ignoreHiddenLayers = parseBool(raw.ignoreHiddenLayers, cached.ignoreHiddenLayers) cached.clearOldImages = parseBool(raw.clearOldImages, cached.clearOldImages) if (raw.outputPath ~= nil and raw.outputPath ~= "") then cached.outputPath = raw.outputPath @@ -626,9 +1001,10 @@ function saveCachedOptions(configPath, options) configFile:write("originX=" .. string.format("%.3f", options.originX) .. "\n") configFile:write("originY=" .. string.format("%.3f", options.originY) .. "\n") + configFile:write("originMode=" .. (options.originMode or ORIGIN_MODE.NORMALIZED) .. "\n") configFile:write("roundCoordinatesToInteger=" .. tostring(options.roundCoordinatesToInteger == true) .. "\n") configFile:write("outputPath=" .. (options.outputPath or "") .. "\n") - configFile:write("ignoreGroupVisibility=" .. tostring(options.ignoreGroupVisibility == true) .. "\n") + configFile:write("ignoreHiddenLayers=" .. tostring(options.ignoreHiddenLayers == true) .. "\n") configFile:write("clearOldImages=" .. tostring(options.clearOldImages == true) .. "\n") configFile:close() end @@ -655,7 +1031,7 @@ end local flattenedLayers = {} -- This will be the flattened view of the sprite layers, ignoring groups local effectiveVisibilities = {} -- This will be the effective visibility of each layer (true / false) -flattenWithEffectiveVisibility(activeSprite, flattenedLayers, effectiveVisibilities, true, options.ignoreGroupVisibility) +flattenWithEffectiveVisibility(activeSprite, flattenedLayers, effectiveVisibilities, true, options.ignoreHiddenLayers) if (containsDuplicates(flattenedLayers, effectiveVisibilities)) then return @@ -664,6 +1040,12 @@ end -- Get an array containing each layer index and whether it is currently visible local visibilities = captureVisibilityStates(flattenedLayers) +-- Calculate the normalized origin coordinates (range 0-1) based on the user's selected origin mode and input values +local normalizedOriginX, normalizedOriginY = getNormalizeOriginCoordinates( + options.originMode, + options.originX, + options.originY +) -- Saves each sprite layer as a separate .png under the 'images' subdirectory captureLayers( flattenedLayers, @@ -671,8 +1053,8 @@ captureLayers( effectiveVisibilities, options.outputPath, options.clearOldImages, - options.originX, - options.originY, + normalizedOriginX, + normalizedOriginY, options.roundCoordinatesToInteger ) From b3d8f61b6bba24a52f73c4500eafc2a03b300a89 Mon Sep 17 00:00:00 2001 From: Ale Date: Thu, 19 Mar 2026 16:14:41 +0900 Subject: [PATCH 12/17] [Aseprite] Add Image Settings for scale and padding control - Added Image Scale option to adjust the resolution of exported images. - Added Image Padding setting to define pixel padding around image borders. --- aseprite/Prepare-For-Spine.lua | 296 +++++++++++++++++++++++++++------ 1 file changed, 243 insertions(+), 53 deletions(-) diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index 4357054..7b4c409 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -117,6 +117,8 @@ outputPath: the output json file path clearOldImages: if true, clear existing images folder before export originX, originY: the user-defined origin point for the exported Spine skeleton, as a percentage of the sprite's width and height (range 0-1) roundCoordinatesToInteger: if true, rounds the attachment coordinates to the nearest integer instead of keeping decimals (not recommended for pixel art) +imageScalePercent: the scale percentage to apply to exported image resolution +imagePaddingPx: the padding to apply around each captured image, in pixels ]] function captureLayers( layers, @@ -126,7 +128,9 @@ function captureLayers( clearOldImages, originX, originY, - roundCoordinatesToInteger) + roundCoordinatesToInteger, + imageScalePercent, + imagePaddingPx) -- Default output path to the sprite-name json in the sprite's directory. if (outputPath == nil or outputPath == "") then local defaultOutputDir = app.fs.filePath(sprite.filename) @@ -186,6 +190,7 @@ function captureLayers( local slotsJson = {} local skinsJson = {} local index = 1 + local scaleFactor = imageScalePercent / 100 -- convert from percentage to a multiplier (e.g. 100% -> 1, 50% -> 0.5, 200% -> 2) for i, layer in ipairs(layers) do -- Ignore groups and non-visible layers if (not layer.isGroup and effectiveVisibilities[i] == true) then @@ -196,7 +201,18 @@ function captureLayers( local savedOk = false savedOk = pcall(function() local cropped = Sprite(sprite) - cropped:crop(cel.position.x, cel.position.y, cel.bounds.width, cel.bounds.height) + local cropX = cel.position.x - imagePaddingPx + local cropY = cel.position.y - imagePaddingPx + local cropWidth = cel.bounds.width + imagePaddingPx * 2 + local cropHeight = cel.bounds.height + imagePaddingPx * 2 + cropped:crop(cropX, cropY, cropWidth, cropHeight) + + local scaledWidth = math.max(1, math.floor(cropWidth * scaleFactor + 0.5)) + local scaledHeight = math.max(1, math.floor(cropHeight * scaleFactor + 0.5)) + if (scaledWidth ~= cropWidth or scaledHeight ~= cropHeight) then + cropped:resize(scaledWidth, scaledHeight) + end + cropped:saveCopyAs(imagePath) cropped:close() end) @@ -208,6 +224,8 @@ function captureLayers( -- Calculate the attachment position based on the cel position, cel bounds, sprite bounds, and the user-defined originX and originY. local attachmentX = cel.bounds.width / 2 + cel.position.x - sprite.bounds.width * originX local attachmentY = sprite.bounds.height * (1 - originY) - cel.position.y - cel.bounds.height / 2 + attachmentX = attachmentX * scaleFactor + attachmentY = attachmentY * scaleFactor slotsJson[index] = string.format([[ { "name": "%s", "bone": "%s", "attachment": "%s" } ]], name, "root", name) -- If roundCoordinatesToInteger is true, round the attachmentX and attachmentY to the nearest integer using math.modf. Otherwise, keep the decimal values with 3 decimal places. if (roundCoordinatesToInteger == true) then @@ -314,6 +332,10 @@ function showExportOptionsDialog() setOriginMode(optionsDialog, ORIGIN_MODE.NORMALIZED) optionsDialog:modify({ id = "originX", text = string.format("%.3f", 0.5) }) optionsDialog:modify({ id = "originY", text = string.format("%.3f", 0) }) + optionsDialog:modify({ id = "imageScalePercent", text = string.format("%.3f", 100) }) + optionsDialog:modify({ id = "imageScaleSlider", value = IMAGE_SCALE_SLIDER_MAX / 10 }) + optionsDialog:modify({ id = "imagePaddingPx", text = string.format("%.0f", 1) }) + optionsDialog:modify({ id = "imagePaddingSlider", value = 1 }) optionsDialog:modify({ id = "roundCoordinatesToInteger", selected = false }) optionsDialog:modify({ id = "outputPath", text = defaultOutputPath }) optionsDialog:modify({ id = "ignoreHiddenLayers", selected = true }) @@ -325,7 +347,6 @@ function showExportOptionsDialog() --#endregion --#region Coordinate Settings - optionsDialog:label({ id = "coordinateSettings", label = "Coordinate Settings", @@ -376,7 +397,7 @@ function showExportOptionsDialog() max = ORIGIN_SLIDER_STEPS, value = 0, onchange = function() - applyOriginSliderChange(optionsDialog, "x") + syncOriginSlidersToFields(optionsDialog, "x") end }) :slider({ @@ -385,7 +406,7 @@ function showExportOptionsDialog() max = ORIGIN_SLIDER_STEPS, value = 0, onchange = function() - applyOriginSliderChange(optionsDialog, "y") + syncOriginSlidersToFields(optionsDialog, "y") end }) applyOriginMode(optionsDialog) @@ -425,30 +446,58 @@ function showExportOptionsDialog() text = "Drop decimal pixels, May misalign pixels; not recommended for pixel art.", selected = cachedOptions.roundCoordinatesToInteger }) + optionsDialog:separator({}) ---#endregion + --#endregion - --#region Output Path Settings - -- entry: Output json path - optionsDialog:entry({ - id = "outputPath", - label = "Output Path", - text = cachedOptions.outputPath + --#region Image Settings + optionsDialog:label({ + id = "imageSettings", + label = "Image Settings", + text = "Configure output image transform settings." }) - -- file: File picker to select output json path (syncs with entry) - optionsDialog:file({ - id = "outputPathPicker", - title = "Select Output Path", - filename = cachedOptions.outputPath, - text = "Select Output Path", - save = true, + + -- number + slider: Image scale as a percentage, where 100% means the same size as the original sprite. + optionsDialog:number({ + id = "imageScalePercent", + label = "Scale (%)", + text = string.format("%.3f", cachedOptions.imageScalePercent), + decimals = 3, onchange = function() - local selectedPath = optionsDialog.data.outputPathPicker - if (selectedPath ~= nil and selectedPath ~= "") then - optionsDialog:modify({ id = "outputPath", text = selectedPath }) - end + clampImageScaleFieldValue(optionsDialog) + end + }) + :slider({ + id = "imageScaleSlider", + min = 0, + max = IMAGE_SCALE_SLIDER_MAX, + onchange = function() + syncImageScaleSliderToField(optionsDialog) end }) + syncImageScaleSliderFromField(optionsDialog) + optionsDialog:newrow() + + -- number + slider: Image padding in pixels. + optionsDialog:number({ + id = "imagePaddingPx", + label = "Padding (px)", + text = string.format("%.0f", cachedOptions.imagePaddingPx), + decimals = 0, + onchange = function() + clampImagePaddingFieldValue(optionsDialog) + end + }) + :slider({ + id = "imagePaddingSlider", + min = 0, + max = IMAGE_PADDING_SLIDER_MAX, + onchange = function() + syncImagePaddingSliderToField(optionsDialog) + end + }) + syncImagePaddingSliderFromField(optionsDialog) + optionsDialog:separator({}) --#endregion @@ -465,12 +514,36 @@ function showExportOptionsDialog() optionsDialog:check({ id = "clearOldImages", label = "Clear Old Images", - text = "Delete existing images first.", + text = "Delete existing images first, including leftovers from removed layers.", selected = cachedOptions.clearOldImages }) optionsDialog:separator({}) --#endregion + --#region Output Path Settings + -- entry: Output json path + optionsDialog:entry({ + id = "outputPath", + label = "Output Path", + text = cachedOptions.outputPath + }) + -- file: File picker to select output json path (syncs with entry) + optionsDialog:file({ + id = "outputPathPicker", + title = "Select Output Path", + filename = cachedOptions.outputPath, + text = "Select Output Path", + save = true, + onchange = function() + local selectedPath = optionsDialog.data.outputPathPicker + if (selectedPath ~= nil and selectedPath ~= "") then + optionsDialog:modify({ id = "outputPath", text = selectedPath }) + end + end + }) + optionsDialog:separator({}) + --#endregion + --#region Execution Buttons -- button: Confirm export local confirmed = false @@ -503,7 +576,7 @@ function showExportOptionsDialog() if (options.outputPath == nil or options.outputPath == "") then options.outputPath = defaultOutputPath end - -- Parse originX and originY as numbers, and fallback to defaults if parsing fails or values are out of range + -- Parse originX and originY as numbers, and fallback to defaults if parsing fails or values are out of range. if (options.originMode ~= ORIGIN_MODE.PIXEL and options.originMode ~= ORIGIN_MODE.NORMALIZED) then options.originMode = ORIGIN_MODE.NORMALIZED end @@ -516,6 +589,12 @@ function showExportOptionsDialog() options.originX = clampValue(parsedOriginX or 0.5, 0, 1) options.originY = clampValue(parsedOriginY or 0, 0, 1) end + -- Parse imageScalePercent as a number, and fallback to default if parsing fails or value is out of range. + local parsedImageScalePercent = tonumber(options.imageScalePercent) + options.imageScalePercent = clampValue(parsedImageScalePercent or 100, 0, IMAGE_SCALE_VALUE_MAX) + -- Parse imagePaddingPx as a number, and fallback to default if parsing fails or value is out of range. + local parsedImagePaddingPx = tonumber(options.imagePaddingPx) + options.imagePaddingPx = clampValue(math.floor((parsedImagePaddingPx or 1) + 0.5), 0, IMAGE_PADDING_INPUT_MAX) -- Save the options to cache so they will be remembered next time the dialog is opened. saveCachedOptions(configPath, options) @@ -531,8 +610,8 @@ end --[[ Shows export completion dialog with action to open the exported file location. -jsonFileName: The exported json file path -failedPaths: The list of file/directory paths that failed to write +jsonFileName: The exported json file path. +failedPaths: The list of file/directory paths that failed to write. ]] function showExportCompletedDialog(jsonFileName, failedPaths) local completedDialog = Dialog({ title = "Export Completed" }) @@ -553,7 +632,7 @@ function showExportCompletedDialog(jsonFileName, failedPaths) completedDialog:label({ text = "Failed to write:" }) - -- List each failed path + -- List each failed path. for _, path in ipairs(failedPaths) do completedDialog:newrow() completedDialog:label({ @@ -563,7 +642,7 @@ function showExportCompletedDialog(jsonFileName, failedPaths) end completedDialog:newrow() - -- Button to open the file location in the OS file explorer + -- Button to open the file location in the OS file explorer. completedDialog:button({ text = "Open File Folder", onclick = function() @@ -571,7 +650,7 @@ function showExportCompletedDialog(jsonFileName, failedPaths) completedDialog:close() end }) - -- Button to close the dialog + -- Button to close the dialog. completedDialog:button({ text = "OK", focus = true, @@ -583,7 +662,7 @@ function showExportCompletedDialog(jsonFileName, failedPaths) completedDialog:show({ wait = true }) end ---#region Coordinates Origin Mode Functions +--#region Coordinates Settings Functions ORIGIN_MODE = { NORMALIZED = "Normalized", -- Normalized origin coordinates in the range [0,1], where (0,0) is the bottom-left. PIXEL = "Pixel", -- Pixel-based origin coordinates, where (0,0) is the bottom-left of the sprite and values are in pixels. @@ -730,6 +809,8 @@ function clampOriginXyFieldValue(optionsDialog) local clampedY = clampValue(parsedY, 0, maxY) optionsDialog:modify({ id = "originX", text = string.format("%.3f", clampedX) }) optionsDialog:modify({ id = "originY", text = string.format("%.3f", clampedY) }) + + -- After clamping the field values, also update the slider positions to match the clamped values. syncOriginSlidersFromFields(optionsDialog) end @@ -837,25 +918,6 @@ end --#region Origin Coordinate Sliders Functions --- Converts a coordinate value into slider step value based on current mode range. -function toOriginSliderValue(value, maxValue) - if (maxValue == nil or maxValue <= 0) then - maxValue = 1 - end - local normalized = clampValue((value or 0) / maxValue, 0, 1) - return math.floor(normalized * ORIGIN_SLIDER_STEPS + 0.5) -end - --- Converts a slider step value back into coordinate value based on current mode range. -function fromOriginSliderValue(sliderValue, maxValue) - if (maxValue == nil or maxValue <= 0) then - maxValue = 1 - end - local step = clampValue(sliderValue or 0, 0, ORIGIN_SLIDER_STEPS) - local normalized = step / ORIGIN_SLIDER_STEPS - return normalized * maxValue -end - -- Syncs the slider positions from current originX/originY input values. function syncOriginSlidersFromFields(optionsDialog) if (ORIGIN_SLIDER_IS_SYNCING) then @@ -889,8 +951,8 @@ function syncOriginSlidersFromFields(optionsDialog) ORIGIN_SLIDER_IS_SYNCING = false end --- Applies slider movement back to originX/originY inputs. -function applyOriginSliderChange(optionsDialog, axis) +-- Syncs the originX and originY input fields from the current slider positions. +function syncOriginSlidersToFields(optionsDialog, axis) if (ORIGIN_SLIDER_IS_SYNCING) then return end @@ -922,6 +984,126 @@ function applyOriginSliderChange(optionsDialog, axis) clampOriginXyFieldValue(optionsDialog) end + +-- Converts a coordinate value into slider step value based on current mode range. +function toOriginSliderValue(value, maxValue) + if (maxValue == nil or maxValue <= 0) then + maxValue = 1 + end + local normalized = clampValue((value or 0) / maxValue, 0, 1) + return math.floor(normalized * ORIGIN_SLIDER_STEPS + 0.5) +end + +-- Converts a slider step value back into coordinate value based on current mode range. +function fromOriginSliderValue(sliderValue, maxValue) + if (maxValue == nil or maxValue <= 0) then + maxValue = 1 + end + local step = clampValue(sliderValue or 0, 0, ORIGIN_SLIDER_STEPS) + local normalized = step / ORIGIN_SLIDER_STEPS + return normalized * maxValue +end +--#endregion +--#endregion + +--#region Image Settings Functions +IMAGE_SCALE_SLIDER_MAX = 1000 +IMAGE_SCALE_SLIDER_IS_SYNCING = false +IMAGE_SCALE_VALUE_MAX = 10000 +IMAGE_PADDING_SLIDER_MAX = 4 +IMAGE_PADDING_IS_SYNCING = false +IMAGE_PADDING_INPUT_MAX = 100 + +--#region Image Scale Slider Functions + +-- Clamps the image scale input to a minimum of 0 and updates the slider state. +function clampImageScaleFieldValue(optionsDialog) + local parsedScale = tonumber(optionsDialog.data.imageScalePercent) + if (parsedScale == nil) then + parsedScale = 100 + end + local clampedScale = clampValue(parsedScale, 0, IMAGE_SCALE_VALUE_MAX) + optionsDialog:modify({ id = "imageScalePercent", text = string.format("%.3f", clampedScale) }) + syncImageScaleSliderFromField(optionsDialog) +end + +-- Syncs slider value from the scale input field; input above slider max keeps slider at max. +function syncImageScaleSliderFromField(optionsDialog) + if (IMAGE_SCALE_SLIDER_IS_SYNCING) then + return + end + + local parsedScale = tonumber(optionsDialog.data.imageScalePercent) + if (parsedScale == nil) then + parsedScale = 100 + end + local clampedScale = clampValue(parsedScale, 0, IMAGE_SCALE_VALUE_MAX) + local sliderValue = clampValue(math.floor(clampedScale + 0.5), 0, IMAGE_SCALE_SLIDER_MAX) + + IMAGE_SCALE_SLIDER_IS_SYNCING = true + optionsDialog:modify({ id = "imageScaleSlider", value = sliderValue }) + IMAGE_SCALE_SLIDER_IS_SYNCING = false +end + +-- Syncs the scale input field from the slider value. +function syncImageScaleSliderToField(optionsDialog) + if (IMAGE_SCALE_SLIDER_IS_SYNCING) then + return + end + + local sliderValue = tonumber(optionsDialog.data.imageScaleSlider) or 0 + sliderValue = clampValue(sliderValue, 0, IMAGE_SCALE_SLIDER_MAX) + + IMAGE_SCALE_SLIDER_IS_SYNCING = true + optionsDialog:modify({ id = "imageScalePercent", text = string.format("%.3f", sliderValue) }) + IMAGE_SCALE_SLIDER_IS_SYNCING = false +end +--#endregion + +--#region Image Padding Slider Functions + +-- Clamps the image padding input to [0,100] and updates the slider state. +function clampImagePaddingFieldValue(optionsDialog) + local parsedPadding = tonumber(optionsDialog.data.imagePaddingPx) + if (parsedPadding == nil) then + parsedPadding = 0 + end + local clampedPadding = clampValue(math.floor(parsedPadding + 0.5), 0, IMAGE_PADDING_INPUT_MAX) + optionsDialog:modify({ id = "imagePaddingPx", text = string.format("%.0f", clampedPadding) }) + syncImagePaddingSliderFromField(optionsDialog) +end + +-- Syncs padding slider value from the input field; input above slider max keeps slider at max. +function syncImagePaddingSliderFromField(optionsDialog) + if (IMAGE_PADDING_IS_SYNCING) then + return + end + + local parsedPadding = tonumber(optionsDialog.data.imagePaddingPx) + if (parsedPadding == nil) then + parsedPadding = 0 + end + local clampedPadding = clampValue(math.floor(parsedPadding + 0.5), 0, IMAGE_PADDING_INPUT_MAX) + local sliderValue = clampValue(clampedPadding, 0, IMAGE_PADDING_SLIDER_MAX) + + IMAGE_PADDING_IS_SYNCING = true + optionsDialog:modify({ id = "imagePaddingSlider", value = sliderValue }) + IMAGE_PADDING_IS_SYNCING = false +end + +-- Syncs the padding input field from the slider value. +function syncImagePaddingSliderToField(optionsDialog) + if (IMAGE_PADDING_IS_SYNCING) then + return + end + + local sliderValue = tonumber(optionsDialog.data.imagePaddingSlider) or 0 + sliderValue = clampValue(math.floor(sliderValue + 0.5), 0, IMAGE_PADDING_SLIDER_MAX) + + IMAGE_PADDING_IS_SYNCING = true + optionsDialog:modify({ id = "imagePaddingPx", text = string.format("%.0f", sliderValue) }) + IMAGE_PADDING_IS_SYNCING = false +end --#endregion --#endregion @@ -949,6 +1131,8 @@ function loadCachedOptions(defaultOutputPath) local cached = { originX = 0.5, originY = 0, + imageScalePercent = 100, + imagePaddingPx = 1, originMode = ORIGIN_MODE.NORMALIZED, roundCoordinatesToInteger = false, outputPath = defaultOutputPath, @@ -975,6 +1159,8 @@ function loadCachedOptions(defaultOutputPath) cached.originX = tonumber(raw.originX) or cached.originX cached.originY = tonumber(raw.originY) or cached.originY + cached.imageScalePercent = clampValue(tonumber(raw.imageScalePercent) or cached.imageScalePercent, 0, IMAGE_SCALE_VALUE_MAX) + cached.imagePaddingPx = clampValue(math.floor((tonumber(raw.imagePaddingPx) or cached.imagePaddingPx) + 0.5), 0, IMAGE_PADDING_INPUT_MAX) if (raw.originMode == ORIGIN_MODE.PIXEL or raw.originMode == ORIGIN_MODE.NORMALIZED) then cached.originMode = raw.originMode end @@ -1001,6 +1187,8 @@ function saveCachedOptions(configPath, options) configFile:write("originX=" .. string.format("%.3f", options.originX) .. "\n") configFile:write("originY=" .. string.format("%.3f", options.originY) .. "\n") + configFile:write("imageScalePercent=" .. string.format("%.3f", options.imageScalePercent or 100) .. "\n") + configFile:write("imagePaddingPx=" .. string.format("%.0f", options.imagePaddingPx or 0) .. "\n") configFile:write("originMode=" .. (options.originMode or ORIGIN_MODE.NORMALIZED) .. "\n") configFile:write("roundCoordinatesToInteger=" .. tostring(options.roundCoordinatesToInteger == true) .. "\n") configFile:write("outputPath=" .. (options.outputPath or "") .. "\n") @@ -1055,7 +1243,9 @@ captureLayers( options.clearOldImages, normalizedOriginX, normalizedOriginY, - options.roundCoordinatesToInteger + options.roundCoordinatesToInteger, + options.imageScalePercent, + options.imagePaddingPx ) -- Restore the layer's visibilities to how they were before From ae36f536843b3625239c47deffff6bbef037d802 Mon Sep 17 00:00:00 2001 From: Ale Date: Thu, 19 Mar 2026 19:15:10 +0900 Subject: [PATCH 13/17] [Aseprite] Support [origin] layer, add Spine logo, and refine UI layout - Added support for [origin] tag: The plugin now automatically uses the center of any layer named [origin] as the export origin. - Added Spine Logo to the dialog header for better branding/recognition. - Refined UI Layout: Optimized spacing and alignment of all control panels for a cleaner look. --- aseprite/Images/Prepare-For-Spine-Logo.png | Bin 0 -> 485 bytes aseprite/Prepare-For-Spine.lua | 279 +++++++++++++++++++-- 2 files changed, 258 insertions(+), 21 deletions(-) create mode 100644 aseprite/Images/Prepare-For-Spine-Logo.png diff --git a/aseprite/Images/Prepare-For-Spine-Logo.png b/aseprite/Images/Prepare-For-Spine-Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f76a0ed59ce9f76b73785b1b145853dd52da1bc7 GIT binary patch literal 485 zcmVPx$pGibPRA_tes{gAAQ1j0 zkV?HnJVN-vWgX`}F?x8tU39@@BmuqaqN(}B=pR=baaX(*Z+pMu_I;ScrM5^gAsiHq z!lID32-Q7dui(j5#%28~3)9Hn8>Ug!t`<11BW#wAtF<}naa2cKc}=^$P?Ux$HO8L3 z+&ytwvvs7$!JDD#5+iUs*d{J(_9;SfN;V4Yd9aKdH#b*Yhhf(^+)ZN^vqP6l>~nR8y;eFp z-%Flnb(0Y7s#joDu*5Ci$63Uz$Kg%Fng^PAb)LiWI@gZ}3+Ts#74(yQC4WxxAzeTk b{NIsZ(Vy~+kQ4s$00000NkvXXu0mjf8yMV) literal 0 HcmV?d00001 diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index 7b4c409..01b5fdb 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -43,7 +43,7 @@ function containsDuplicates(layers, visibilities) local duplicateNames = {} -- List of layer duplicates names -- Iterate through the layers and count the occurrences of each name among visible layers for i, layer in ipairs(layers) do - if (not layer.isGroup and visibilities[i] == true) then + if (not layer.isGroup and visibilities[i] == true and not isMarkerLayer(layer)) then local name = layer.name local count = (nameCounts[name] or 0) + 1 nameCounts[name] = count @@ -193,7 +193,7 @@ function captureLayers( local scaleFactor = imageScalePercent / 100 -- convert from percentage to a multiplier (e.g. 100% -> 1, 50% -> 0.5, 200% -> 2) for i, layer in ipairs(layers) do -- Ignore groups and non-visible layers - if (not layer.isGroup and effectiveVisibilities[i] == true) then + if (not layer.isGroup and effectiveVisibilities[i] == true and not isMarkerLayer(layer)) then -- Set the layer to visible so we can capture it, then set it back to hidden after layer.isVisible = true local cel = layer.cels[1] @@ -306,6 +306,96 @@ function openFileLocation(filePath) end end +--#region Layer Marker Functions + +--[[ +Finds the first non-group layer whose name contains [] marker text by recursive layer order. +parent: The sprite or parent layer group +markerName: Marker text name to match inside [] (case-insensitive) +]] +function findFirstMarkerLayer(parent, markerName) + for _, layer in ipairs(parent.layers) do + if (isMarkerLayer(layer)) then + if (markerName == nil or markerName == "" or hasMarkerName(layer.name, markerName)) then + return layer + end + end + if (layer.isGroup) then + local found = findFirstMarkerLayer(layer, markerName) + if (found ~= nil) then + return found + end + end + end + return nil +end + +--[[ +Returns true when a layer is a non-group marker layer. +layer: the layer to check +]] +function isMarkerLayer(layer) + if (layer == nil or layer.isGroup) then + return false + end + + if (layer.name == nil) then + return false + end + + return string.find(layer.name, "%b[]") ~= nil +end + +--[[ +Returns true when layerName contains the exact marker text inside [] (case-insensitive). +layerName: the layer name to check for the marker text +markerName: the marker text to match inside [] (case-insensitive) +]] +function hasMarkerName(layerName, markerName) + if (layerName == nil or markerName == nil or markerName == "") then + return false + end + + local escapedMarker = string.gsub(string.lower(markerName), "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + local markerPattern = "%[" .. escapedMarker .. "%]" + return string.find(string.lower(layerName), markerPattern) ~= nil +end + +--[[ +Gets marker-center coordinates as origin values in the requested mode. +sprite: the sprite to search for marker layers +mode: the origin mode to return values in (ORIGIN_MODE.PIXEL or ORIGIN_MODE.NORMALIZED) +]] +function getOriginFromMarkerLayer(sprite, mode) + if (sprite == nil) then + return nil, nil + end + -- Find the first non-group layer whose name contains [origin] by recursive layer order. + local markerLayer = findFirstMarkerLayer(sprite, "origin") + if (markerLayer == nil or markerLayer.cels == nil or #markerLayer.cels == 0) then + return nil, nil + end + -- Calculate the center of the cel bounds as the marker position, and convert to the requested mode. + local cel = markerLayer.cels[1] + if (cel == nil or cel.bounds == nil or cel.position == nil) then + return nil, nil + end + local spriteWidth, spriteHeight = getActiveSpriteSize() + local centerX = cel.bounds.x + cel.bounds.width * 0.5 + local centerYFromTop = cel.bounds.y + cel.bounds.height * 0.5 + local pixelYFromBottom = spriteHeight - centerYFromTop + + -- Clamp the returned values to valid ranges for the selected mode, in case the marker layer is placed outside the sprite bounds. + if (mode == ORIGIN_MODE.PIXEL) then + return clampValue(centerX, 0, spriteWidth), clampValue(pixelYFromBottom, 0, spriteHeight) + elseif (mode == ORIGIN_MODE.NORMALIZED) then + return clampValue(centerX / spriteWidth, 0, 1), clampValue(pixelYFromBottom / spriteHeight, 0, 1) + end + + return nil, nil +end +--#endregion + -----------------------------------------------[[ UI Functions ]]----------------------------------------------- --[[ @@ -323,6 +413,9 @@ function showExportOptionsDialog() local defaultOutputPath = spriteOutputDir .. app.fs.pathSeparator .. spriteOutputName .. ".json" local cachedOptions, configPath = loadCachedOptions(defaultOutputPath) CURRENT_ORIGIN_MODE = cachedOptions.originMode + + -- Draw the Spine logo at the top. + DrawSpineLogo(optionsDialog) --#region Other Buttons -- button: Resets all options to their default values @@ -340,6 +433,8 @@ function showExportOptionsDialog() optionsDialog:modify({ id = "outputPath", text = defaultOutputPath }) optionsDialog:modify({ id = "ignoreHiddenLayers", selected = true }) optionsDialog:modify({ id = "clearOldImages", selected = false }) + optionsDialog:repaint() + app:refresh() end }) @@ -352,6 +447,16 @@ function showExportOptionsDialog() label = "Coordinate Settings", text = "Set which position is used as the Spine origin (0,0). Range: [0,1]." }) + optionsDialog:newrow() + -- Get origin coordinates from the first [origin] marker layer if present. + local markerOriginX, markerOriginY = getOriginFromMarkerLayer(activeSprite, getOriginMode()) + local markerOriginApplied = markerOriginX ~= nil and markerOriginY ~= nil + -- label: Shows whether the origin was set from the [origin] marker layer. + optionsDialog:label({ + id = "originMarkerStatus", + text = markerOriginApplied and "✅ Origin set from [origin] marker layer." or "⚪ Origin not set from [origin] marker layer." + }) + -- radio: Option to choose between normalized coordinates (0-1) or pixel-based coordinates optionsDialog:radio({ id = "originModeNormalized", @@ -372,7 +477,9 @@ function showExportOptionsDialog() applyOriginMode(optionsDialog) end }) + -- Set the initial state of the origin mode radio buttons based on cached options. setOriginMode(optionsDialog, cachedOptions.originMode) + -- number + slider: Coordinate origin X and Y. optionsDialog:number({ id = "originX", @@ -448,6 +555,13 @@ function showExportOptionsDialog() }) optionsDialog:separator({}) + + -- Override origin values from the first [origin] marker layer if present. + if (markerOriginApplied) then + optionsDialog:modify({ id = "originX", text = string.format("%.3f", markerOriginX) }) + optionsDialog:modify({ id = "originY", text = string.format("%.3f", markerOriginY) }) + clampOriginXyFieldValue(optionsDialog) + end --#endregion --#region Image Settings @@ -501,26 +615,12 @@ function showExportOptionsDialog() optionsDialog:separator({}) --#endregion - --#region Other Settings - -- check: Option to skip exporting hidden layers (including layers under hidden groups) - optionsDialog:check({ - id = "ignoreHiddenLayers", - label = "Ignore Hidden Layers", - text = "Hidden layers and layers under hidden groups will not be output.", - selected = cachedOptions.ignoreHiddenLayers - }) - - -- check: Option to clear old images in the output images directory before export - optionsDialog:check({ - id = "clearOldImages", - label = "Clear Old Images", - text = "Delete existing images first, including leftovers from removed layers.", - selected = cachedOptions.clearOldImages + --#region Output Settings + optionsDialog:label({ + id = "outputSettings", + label = "Output Settings", + text = "Configure the export JSON and image paths, and set output behavior." }) - optionsDialog:separator({}) - --#endregion - - --#region Output Path Settings -- entry: Output json path optionsDialog:entry({ id = "outputPath", @@ -541,6 +641,23 @@ function showExportOptionsDialog() end end }) + + -- check: Option to skip exporting hidden layers (including layers under hidden groups) + optionsDialog:check({ + id = "ignoreHiddenLayers", + label = "Ignore Hidden Layers", + text = "Hidden layers and layers under hidden groups will not be output.", + selected = cachedOptions.ignoreHiddenLayers + }) + + -- check: Option to clear old images in the output images directory before export + optionsDialog:check({ + id = "clearOldImages", + label = "Clear Old Images", + text = "Delete existing images first, including leftovers from removed layers.", + selected = cachedOptions.clearOldImages + }) + optionsDialog:separator({}) --#endregion @@ -565,8 +682,24 @@ function showExportOptionsDialog() }) --#endregion + --#region Show MainUI + -- Delay one frame before refreshing so the logo canvas gets painted reliably. + local firstFrameRefreshTimer + firstFrameRefreshTimer = Timer({ + interval = 1 / 60, + ontick = function() + if (firstFrameRefreshTimer ~= nil) then + firstFrameRefreshTimer:stop() + end + optionsDialog:repaint() + app:refresh() + end + }) + firstFrameRefreshTimer:start() + -- Show the dialog and wait for user input. optionsDialog:show({ wait = true}) + --#endregion --#region options Data Extraction -- Get the selected options @@ -608,6 +741,8 @@ function showExportOptionsDialog() return options end +--#region Info Dialog Functions + --[[ Shows export completion dialog with action to open the exported file location. jsonFileName: The exported json file path. @@ -661,6 +796,7 @@ function showExportCompletedDialog(jsonFileName, failedPaths) completedDialog:show({ wait = true }) end +--#endregion --#region Coordinates Settings Functions ORIGIN_MODE = { @@ -1198,6 +1334,107 @@ function saveCachedOptions(configPath, options) end --#endregion +--#region Other Functions + +-- Draws a consistent pixel-grid Spine logo on the options dialog. +function DrawSpineLogo(optionsDialog) + if (optionsDialog == nil) then + return + end + + -- load the logo image from cache. + local cacheDir = app.fs.joinPath(app.fs.filePath(app.fs.userConfigPath), "Cache") + app.fs.makeDirectory(cacheDir) + local logoPath = app.fs.joinPath(cacheDir, "Prepare-For-Spine-Logo.png") + local loadedImage = nil + local cacheFile = io.open(logoPath, "rb") + local hasCachedLogo = cacheFile ~= nil + if hasCachedLogo then + cacheFile:close() + local loadedOk, imageOrError = pcall(function() + return Image({ fromFile = logoPath }) + end) + if (loadedOk == true and imageOrError ~= nil) then + loadedImage = imageOrError + end + end + + -- if loading from cache failed, attempt to download the logo image and save it to cache, then load it. + if (loadedImage == nil) then + local logoUrl = "https://github.com/EsotericSoftware/spine-scripts/blob/master/aseprite/Images/Spine-Logo.png" + local downloadOk = false + if (app.fs.pathSeparator == "\\") then + local cmd = 'powershell -NoProfile -Command "try { Invoke-WebRequest -Uri \"' .. logoUrl .. '\" -OutFile \"' .. logoPath .. '\" -UseBasicParsing; exit 0 } catch { exit 1 }"' + local result = os.execute(cmd) + downloadOk = result == true or result == 0 + else + local cmd = 'curl -L -o "' .. logoPath .. '" "' .. logoUrl .. '"' + local result = os.execute(cmd) + downloadOk = result == true or result == 0 + end + + if (downloadOk == true) then + local loadedOk, imageOrError = pcall(function() + return Image({ fromFile = logoPath }) + end) + if (loadedOk == true and imageOrError ~= nil) then + loadedImage = imageOrError + end + end + end + + -- Draw the logo image on a canvas in the options dialog, or show an error message if loading failed. + if (loadedImage == nil) then + optionsDialog:label({ + id = "spineLogoStatus", + text = "Logo render failed (download/cache load)." + }) + optionsDialog:newrow() + return + else + local displayScale = 2 + local displayImage = loadedImage + -- Build a 2x nearest-neighbor display image for clearer pixel-art rendering in the dialog. + local scaledOk, scaledOrError = pcall(function() + local scaled = Image(loadedImage.width * displayScale, loadedImage.height * displayScale, loadedImage.colorMode) + for y = 0, loadedImage.height - 1 do + for x = 0, loadedImage.width - 1 do + local px = loadedImage:getPixel(x, y) + local sx = x * displayScale + local sy = y * displayScale + scaled:putPixel(sx, sy, px) + scaled:putPixel(sx + 1, sy, px) + scaled:putPixel(sx, sy + 1, px) + scaled:putPixel(sx + 1, sy + 1, px) + end + end + return scaled + end) + if (scaledOk == true and scaledOrError ~= nil) then + displayImage = scaledOrError + end + + local minCanvasWidth = 360 + local canvasWidth = math.max(displayImage.width, minCanvasWidth) + local canvasHeight = displayImage.height + local drawX = math.floor((canvasWidth - displayImage.width) * 0.5) + local drawY = 0 + optionsDialog:canvas({ + id = "spineLogoCanvas", + width = canvasWidth, + height = canvasHeight, + onpaint = function(ev) + local gc = ev.context + gc.antialias = false + gc:drawImage(displayImage, drawX, drawY) + end + }) + end + + optionsDialog:separator({}) +end +--#endregion + -----------------------------------------------[[ Main Execution ]]----------------------------------------------- local activeSprite = app.activeSprite From 289592d24c138a6daf6a508ce121bd38f1fa3b74 Mon Sep 17 00:00:00 2001 From: Ale Date: Thu, 19 Mar 2026 21:14:43 +0900 Subject: [PATCH 14/17] [Aseprite] Update README to v1.3 - Update the document content to v1.3 - Code organization and optimization. --- aseprite/Images/image-1.png | Bin 9005 -> 16069 bytes aseprite/Prepare-For-Spine.lua | 131 +++++++++++++-------------------- aseprite/README.md | 77 +++++++++++++------ aseprite/README_cn.md | 77 +++++++++++++------ 4 files changed, 157 insertions(+), 128 deletions(-) diff --git a/aseprite/Images/image-1.png b/aseprite/Images/image-1.png index 6e2e6647735cb253cb0d2264e3d6cdf3bd507034..c57e4c9dec29de118841ed55bc9e5bba6fde11a6 100644 GIT binary patch literal 16069 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 k_6j8MmNp2KDNyT8R@Tkn$|U>RWz>% literal 9005 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 Mv%AxcPfmRCzsF^8A^-pY diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index 01b5fdb..df6b7b9 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -412,7 +412,6 @@ function showExportOptionsDialog() local spriteOutputName = app.fs.fileTitle(activeSprite.filename) local defaultOutputPath = spriteOutputDir .. app.fs.pathSeparator .. spriteOutputName .. ".json" local cachedOptions, configPath = loadCachedOptions(defaultOutputPath) - CURRENT_ORIGIN_MODE = cachedOptions.originMode -- Draw the Spine logo at the top. DrawSpineLogo(optionsDialog) @@ -423,8 +422,7 @@ function showExportOptionsDialog() text = "Reset Config", onclick = function() setOriginMode(optionsDialog, ORIGIN_MODE.NORMALIZED) - optionsDialog:modify({ id = "originX", text = string.format("%.3f", 0.5) }) - optionsDialog:modify({ id = "originY", text = string.format("%.3f", 0) }) + setOriginPreset(optionsDialog, "bottom-center") optionsDialog:modify({ id = "imageScalePercent", text = string.format("%.3f", 100) }) optionsDialog:modify({ id = "imageScaleSlider", value = IMAGE_SCALE_SLIDER_MAX / 10 }) optionsDialog:modify({ id = "imagePaddingPx", text = string.format("%.0f", 1) }) @@ -448,13 +446,9 @@ function showExportOptionsDialog() text = "Set which position is used as the Spine origin (0,0). Range: [0,1]." }) optionsDialog:newrow() - -- Get origin coordinates from the first [origin] marker layer if present. - local markerOriginX, markerOriginY = getOriginFromMarkerLayer(activeSprite, getOriginMode()) - local markerOriginApplied = markerOriginX ~= nil and markerOriginY ~= nil -- label: Shows whether the origin was set from the [origin] marker layer. optionsDialog:label({ - id = "originMarkerStatus", - text = markerOriginApplied and "✅ Origin set from [origin] marker layer." or "⚪ Origin not set from [origin] marker layer." + id = "originMarkerStatus" }) -- radio: Option to choose between normalized coordinates (0-1) or pixel-based coordinates @@ -465,7 +459,6 @@ function showExportOptionsDialog() selected = cachedOptions.originMode == ORIGIN_MODE.NORMALIZED, onclick = function() setOriginMode(optionsDialog, ORIGIN_MODE.NORMALIZED) - applyOriginMode(optionsDialog) end }) optionsDialog:radio({ @@ -474,12 +467,9 @@ function showExportOptionsDialog() selected = cachedOptions.originMode == ORIGIN_MODE.PIXEL, onclick = function() setOriginMode(optionsDialog, ORIGIN_MODE.PIXEL) - applyOriginMode(optionsDialog) end }) - -- Set the initial state of the origin mode radio buttons based on cached options. - setOriginMode(optionsDialog, cachedOptions.originMode) - + -- number + slider: Coordinate origin X and Y. optionsDialog:number({ id = "originX", @@ -504,7 +494,7 @@ function showExportOptionsDialog() max = ORIGIN_SLIDER_STEPS, value = 0, onchange = function() - syncOriginSlidersToFields(optionsDialog, "x") + syncOriginSlidersToFields(optionsDialog) end }) :slider({ @@ -513,10 +503,13 @@ function showExportOptionsDialog() max = ORIGIN_SLIDER_STEPS, value = 0, onchange = function() - syncOriginSlidersToFields(optionsDialog, "y") + syncOriginSlidersToFields(optionsDialog) end }) - applyOriginMode(optionsDialog) + -- Set the initial state of the origin mode radio buttons based on cached options. + setOriginMode(optionsDialog, cachedOptions.originMode) + -- Set the initial state of the origin X and Y fields and sliders based on cached options. + setOriginXyValues(optionsDialog, cachedOptions.originX, cachedOptions.originY) -- button: Presets for common origin settings (center, bottom-center, bottom-left, top-left) optionsDialog:newrow() @@ -549,7 +542,7 @@ function showExportOptionsDialog() -- check: Option to round attachment coordinates to integers instead of keeping decimals optionsDialog:check({ id = "roundCoordinatesToInteger", - label = "Round Coordinates To Integer", + label = "Round To Integer", text = "Drop decimal pixels, May misalign pixels; not recommended for pixel art.", selected = cachedOptions.roundCoordinatesToInteger }) @@ -557,11 +550,16 @@ function showExportOptionsDialog() optionsDialog:separator({}) -- Override origin values from the first [origin] marker layer if present. + local markerOriginX, markerOriginY = getOriginFromMarkerLayer(activeSprite, getOriginMode()) + local markerOriginApplied = markerOriginX ~= nil and markerOriginY ~= nil if (markerOriginApplied) then - optionsDialog:modify({ id = "originX", text = string.format("%.3f", markerOriginX) }) - optionsDialog:modify({ id = "originY", text = string.format("%.3f", markerOriginY) }) - clampOriginXyFieldValue(optionsDialog) + setOriginXyValues(optionsDialog, markerOriginX, markerOriginY) end + -- Set the label to show whether the origin was set from the [origin] marker layer. + optionsDialog:modify({ + id = "originMarkerStatus", + text = markerOriginApplied and "✅ Origin set from [origin] marker layer." or "⚪ Origin not set from [origin] marker layer." + }) --#endregion --#region Image Settings @@ -803,29 +801,10 @@ ORIGIN_MODE = { NORMALIZED = "Normalized", -- Normalized origin coordinates in the range [0,1], where (0,0) is the bottom-left. PIXEL = "Pixel", -- Pixel-based origin coordinates, where (0,0) is the bottom-left of the sprite and values are in pixels. } -CURRENT_ORIGIN_MODE = ORIGIN_MODE.NORMALIZED +CURRENT_ORIGIN_MODE = nil ORIGIN_SLIDER_STEPS = 100 ORIGIN_SLIDER_IS_SYNCING = false ---[[ -Sets the selected origin mode radio button in the options dialog based on the given mode. -optionsDialog: The export options dialog instance -mode: The origin mode to select (ORIGIN_MODE.PIXEL or ORIGIN_MODE.NORMALIZED) -]] -function setOriginMode(optionsDialog, mode) - local currentMode = CURRENT_ORIGIN_MODE - - if (currentMode ~= mode) then - convertOriginCoordinatesByMode(optionsDialog, mode) - optionsDialog:modify({ id = "originModeNormalized", selected = mode == ORIGIN_MODE.NORMALIZED }) - optionsDialog:modify({ id = "originModePixel", selected = mode == ORIGIN_MODE.PIXEL }) - CURRENT_ORIGIN_MODE = mode - else - optionsDialog:modify({ id = "originModeNormalized", selected = mode == ORIGIN_MODE.NORMALIZED }) - optionsDialog:modify({ id = "originModePixel", selected = mode == ORIGIN_MODE.PIXEL }) - end -end - --[[ Gets the currently selected origin mode from the options dialog. optionsDialog: The export options dialog instance @@ -835,37 +814,26 @@ function getOriginMode() end --[[ -Applies the selected origin mode to the originX and originY fields. +Sets the selected origin mode radio button in the options dialog based on the given mode. optionsDialog: The export options dialog instance +mode: The origin mode to select (ORIGIN_MODE.PIXEL or ORIGIN_MODE.NORMALIZED) ]] -function applyOriginMode(optionsDialog) - local spriteWidth, spriteHeight = getActiveSpriteSize() - local mode = getOriginMode() - - local currentX = tonumber(optionsDialog.data.originX) - local currentY = tonumber(optionsDialog.data.originY) - if (mode == ORIGIN_MODE.PIXEL) then - if (currentX == nil) then - currentX = spriteWidth * 0.5 - end - if (currentY == nil) then - currentY = 0 - end - else - if (currentX == nil) then - currentX = 0.5 - end - if (currentY == nil) then - currentY = 0 - end +function setOriginMode(optionsDialog, mode) + -- If the mode is the same as the current mode, no need to update. + if (CURRENT_ORIGIN_MODE == mode) then + return end + CURRENT_ORIGIN_MODE = mode - optionsDialog:modify({ id = "originX", text = tostring(currentX) }) - optionsDialog:modify({ id = "originY", text = tostring(currentY) }) - clampOriginXyFieldValue(optionsDialog) + -- Update the selected state of the origin mode radio buttons based on the given mode. + optionsDialog:modify({ id = "originModeNormalized", selected = mode == ORIGIN_MODE.NORMALIZED }) + optionsDialog:modify({ id = "originModePixel", selected = mode == ORIGIN_MODE.PIXEL }) + -- When the origin mode changes, convert the current originX and originY values to the new mode. + convertOriginCoordinatesByMode(optionsDialog, mode) - -- Update the coordinate settings label to show the valid input range for the selected origin mode + -- Update the coordinate settings label to show the valid input range for the selected origin mode. if (mode == ORIGIN_MODE.PIXEL) then + local spriteWidth, spriteHeight = getActiveSpriteSize() optionsDialog:modify({ id = "coordinateSettings", text = string.format("Set Spine origin(0,0) in pixels. Range X:[0,%.0f], Y:[0,%.0f]", spriteWidth, spriteHeight) @@ -878,6 +846,18 @@ function applyOriginMode(optionsDialog) end end +--[[ +Set originX and originY field values. +optionsDialog: The export options dialog instance +x: The preset origin X value to set +y: The preset origin Y value to set +]] +function setOriginXyValues(optionsDialog, x, y) + optionsDialog:modify({ id = "originX", text = string.format(x) }) + optionsDialog:modify({ id = "originY", text = string.format(y) }) + clampOriginXyFieldValue(optionsDialog) +end + --[[ Converts current origin coordinates in the options dialog between normalized and pixel modes. optionsDialog: The export options dialog instance @@ -916,8 +896,7 @@ function convertOriginCoordinatesByMode(optionsDialog, toMode) convertedY = originY / spriteHeight end - optionsDialog:modify({ id = "originX", text = tostring(convertedX) }) - optionsDialog:modify({ id = "originY", text = tostring(convertedY) }) + setOriginXyValues(optionsDialog, convertedX, convertedY) end --[[ @@ -995,9 +974,7 @@ function setOriginPreset(optionsDialog, presetName) end end - optionsDialog:modify({ id = "originX", text = string.format("%.3f", x) }) - optionsDialog:modify({ id = "originY", text = string.format("%.3f", y) }) - clampOriginXyFieldValue(optionsDialog) + setOriginXyValues(optionsDialog, x, y) end --[[ @@ -1088,7 +1065,7 @@ function syncOriginSlidersFromFields(optionsDialog) end -- Syncs the originX and originY input fields from the current slider positions. -function syncOriginSlidersToFields(optionsDialog, axis) +function syncOriginSlidersToFields(optionsDialog) if (ORIGIN_SLIDER_IS_SYNCING) then return end @@ -1109,16 +1086,10 @@ function syncOriginSlidersToFields(optionsDialog, axis) local sliderY = tonumber(optionsDialog.data.originYSlider) or 0 ORIGIN_SLIDER_IS_SYNCING = true - if (axis == "x") then - local x = fromOriginSliderValue(sliderX, maxX) - optionsDialog:modify({ id = "originX", text = string.format("%.3f", x) }) - elseif (axis == "y") then - local y = fromOriginSliderValue(sliderY, maxY) - optionsDialog:modify({ id = "originY", text = string.format("%.3f", y) }) - end + local x = fromOriginSliderValue(sliderX, maxX) + local y = fromOriginSliderValue(sliderY, maxY) + setOriginXyValues(optionsDialog, x, y) ORIGIN_SLIDER_IS_SYNCING = false - - clampOriginXyFieldValue(optionsDialog) end -- Converts a coordinate value into slider step value based on current mode range. diff --git a/aseprite/README.md b/aseprite/README.md index a2f70d2..fcd5997 100644 --- a/aseprite/README.md +++ b/aseprite/README.md @@ -10,7 +10,7 @@ ___ ## Lua Script for importing Aseprite projects into Spine -## v1.2 +## v1.3 ### Installation @@ -35,23 +35,42 @@ After following these steps, the "Prepare-For-Spine" script should show up in th * Reset Config Button: Resets all options to their default values. * This will also clear any cached settings, so the next time you open the options dialog it will be restored to the default values. -* Origin (X/Y): Sets the coordinate origin for the exported images. - * This coordinate origin will align with the coordinate origin in Spine, affecting the default position of the images when imported into Spine. - * The origin coordinates are normalized to the range [0,1], where (0,0) represents the bottom-left corner of the image and (1,1) represents the top-right corner. - * There are also quick preset buttons for common origin configurations (Center, Bottom-Center, Bottom-Left, Top-Left) that will automatically set the X and Y values accordingly. -* Round Coordinates to Integer: When enabled, the script will round all coordinate values to the nearest integer, dropping any decimal part. - * This may cause pixel misalignment. For example, if the origin is set to center and the image has odd pixel dimensions, the true center lies at the center of the middle pixel rather than on an edge. Forcing integer coordinates can therefore introduce a half-pixel offset. - * Pixel art usually requires perfect pixel alignment, so this option is not recommended unless you have a specific need. -* Output Path: Allows you to specify a custom output path for the exported JSON file. - * By default, it will be saved in the same directory as your Aseprite project file. - * You can type a path directly into the text field, or click the button below to open a file picker dialog. After selecting a location, the path is filled into the text field automatically. -* Ignore Group Visibility: When enabled, the script will ignore the visibility of groups during export. - * This only considers each layer's own visibility and ignores the visibility of its parent group. That means a layer can still be exported even if its group is hidden, as long as the layer itself is visible. -* Clear Old Images: When enabled, the script will automatically delete any previously exported images in the output directory before exporting new ones. - * This helps to prevent confusion and clutter from old files that are no longer relevant to the current export. -* Export Button: Starts the export process with the configured options. - * After export completes, click the [Open File Folder] button to open the directory containing the exported files. -* Cancel Button: Closes the options dialog without exporting. + +* Coordinate Settings: Configure the coordinate origin used for exported images in Spine. + * Origin Mode: Sets how the origin is interpreted, with two modes: Normalized and Pixel. + * Normalized mode: Origin (X/Y) values are normalized to the [0,1] range. + * Pixel mode: Origin (X/Y) values represent exact pixel coordinates. + * [origin] tag import: If a layer name contains [origin], that layer is used as an automatic source for origin coordinates. + * The center point of that layer is converted to Origin (X, Y) in the export settings. + * Import success/failure is shown with an icon and status text. + * Origin (X/Y): Sets the coordinate origin for the exported images. + * This coordinate origin will align with the coordinate origin in Spine, affecting the default position of the images when imported into Spine. + * (0,0) represents the bottom-left corner of the image, and (1,1) or (image width, image height) represents the top-right corner. + * Sliders below the input fields let you quickly adjust X and Y for more intuitive origin placement. + * There are also quick preset buttons for common origin configurations (Center, Bottom-Center, Bottom-Left, Top-Left) that will automatically set the X and Y values accordingly. + * Round to Integer: When enabled, the script will round all coordinate values to the nearest integer, dropping any decimal part. + * This may cause pixel misalignment. For example, if the origin is set to center and the image has odd pixel dimensions, the true center lies at the center of the middle pixel rather than on an edge. Forcing integer coordinates can therefore introduce a half-pixel offset. + * Pixel art usually requires perfect pixel alignment, so this option is not recommended unless you have a specific need. + +* Image Settings: Control export image scale and padding. + * Scale(%): Adjusts exported image resolution as a percentage. The default is 100%, which means no scaling. + * Pixel art often appears too small on screen after export; increasing the scale can improve display size. + * Padding(px): Defines transparent pixel padding around image edges. The default is 1, meaning 1 pixel of edge padding. + * This can avoid aliasing artifacts for opaque pixels along the image edge. + +* Output Settings: Configure output paths for JSON and images, plus export behavior options. + * Output Path: Allows you to specify a custom output path for the exported JSON file. + * By default, it will be saved in the same directory as your Aseprite project file. + * You can type a path directly into the text field, or click the button below to open a file picker dialog. After selecting a location, the path is filled into the text field automatically. + * Ignore Hidden Layers: When enabled, the script ignores layer visibility during export. + * Layers are still exported even if the layer itself or its parent group is hidden. + * Clear Old Images: When enabled, the script will automatically delete any previously exported images in the output directory before exporting new ones. + * This helps to prevent confusion and clutter from old files that are no longer relevant to the current export. + +* Action Buttons: Start export using the current configuration. + * Export Button: Starts the export process with the configured options. + * After export completes, click the [Open File Folder] button to open the directory containing the exported files. + * Cancel Button: Closes the options dialog without exporting. #### 「Spine Import」 @@ -80,18 +99,28 @@ After following these steps, the "Prepare-For-Spine" script should show up in th ### Known Issues -#### v1.2 - * Opening the exported file location currently relies on `os` library APIs and may cause a brief UI stall (a few seconds). * Deleting old `images` files also relies on `os` library APIs and may cause a brief UI stall. * New layers added in Aseprite may have incorrect draw order when imported into an existing Spine skeleton, and need to be adjusted manually in Spine. -#### v1.1 +### Version History -* Hiding a group of layers will not exclude it from the export. Each layer needs to be shown or hidden individually (group visibility is ignored) -* Not as many options as the Photshop script. Maybe I'll add these in the future but honestly i've never used any of them so we will see. +#### v1.3 -### Version History +* Add coordinate modes and refine layer visibility options + * Added Normalized [0,1] and Pixel modes for origin coordinates. + * Added Sliders for Origin (X, Y) to allow more intuitive control. + * Added "Ignore Hidden Layers" toggle for more flexible exports. + * Removed redundant "Use layer visibility only" option. + +* Add Image Settings for scale and padding control + * Added Image Scale option to adjust the resolution of exported images. + * Added Image Padding setting to define pixel padding around image borders. + +* Support [origin] layer, add Spine logo, and refine UI layout + * Layers with [origin] in their name will be automatically configured as the origin coordinates in the export settings. + * Added Spine Logo to the dialog header for better branding/recognition. + * Refined UI Layout, Optimized spacing and alignment of all control panels for a cleaner look. #### v1.2 diff --git a/aseprite/README_cn.md b/aseprite/README_cn.md index 751b4b7..49e1814 100644 --- a/aseprite/README_cn.md +++ b/aseprite/README_cn.md @@ -10,7 +10,7 @@ ___ ## 用于将 Aseprite 项目导入 Spine 的 Lua 脚本 -## v1.2 +## v1.3 ### 安装 @@ -35,23 +35,42 @@ ___ * Reset Config 按钮:将所有选项 重置为 默认值。 * 同时会 清除缓存设置,因此下次 打开选项弹窗时会 恢复默认配置。 -* Origin (X/Y):设置导出图像在 Spine 中使用的 坐标原点。 - * 这个 坐标原点 会与 Spine中的坐标原点 对齐,影响导入后图片在Spine中的 默认位置。 - * 原点坐标 被规范化到 [0,1] 区间,其中 (0,0) 表示图像 左下角,(1,1) 表示图像 右上角。 - * 提供了 常用原点的 预设按钮(Center、Bottom-Center、Bottom-Left、Top-Left),点击后会 自动设置对应的 X、Y 值。 -* Round Coordinates to Integer:启用后,脚本会将所有 坐标值取整,丢弃小数部分。 - * 这可能导致 像素不对齐。例如,将原点设为中心 且 图片像素尺寸为奇数时,几何中心会落在 中间像素中心 而不是边界上,强制整数坐标 可能带来 半像素偏移。 - * 像素风格 通常需要严格的 像素对齐,除非有特殊需求,否则不建议开启该选项。 -* Output Path:允许你为导出的 JSON 文件 指定自定义输出路径。 - * 默认会保存到 Aseprite 项目文件所在目录。 - * 你可以直接在 文本框中输入路径,或者点击 下方按钮 打开文件选择对话框。选择后,路径会 自动填入文本框。 -* Ignore Group Visibility:启用后,导出时将 忽略组可见性。 - * 仅根据 图层自身可见性判断,不考虑其 父组是否可见。这意味着即使 组被隐藏,只要图层本身可见,仍会被导出。 -* Clear Old Images:启用后,导出前会 自动删除 输出目录中旧的图片。 - * 这可以减少 旧文件残留 造成的 混淆和目录杂乱。 -* Export 按钮:使用当前配置 开始导出。 - * 导出完成后,可点击 [Open File Folder] 按钮直接 打开导出目录。 -* Cancel 按钮:关闭选项弹窗并 取消导出。 + +* Coordinate Settings:坐标配置。设置导出图像在 Spine 中的坐标原点。 + * [origin]标签导入:如果图层名称中包含 [origin],该图层会被用作 原点坐标 的自动配置来源。 + * 该图层的 中心点坐标 会被转换为 导出设置中的 Origin (X, Y)。 + * 是否成功导入,会通过图标与文本进行提示。 + * Origin Mode:设置坐标原点的模式,支持 Normalized(归一化)和 Pixel(像素)两种模式。 + * Normalized 模式:Origin (X/Y) 的值被 规范化到 [0,1] 区间。 + * Pixel 模式:Origin (X/Y) 的值表示具体的 像素坐标。 + * Origin (X/Y):设置导出图像在 Spine 中使用的 坐标原点。 + * 这个 坐标原点 会与 Spine中的坐标原点 对齐,影响导入后图片在Spine中的 默认位置。 + * (0,0) 表示图像的左下角,(1,1) 或 (图像宽度, 图像高度) 表示图像的右上角。 + * 输入框底部的 滑块,可以快速调整 X 和 Y 的值,更直观地设置 坐标原点位置。 + * 提供了 常用原点的 预设按钮(Center、Bottom-Center、Bottom-Left、Top-Left),点击后会 自动设置对应的 X、Y 值。 + * Round to Integer:启用后,脚本会将所有 坐标值取整,丢弃小数部分。 + * 这可能导致 像素不对齐。例如,将原点设为中心 且 图片像素尺寸为奇数时,几何中心会落在 中间像素中心 而不是边界上,强制整数坐标 可能带来 半像素偏移。 + * 像素风格 通常需要严格的 像素对齐,除非有特殊需求,否则不建议开启该选项。 + +* Image Settings:控制导出图像的 缩放与边距。 + * Scale(%):调整导出 图像分辨率的 缩放比例 的百分比。默认值为 100%,表示不缩放。 + * 像素艺术在导出后,通常会有 在屏幕上显示的尺寸过小 的问题,可以通过增加 缩放比例 来放大显示的尺寸。 + * Padding(px):定义图像边缘的 像素留白。默认值为 1,表示在 图像边缘 留出1像素的空白区域。 + * 对于 不透明像素 沿图像边缘的 锯齿伪影,增加边距 可以起到 缓解作用。 + +* Output Settings:输出配置。Json文件 与 图片 的输出路径 与 各类导出选项。 + * Output Path:允许你为导出的 JSON文件 指定自定义 输出路径。 + * 默认会保存到 Aseprite 项目文件 所在目录。 + * 你可以直接在 文本框中 输入路径,或者点击 下方按钮 打开文件选择对话框。选择后,路径会 自动填入文本框。 + * Ignore Hidden Layers:启用后,脚本会在导出时 忽略图层的可见性。 + * 即使 图层 或其父组被隐藏,仍然会被导出。 + * Clear Old Images:启用后,导出前会 自动删除 输出目录中旧的图片。 + * 这可以减少 旧文件残留 造成的 混淆和目录杂乱。 + +* 执行按钮: 使用当前配置 开始导出。 + * Export 按钮:使用当前配置 开始导出。 + * 导出完成后,可点击 [Open File Folder] 按钮直接 打开导出目录。 + * Cancel 按钮:关闭选项弹窗并 取消导出。 #### 「Spine 导入」 @@ -80,18 +99,28 @@ ___ ### 已知问题 -#### v1.2 - * 打开 导出文件位置,目前依赖 `os` 库 API,可能导致短暂 UI 卡顿(几秒)。 * 删除旧的 `images` 文件,同样依赖 `os` 库 API,也可能导致短暂 UI 卡顿。 * Aseprite 中新增的图层,在导入到 Spine 的现有骨架时,可能会出现 绘制顺序不正确 的问题,需要在 Spine 中手动调整。 -#### v1.1 +### 版本历史 -* 隐藏图层组 不会阻止 组内图层导出。每个图层都需要 单独设置显示/隐藏(组可见性会被忽略)。 -* 选项数量相比 Photoshop 脚本更少。后续可能会补充,但作者目前很少用到这些选项。 +#### v1.3 -### 版本历史 +* 新增 坐标模式 并 优化图层可见性选项 + * 为原点坐标 新增 Normalized 与 Pixel 两种模式。 + * 为 Origin (X, Y) 新增滑杆,便于 更直观地调整。 + * 新增 "Ignore Hidden Layers" 开关,让导出更灵活。 + * 移除冗余的 "Use layer visibility only" 选项。 + +* 新增 Image Settings,用于控制 缩放与边距 + * 新增 Image Scale 选项,用于调整 导出图像分辨率的 缩放比例。 + * 新增 Image Padding 设置,用于定义 图像边缘的 像素留白。 + +* 支持 [origin] 图层、添加 Spine logo,并优化 UI 布局 + * 图层名称 中包含[origin]的图层,会被作为 原点坐标 自动配置到 导出设置。 + * 在对话框头部 新增 Spine Logo,提升识别度。 + * 优化 UI 布局,调整各控制面板的 间距与对齐,使界面更整洁。 #### v1.2 From 12748ee1267ef8790588440328479d4ae995a8e1 Mon Sep 17 00:00:00 2001 From: Ale Date: Thu, 19 Mar 2026 21:41:33 +0900 Subject: [PATCH 15/17] [Aseprite] Update the logo image of Spine --- aseprite/Images/Prepare-For-Spine-Logo.png | Bin 485 -> 0 bytes aseprite/Images/Spine-Logo.png | Bin 0 -> 486 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 aseprite/Images/Prepare-For-Spine-Logo.png create mode 100644 aseprite/Images/Spine-Logo.png diff --git a/aseprite/Images/Prepare-For-Spine-Logo.png b/aseprite/Images/Prepare-For-Spine-Logo.png deleted file mode 100644 index f76a0ed59ce9f76b73785b1b145853dd52da1bc7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 485 zcmVPx$pGibPRA_tes{gAAQ1j0 zkV?HnJVN-vWgX`}F?x8tU39@@BmuqaqN(}B=pR=baaX(*Z+pMu_I;ScrM5^gAsiHq z!lID32-Q7dui(j5#%28~3)9Hn8>Ug!t`<11BW#wAtF<}naa2cKc}=^$P?Ux$HO8L3 z+&ytwvvs7$!JDD#5+iUs*d{J(_9;SfN;V4Yd9aKdH#b*Yhhf(^+)ZN^vqP6l>~nR8y;eFp z-%Flnb(0Y7s#joDu*5Ci$63Uz$Kg%Fng^PAb)LiWI@gZ}3+Ts#74(yQC4WxxAzeTk b{NIsZ(Vy~+kQ4s$00000NkvXXu0mjf8yMV) diff --git a/aseprite/Images/Spine-Logo.png b/aseprite/Images/Spine-Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d16a47cbaf2398d393db389f0be19b4e7c40a872 GIT binary patch literal 486 zcmV@P)Px$ph-kQRA_tes|tg5eR=1 zh*Iwmj}U(FwT^S27(G1SPP$++l7QZI(bRlm^pC5JxGUa@x4mC+`#sFzQd=aL5Dtn) zVNu9igzBEKSMcNtUfJuNK&^BW#xTtF<}naa2Rx^_qHnp(qVis*gQ; zxqITWX3I#AgEvFfB}U+Suufdo>{Eo|lq?k3^WZhE-`rSn8HSzXa6@4lMXnF;DGAYG z4%?xDvbVIZW5VC<38(Qei2-}yML@GClsur4@g>Mmg%dm#0%wp!qQq;Fj%Y>9dK?w+ zHBacj!KNeH3BHD54rFMy@V1iiu)^Vn&-S<^QmgQ+;qW(y(p(DCO0*h>AcdpmGfm?- zw6UQQ-mkbFEzl;xhrM#aa;me^l*FAJx>N=sf%LfCAfR?v6ymHgSshjamH c@P9{s0ghktjIM@ENdN!<07*qoM6N<$g0pzz9{>OV literal 0 HcmV?d00001 From 492392917fac1102c3df4e6294f02af5728eed16 Mon Sep 17 00:00:00 2001 From: Ale Date: Thu, 19 Mar 2026 21:46:10 +0900 Subject: [PATCH 16/17] [Aseprite] Update the UI images in the README --- aseprite/Images/image-1.png | Bin 16069 -> 16081 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/aseprite/Images/image-1.png b/aseprite/Images/image-1.png index c57e4c9dec29de118841ed55bc9e5bba6fde11a6..f95fb470f7ec1ca1d64b47e3b2e545bce6a9637e 100644 GIT binary patch literal 16081 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 literal 16069 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 k_6j8MmNp2KDNyT8R@Tkn$|U>RWz>% From 84b6ad2d3f38e5abba5362d97948cc3ed04d4307 Mon Sep 17 00:00:00 2001 From: Ale Date: Thu, 19 Mar 2026 22:11:32 +0900 Subject: [PATCH 17/17] [Aseprite] Update the content of the README --- aseprite/README.md | 2 +- aseprite/README_cn.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aseprite/README.md b/aseprite/README.md index fcd5997..c5d512b 100644 --- a/aseprite/README.md +++ b/aseprite/README.md @@ -92,7 +92,7 @@ After following these steps, the "Prepare-For-Spine" script should show up in th * Create a new skeleton: If checked, a new skeleton will be created during import. * If you already created an empty new project, you do not need to check this option and can import directly. * Import into an existing skeleton: If checked, imported assets will be added to an existing skeleton. - * Replace existing attachments: If checked, attachments with the same name in the existing skeleton will be replaced during import. + * Replace existing attachments: It is recommended to select this option to ensure that attachments are correctly replaced and coordinates and other related properties are updated. * New layers will generate new attachments and be added to the existing skeleton, but the draw order may be incorrect and needs to be manually adjusted in Spine. * Import button: Start importing with the current configuration. * Cancel button: Close the dialog and cancel the import. diff --git a/aseprite/README_cn.md b/aseprite/README_cn.md index 49e1814..1cd4f13 100644 --- a/aseprite/README_cn.md +++ b/aseprite/README_cn.md @@ -92,7 +92,7 @@ ___ * Create a new skeleton:如果选中,导入时会 创建一个新的骨架。 * 如果已经创建了 空的新项目,则不需要选中该选项,直接导入即可。 * Import into an existing skeleton:如果选中,导入的资源会被添加到 现有骨架中。 - * Replace existing attachments:如果选中,导入时会 替换现有骨架中 同名的附件。 + * Replace existing attachments:建议选中,以确保 附件被正确替换,更新 坐标和其他相关属性。 * 新增的图层 会生成 新的附件 并添加到 现有的骨架中,但是 绘制顺序 可能会出现问题,需要在 Spine 中手动调整。 * Import 按钮:使用当前配置 开始导入。 * Cancel 按钮:关闭对话框并 取消导入。