diff --git a/aseprite/Images/Spine-Logo.png b/aseprite/Images/Spine-Logo.png new file mode 100644 index 0000000..d16a47c Binary files /dev/null and b/aseprite/Images/Spine-Logo.png differ diff --git a/aseprite/Images/image-1.png b/aseprite/Images/image-1.png new file mode 100644 index 0000000..f95fb47 Binary files /dev/null and b/aseprite/Images/image-1.png differ diff --git a/aseprite/Images/image-2.png b/aseprite/Images/image-2.png new file mode 100644 index 0000000..1b7c0ff Binary files /dev/null and b/aseprite/Images/image-2.png differ diff --git a/aseprite/Prepare-For-Spine.lua b/aseprite/Prepare-For-Spine.lua index b796470..df6b7b9 100644 --- a/aseprite/Prepare-For-Spine.lua +++ b/aseprite/Prepare-For-Spine.lua @@ -6,39 +6,79 @@ 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 and computes each layer's export visibility. parent: The sprite or parent layer group -arr: The array to append to +outLayers: The array to append the flattened layers +outVis: The array to append the effective visibility of each layer (true / false) +groupIsVisible: The visibility inherited from parent groups (true / false) +ignoreHiddenLayers: If true, hidden layers and layers under hidden groups are excluded ]] -function getLayers(parent, arr) - for i, layer in ipairs(parent.layers) do - if (layer.isGroup) then - arr[#arr + 1] = layer - arr = getLayers(layer, arr) +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 (ignoreHiddenLayers) then + effectiveVisible = groupIsVisible and layer.isVisible else - arr[#arr + 1] = layer + 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 visibility + if layer.isGroup then + flattenWithEffectiveVisibility(layer, outLayers, outVis, effectiveVisible, ignoreHiddenLayers) end end - return arr 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) + 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 (layer.isVisible) 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 - app.alert("Found multiple visible layers named '" .. layer.name .. "'. Please use unique layer names or hide one of these layers.") - return true - end + 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 + 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 @@ -72,69 +112,152 @@ 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) +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 +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, sprite, visibilityStates) - hideAllLayers(layers) - - local outputDir = app.fs.filePath(sprite.filename) - local spriteFileName = app.fs.fileTitle(sprite.filename) - - local jsonFileName = outputDir .. app.fs.pathSeparator .. spriteFileName .. ".json" - json = io.open(jsonFileName, "w") - - json:write('{') +function captureLayers( + layers, + sprite, + effectiveVisibilities, + outputPath, + clearOldImages, + originX, + originY, + 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) + local defaultSpriteName = app.fs.fileTitle(sprite.filename) + outputPath = defaultOutputDir .. app.fs.pathSeparator .. defaultSpriteName .. ".json" + end + local outputDir = app.fs.filePath(outputPath) + -- 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 + if (clearOldImages == true) then + deleteDirectoryRecursive(imagesDir) + end + app.fs.makeDirectory(imagesDir) - -- skeleton - json:write([[ "skeleton": { "images": "images/" }, ]]) + -- 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 - -- bones - json:write([[ "bones": [ { "name": "root" } ], ]]) + -- 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 + 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 - - local separator = app.fs.pathSeparator - + 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 visibilityStates[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] - 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:close() + local imagePath = imagesDir .. separator .. layer.name .. ".png" + local savedOk = false + savedOk = pcall(function() + local cropped = Sprite(sprite) + 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) + 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. + 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) - 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 -- 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('}') + 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() + -- close the json + json:write("}") + json:close() + end - app.alert("Export completed! Use file '" .. jsonFileName .. "' for importing into Spine.") + -- Show export completion dialog + showExportCompletedDialog(jsonFileName, failedPaths) end --[[ @@ -148,9 +271,1144 @@ 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 + +--[[ +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 + +--#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 ]]----------------------------------------------- +--[[ +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 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) + + -- Draw the Spine logo at the top. + DrawSpineLogo(optionsDialog) + + --#region Other Buttons + -- button: Resets all options to their default values + optionsDialog:button({ + text = "Reset Config", + onclick = function() + setOriginMode(optionsDialog, ORIGIN_MODE.NORMALIZED) + 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) }) + optionsDialog:modify({ id = "imagePaddingSlider", value = 1 }) + optionsDialog:modify({ id = "roundCoordinatesToInteger", selected = false }) + optionsDialog:modify({ id = "outputPath", text = defaultOutputPath }) + optionsDialog:modify({ id = "ignoreHiddenLayers", selected = true }) + optionsDialog:modify({ id = "clearOldImages", selected = false }) + optionsDialog:repaint() + app:refresh() + end + }) + + optionsDialog:separator({}) + --#endregion + + --#region Coordinate Settings + optionsDialog:label({ + id = "coordinateSettings", + label = "Coordinate Settings", + text = "Set which position is used as the Spine origin (0,0). Range: [0,1]." + }) + optionsDialog:newrow() + -- label: Shows whether the origin was set from the [origin] marker layer. + optionsDialog:label({ + id = "originMarkerStatus" + }) + + -- 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) + end + }) + optionsDialog:radio({ + id = "originModePixel", + text = ORIGIN_MODE.PIXEL, + selected = cachedOptions.originMode == ORIGIN_MODE.PIXEL, + onclick = function() + setOriginMode(optionsDialog, ORIGIN_MODE.PIXEL) + end + }) + + -- 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() + clampOriginXyFieldValue(optionsDialog) + end + }) + :number({ + id = "originY", + text = string.format("%.3f", cachedOptions.originY), + decimals = 3, + onchange = function() + clampOriginXyFieldValue(optionsDialog) + end + }) + :slider({ + id = "originXSlider", + min = 0, + max = ORIGIN_SLIDER_STEPS, + value = 0, + onchange = function() + syncOriginSlidersToFields(optionsDialog) + end + }) + :slider({ + id = "originYSlider", + min = 0, + max = ORIGIN_SLIDER_STEPS, + value = 0, + onchange = function() + syncOriginSlidersToFields(optionsDialog) + end + }) + -- 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() + optionsDialog:button({ + text = "Center", + onclick = function() + setOriginPreset(optionsDialog, "center") + end + }) + optionsDialog:button({ + text = "Bottom-Center", + onclick = function() + setOriginPreset(optionsDialog, "bottom-center") + end + }) + optionsDialog:button({ + text = "Bottom-Left", + onclick = function() + setOriginPreset(optionsDialog, "bottom-left") + end + }) + optionsDialog:button({ + text = "Top-Left", + onclick = function() + setOriginPreset(optionsDialog, "top-left") + end + }) + optionsDialog:newrow() + + -- check: Option to round attachment coordinates to integers instead of keeping decimals + optionsDialog:check({ + id = "roundCoordinatesToInteger", + label = "Round To Integer", + text = "Drop decimal pixels, May misalign pixels; not recommended for pixel art.", + selected = cachedOptions.roundCoordinatesToInteger + }) + + 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 + 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 + optionsDialog:label({ + id = "imageSettings", + label = "Image Settings", + text = "Configure output image transform settings." + }) + + -- 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() + 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 + + --#region Output Settings + optionsDialog:label({ + id = "outputSettings", + label = "Output Settings", + text = "Configure the export JSON and image paths, and set output behavior." + }) + -- 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 + }) + + -- 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 + + --#region Execution Buttons + -- 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 + }) + --#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 + 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) + 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 + -- 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) + --#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 + +--#region Info Dialog Functions + +--[[ +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, 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:" + }) + 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() + openFileLocation(jsonFileName) + completedDialog:close() + end + }) + -- Button to close the dialog. + completedDialog:button({ + text = "OK", + focus = true, + onclick = function() + completedDialog:close() + end + }) + + completedDialog:show({ wait = true }) +end +--#endregion + +--#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. +} +CURRENT_ORIGIN_MODE = nil +ORIGIN_SLIDER_STEPS = 100 +ORIGIN_SLIDER_IS_SYNCING = false + +--[[ +Gets the currently selected origin mode from the options dialog. +optionsDialog: The export options dialog instance +]] +function getOriginMode() + return CURRENT_ORIGIN_MODE +end + +--[[ +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) + -- 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 + + -- 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. + 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) + }) + elseif (mode == ORIGIN_MODE.NORMALIZED) then + optionsDialog:modify({ + id = "coordinateSettings", + text = "Set Spine origin(0,0) in normalized coordinates. Range: [0,1]" + }) + 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 +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 + + setOriginXyValues(optionsDialog, convertedX, 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) }) + + -- After clamping the field values, also update the slider positions to match the clamped values. + 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 + + setOriginXyValues(optionsDialog, x, y) +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 + +-- 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 + +-- Syncs the originX and originY input fields from the current slider positions. +function syncOriginSlidersToFields(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 sliderX = tonumber(optionsDialog.data.originXSlider) or 0 + local sliderY = tonumber(optionsDialog.data.originYSlider) or 0 + + ORIGIN_SLIDER_IS_SYNCING = true + local x = fromOriginSliderValue(sliderX, maxX) + local y = fromOriginSliderValue(sliderY, maxY) + setOriginXyValues(optionsDialog, x, y) + ORIGIN_SLIDER_IS_SYNCING = false +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 + +--#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, + imageScalePercent = 100, + imagePaddingPx = 1, + originMode = ORIGIN_MODE.NORMALIZED, + roundCoordinatesToInteger = false, + outputPath = defaultOutputPath, + ignoreHiddenLayers = true, + 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.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 + cached.roundCoordinatesToInteger = parseBool(raw.roundCoordinatesToInteger, cached.roundCoordinatesToInteger) + 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 + 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("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") + configFile:write("ignoreHiddenLayers=" .. tostring(options.ignoreHiddenLayers == true) .. "\n") + configFile:write("clearOldImages=" .. tostring(options.clearOldImages == true) .. "\n") + configFile:close() +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 - 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") @@ -161,18 +1419,42 @@ elseif (activeSprite.filename == "") then return end -local flattenedLayers = getLayers(activeSprite, {}) +-- Show the export options dialog UI and get the user's selected options. +local options = showExportOptionsDialog() +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, options.ignoreHiddenLayers) -if (containsDuplicates(flattenedLayers)) then +if (containsDuplicates(flattenedLayers, effectiveVisibilities)) then return 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 --- and write out the json file for importing into spine. -captureLayers(flattenedLayers, activeSprite, visibilities) +captureLayers( + flattenedLayers, + activeSprite, + effectiveVisibilities, + options.outputPath, + options.clearOldImages, + normalizedOriginX, + normalizedOriginY, + options.roundCoordinatesToInteger, + options.imageScalePercent, + options.imagePaddingPx +) -- Restore the layer's visibilities to how they were before restoreVisibilities(flattenedLayers, visibilities) \ No newline at end of file diff --git a/aseprite/README.md b/aseprite/README.md index 90154d2..c5d512b 100644 --- a/aseprite/README.md +++ b/aseprite/README.md @@ -1,14 +1,16 @@ -### 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 +[中文版 文档](README_cn.md) + +# aseprite-to-spine ## Lua Script for importing Aseprite projects into Spine -## v1.1 +## v1.3 ### Installation @@ -19,26 +21,140 @@ ___ After following these steps, the "Prepare-For-Spine" script should show up in the list. -### Usage +### Usage + +#### 「Aseprite Export」 -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. +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. 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! -### 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. +![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. + +* 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」 + +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! + +![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: 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. + +### Known Issues + +* 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. ### Version History +#### v1.3 + +* 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 + +* 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..1cd4f13 --- /dev/null +++ b/aseprite/README_cn.md @@ -0,0 +1,161 @@ +### 更新 - 本脚本已收录到官方 Spine Scripts 仓库 + + + +___ + +[English README](README.md) + +# aseprite-to-spine + +## 用于将 Aseprite 项目导入 Spine 的 Lua 脚本 + +## v1.3 + +### 安装 + +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 按钮使用默认配置进行导出。 +4. 如果脚本 请求权限,请点击 "give full trust"(脚本仅需要文件写入权限以完成导出)。 + +![alt text](Images/image-1.png) + +* Reset Config 按钮:将所有选项 重置为 默认值。 + * 同时会 清除缓存设置,因此下次 打开选项弹窗时会 恢复默认配置。 + +* 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 导入」 + +1. 打开 Spine 并新建项目。 +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:建议选中,以确保 附件被正确替换,更新 坐标和其他相关属性。 + * 新增的图层 会生成 新的附件 并添加到 现有的骨架中,但是 绘制顺序 可能会出现问题,需要在 Spine 中手动调整。 +* Import 按钮:使用当前配置 开始导入。 +* Cancel 按钮:关闭对话框并 取消导入。 + +### 已知问题 + +* 打开 导出文件位置,目前依赖 `os` 库 API,可能导致短暂 UI 卡顿(几秒)。 +* 删除旧的 `images` 文件,同样依赖 `os` 库 API,也可能导致短暂 UI 卡顿。 +* Aseprite 中新增的图层,在导入到 Spine 的现有骨架时,可能会出现 绘制顺序不正确 的问题,需要在 Spine 中手动调整。 + +### 版本历史 + +#### 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 + +* 导出时启用组可见性的有效继承 + * 在递归遍历中 将组可见性 向下传递。 + * 将 图层收集 与 有效可见性记录 合并为一次递归遍历,以提升效率。 + +* 新增 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 + +初始发布