---------------------------------------------------------------------- -- AniPhallow - Animation Frame Selector for Aseprite -- -- Select tiles from a spritesheet to build directional walk animations -- (Up, Down, Left, Right). Click tiles on the source view to assign -- them to the currently selected direction. -- -- Left-click: add tile to current direction -- Right-click drag: scroll source view ---------------------------------------------------------------------- ---------------------------------------------------------------------- -- Configuration ---------------------------------------------------------------------- local DEFAULT_TILE_W = 16 local DEFAULT_TILE_H = 24 local DEFAULT_ANIM_SPEED = 200 local DEFAULT_PREVIEW_ZOOM = 4 local DEFAULT_SOURCE_ZOOM = 2 local MAX_PREVIEW_ZOOM = 10 local THUMB_SIZE = 24 local SOURCE_VIEWPORT_W = 300 local SOURCE_VIEWPORT_H = 400 local MAX_ANIMS = 20 -- max number of dynamic animations local TABS = { "Animations", "Optimize" } local DEFAULT_GB_TILE_W = 8 -- Default Game Boy tile width local DEFAULT_GB_TILE_H = 8 -- Default Game Boy tile height local GB_COLS = 16 -- tiles per row in optimized image (128px = GB standard) ---------------------------------------------------------------------- -- Presets paths ---------------------------------------------------------------------- local PRESETS_DIR = app.fs.joinPath(app.fs.userConfigPath, "aniphallow_presets") local MASTER_FILE = app.fs.joinPath(app.fs.userConfigPath, "aniphallow_master.ini") local OLD_PREFS_FILE = app.fs.joinPath(app.fs.userConfigPath, "aniphallow_prefs.ini") local LOCK_FILE = app.fs.joinPath(app.fs.userConfigPath, "aniphallow.lock") ---------------------------------------------------------------------- -- State ---------------------------------------------------------------------- local pc = app.pixelColor local S = { tileW = DEFAULT_TILE_W, tileH = DEFAULT_TILE_H, animSpeed = DEFAULT_ANIM_SPEED, previewZoomAll = DEFAULT_PREVIEW_ZOOM, previewZoomOne = DEFAULT_PREVIEW_ZOOM, sourceZoom = DEFAULT_SOURCE_ZOOM, animFrame = 0, currentAnim = 1, -- index into animNames currentTab = "Animations", sourceImage = nil, scrollX = 0, scrollY = 0, dragging = false, dragLastX = 0, dragLastY = 0, useBgColor = false, bgColor = Color(0, 0, 0), -- GB tab state gbFlipOpt = false, gbTiles = {}, -- list of unique tiles: {hash, positions={{x,y,flip},...}} gbOptImage = nil, -- generated optimized tileset Image gbSelectedTile = 0, -- 1-based index of selected tile in optimized set gbScrollX = 0, gbScrollY = 0, gbDragging = false, gbDragLastX = 0, gbDragLastY = 0, gbSrcScrollX = 0, gbSrcScrollY = 0, gbSrcDragging = false, gbSrcDragLastX = 0, gbSrcDragLastY = 0, gbTotalTiles = 0, gbCols = GB_COLS, -- columns in optimized image (matches source width) gbSimilarThreshold = 80, -- percentage of painted pixels that must match (1-100) gbOffsetOpt = false, -- also check offset combinations when finding similar gbCompress = true, -- compress: pack unique tiles left-to-right; false: keep layout, blank dupes gbSimilarPairs = {}, -- list of {i, j, flip, ox, oy, match} pairs of similar tile indices gbZoomOpt = 3, -- independent zoom for optimized tileset canvas gbZoomSrc = 2, -- independent zoom for source occurrences canvas gbSilhouette = false, -- checkbox state gbSilhouetteColor = Color(0, 0, 0), -- silhouette color gbSilhouetteColorSet = false, -- tracks if ever set by user gbAnalyzeMode = "pixel", -- "pixel" or "tile" gbLastSavePath = "", -- remembers last PNG export path gbLayerName = "auto-optimized-tiles", -- default layer name gbAlwaysOverwrite = false, -- overwrite layer checkbox gbTileW = DEFAULT_GB_TILE_W, -- configurable tile width for GB analysis gbTileH = DEFAULT_GB_TILE_H, -- configurable tile height for GB analysis -- Remember if separate preview window was open previewWindowOpen = false, -- Click vs drag for GB canvases gbOptClickX = 0, gbOptClickY = 0, gbOptIsDrag = false, gbSrcClickX = 0, gbSrcClickY = 0, gbSrcIsDrag = false, -- Dynamic animations: each frame is {x, y, flipped, flippedV, offX, offY} animNames = {}, -- ordered list of animation names (sorted alphabetically) anims = {}, -- name -> list of {x, y, flipped, flippedV, offX, offY} -- Frame selection & drag selectedFrame = 0, frameDragging = false, frameDragFrom = 0, frameDragTo = 0, -- Source canvas click vs drag srcClickX = 0, srcClickY = 0, srcIsDrag = false, -- Preview layout configuration previewLayout = "auto", -- "auto", "fixedCols", "fixedRows" previewLayoutValue = 2, -- number for fixed cols/rows previewMode = "all", -- "all" or "single" previewSingleIdx = 1, -- which animation to show in single mode -- Current preset currentPreset = "Default", } ---------------------------------------------------------------------- -- File-to-preset association map ---------------------------------------------------------------------- local filePresetMap = {} -- filepath -> presetName local knownPresetNames = { "Default" } -- list of all known preset names local lastSpriteFilename = "" -- for auto-detect file change ---------------------------------------------------------------------- -- Tab widget IDs (for show/hide) ---------------------------------------------------------------------- -- Setup tab elements (btnConfig removed so it stays visible on all tabs) local SETUP_IDS = { "sepAnims", "cmbAnimList", "btnNewAnim", "btnDelAnim", "btnRenameAnim", "btnCloneAnim", "sepSource", "canvasSource", "canvasStrips", "btnFlipX", "btnFlipY", "btnMoveLeft", "btnMoveRight", "btnRemoveFrame", "btnFrameUp", "btnFrameDown", "btnFrameLeft", "btnFrameRight", "btnFrameReset", } -- GB tab elements (reordered: Flip, Offset, Silhouette, Compress) local GB_IDS = { "sepGb", "btnAnalyze", "gbFlipOpt", "gbOffsetOpt", "gbSilhouette", "gbCompress", "gbAnalyzeMode", "gbSimilarThreshold", "btnFindSimilar", "sepGbSource", "canvasGbSource", "canvasGbOpt", "btnGbSaveLayer", "btnGbCopyClipboard", "btnGbSave", } -- GB canvas heights (taller to match Animations tab and prevent resize on tab switch) local GB_SRC_H = 240 local GB_OPT_H = 240 ---------------------------------------------------------------------- -- Module-level window/timer variables ---------------------------------------------------------------------- local previewDlg = nil local previewTimer = nil local pvRefreshTimer = nil local mainDlg = nil local mainAnimTimer = nil local mainRefreshTimer = nil -- Forward declarations local openPreviewWindow local openMainDialog ---------------------------------------------------------------------- -- Serialize / Deserialize frames ---------------------------------------------------------------------- -- Serialize frames list: "x1,y1,f,fv,ox,oy;x2,y2,f,fv,ox,oy;..." local function serializeFrames(frames) local parts = {} for _, f in ipairs(frames) do table.insert(parts, f.x .. "," .. f.y .. "," .. (f.flipped and "1" or "0") .. "," .. (f.flippedV and "1" or "0") .. "," .. (f.offX or 0) .. "," .. (f.offY or 0)) end return table.concat(parts, ";") end -- Deserialize frames string back to list local function deserializeFrames(str) local frames = {} if not str or str == "" then return frames end for part in string.gmatch(str, "([^;]+)") do local x, y, fh, fv, ox, oy = string.match(part, "([%d-]+),([%d-]+),?(%d?),?(%d?),?([%d-]*),?([%d-]*)") if x and y then table.insert(frames, { x = tonumber(x), y = tonumber(y), flipped = (fh == "1"), flippedV = (fv == "1"), offX = tonumber(ox) or 0, offY = tonumber(oy) or 0 }) end end return frames end ---------------------------------------------------------------------- -- Reset to defaults ---------------------------------------------------------------------- local function resetToDefaults() S.tileW = DEFAULT_TILE_W S.tileH = DEFAULT_TILE_H S.animSpeed = DEFAULT_ANIM_SPEED S.sourceZoom = DEFAULT_SOURCE_ZOOM S.useBgColor = false S.bgColor = Color(0, 0, 0) S.gbFlipOpt = false S.gbOffsetOpt = false S.gbCompress = true S.gbSilhouette = false S.gbSilhouetteColor = Color(0, 0, 0) S.gbSilhouetteColorSet = false S.gbAnalyzeMode = "pixel" S.gbLayerName = "auto-optimized-tiles" S.gbAlwaysOverwrite = false S.gbTileW = DEFAULT_GB_TILE_W S.gbTileH = DEFAULT_GB_TILE_H S.gbSimilarThreshold = 80 S.animNames = {} S.anims = {} S.currentAnim = 1 S.selectedFrame = 0 end ---------------------------------------------------------------------- -- Preset name helpers ---------------------------------------------------------------------- local function addPresetName(name) for _, n in ipairs(knownPresetNames) do if n == name then return end end table.insert(knownPresetNames, name) table.sort(knownPresetNames, function(a, b) return a:lower() < b:lower() end) end local function removePresetName(name) for i, n in ipairs(knownPresetNames) do if n == name then table.remove(knownPresetNames, i); return end end end ---------------------------------------------------------------------- -- Preset system: save/load preset files and master file ---------------------------------------------------------------------- -- Get list of all known preset names local function getAllPresetNames() -- Ensure "Default" is always first local seen = {} local result = { "Default" } seen["Default"] = true for _, name in ipairs(knownPresetNames) do if not seen[name] then table.insert(result, name) seen[name] = true end end return result end -- Save current state to a preset file local function savePreset(presetName) -- Create presets dir if needed app.fs.makeDirectory(PRESETS_DIR) local path = app.fs.joinPath(PRESETS_DIR, presetName .. ".ini") local f = io.open(path, "w") if not f then return end f:write("tileW=" .. S.tileW .. "\n") f:write("tileH=" .. S.tileH .. "\n") f:write("animSpeed=" .. S.animSpeed .. "\n") f:write("sourceZoom=" .. S.sourceZoom .. "\n") -- Save current animation name for robust resolution after sort local curName = "" if S.currentAnim >= 1 and S.currentAnim <= #S.animNames then curName = S.animNames[S.currentAnim] end f:write("currentAnimName=" .. curName .. "\n") f:write("currentTab=" .. S.currentTab .. "\n") f:write("animNames=" .. table.concat(S.animNames, "|") .. "\n") f:write("useBgColor=" .. tostring(S.useBgColor) .. "\n") f:write("bgColorR=" .. S.bgColor.red .. "\n") f:write("bgColorG=" .. S.bgColor.green .. "\n") f:write("bgColorB=" .. S.bgColor.blue .. "\n") f:write("gbFlipOpt=" .. tostring(S.gbFlipOpt) .. "\n") f:write("gbOffsetOpt=" .. tostring(S.gbOffsetOpt) .. "\n") f:write("gbCompress=" .. tostring(S.gbCompress) .. "\n") f:write("gbSimilarThreshold=" .. S.gbSimilarThreshold .. "\n") f:write("gbZoomOpt=" .. S.gbZoomOpt .. "\n") f:write("gbZoomSrc=" .. S.gbZoomSrc .. "\n") f:write("gbSilhouette=" .. tostring(S.gbSilhouette) .. "\n") f:write("gbSilhouetteColorR=" .. S.gbSilhouetteColor.red .. "\n") f:write("gbSilhouetteColorG=" .. S.gbSilhouetteColor.green .. "\n") f:write("gbSilhouetteColorB=" .. S.gbSilhouetteColor.blue .. "\n") f:write("gbSilhouetteColorSet=" .. tostring(S.gbSilhouetteColorSet) .. "\n") f:write("gbAnalyzeMode=" .. S.gbAnalyzeMode .. "\n") f:write("gbLastSavePath=" .. S.gbLastSavePath .. "\n") f:write("gbLayerName=" .. S.gbLayerName .. "\n") f:write("gbAlwaysOverwrite=" .. tostring(S.gbAlwaysOverwrite) .. "\n") f:write("gbTileW=" .. S.gbTileW .. "\n") f:write("gbTileH=" .. S.gbTileH .. "\n") for i, name in ipairs(S.animNames) do f:write("anim_" .. (i - 1) .. "=" .. serializeFrames(S.anims[name] or {}) .. "\n") end f:close() end -- Load a preset file into S local function loadPreset(presetName) local path = app.fs.joinPath(PRESETS_DIR, presetName .. ".ini") local f = io.open(path, "r") if not f then -- If preset doesn't exist, reset to defaults resetToDefaults() S.currentPreset = presetName return end -- Reset animation data before loading S.animNames = {} S.anims = {} local savedAnimName = nil for line in f:lines() do local k, v = string.match(line, "^([%w_]+)=(.+)$") if k == "tileW" then S.tileW = tonumber(v) or DEFAULT_TILE_W end if k == "tileH" then S.tileH = tonumber(v) or DEFAULT_TILE_H end if k == "animSpeed" then S.animSpeed = tonumber(v) or DEFAULT_ANIM_SPEED end if k == "sourceZoom" then S.sourceZoom = tonumber(v) or DEFAULT_SOURCE_ZOOM end if k == "currentAnim" then S.currentAnim = tonumber(v) or 1 end if k == "currentAnimName" then savedAnimName = v end if k == "currentTab" and v ~= "" then if v == "Setup" then v = "Animations" elseif v == "Render" then v = "Animations" elseif v == "Preview" then v = "Animations" elseif v == "GB" then v = "Optimize" end S.currentTab = v end if k == "animNames" and v ~= "" then S.animNames = {} for name in string.gmatch(v, "([^|]+)") do table.insert(S.animNames, name) end end if k == "useBgColor" then S.useBgColor = (v == "true") end if k == "bgColorR" then S.bgColor = Color(tonumber(v) or 0, S.bgColor.green, S.bgColor.blue) end if k == "bgColorG" then S.bgColor = Color(S.bgColor.red, tonumber(v) or 0, S.bgColor.blue) end if k == "bgColorB" then S.bgColor = Color(S.bgColor.red, S.bgColor.green, tonumber(v) or 0) end if k == "gbFlipOpt" then S.gbFlipOpt = (v == "true") end if k == "gbOffsetOpt" then S.gbOffsetOpt = (v == "true") end if k == "gbCompress" then S.gbCompress = (v == "true") end if k == "gbSimilarThreshold" then S.gbSimilarThreshold = tonumber(v) or 80 end if k == "gbZoomOpt" then S.gbZoomOpt = tonumber(v) or 3 end if k == "gbZoomSrc" then S.gbZoomSrc = tonumber(v) or 2 end if k == "gbSilhouette" then S.gbSilhouette = (v == "true") end if k == "gbSilhouetteColorR" then S.gbSilhouetteColor = Color(tonumber(v) or 0, S.gbSilhouetteColor.green, S.gbSilhouetteColor.blue) end if k == "gbSilhouetteColorG" then S.gbSilhouetteColor = Color(S.gbSilhouetteColor.red, tonumber(v) or 0, S.gbSilhouetteColor.blue) end if k == "gbSilhouetteColorB" then S.gbSilhouetteColor = Color(S.gbSilhouetteColor.red, S.gbSilhouetteColor.green, tonumber(v) or 0) end if k == "gbSilhouetteColorSet" then S.gbSilhouetteColorSet = (v == "true") end if k == "gbAnalyzeMode" then S.gbAnalyzeMode = v end if k == "gbLastSavePath" then S.gbLastSavePath = v end if k == "gbLayerName" then S.gbLayerName = v end if k == "gbAlwaysOverwrite" then S.gbAlwaysOverwrite = (v == "true") end if k == "gbTileW" then S.gbTileW = tonumber(v) or DEFAULT_GB_TILE_W end if k == "gbTileH" then S.gbTileH = tonumber(v) or DEFAULT_GB_TILE_H end if k == "gbTileSize" then local ts = tonumber(v) or DEFAULT_GB_TILE_W S.gbTileW = ts S.gbTileH = ts end -- Dynamic anim frames: anim_0, anim_1, ... local animIdx = k and string.match(k, "^anim_(%d+)$") if animIdx then local idx = tonumber(animIdx) + 1 if S.animNames[idx] then S.anims[S.animNames[idx]] = deserializeFrames(v) end end end f:close() -- Sort animation names alphabetically table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) -- Resolve current animation by name if savedAnimName and savedAnimName ~= "" then for i, name in ipairs(S.animNames) do if name == savedAnimName then S.currentAnim = i break end end end -- Clamp currentAnim to valid range if S.currentAnim < 1 then S.currentAnim = 1 end if S.currentAnim > #S.animNames then S.currentAnim = math.max(1, #S.animNames) end S.currentPreset = presetName end -- Save master file (global prefs + file associations) local function saveMaster() local f = io.open(MASTER_FILE, "w") if not f then return end f:write("currentPreset=" .. S.currentPreset .. "\n") f:write("previewWindowOpen=" .. tostring(S.previewWindowOpen) .. "\n") f:write("previewLayout=" .. S.previewLayout .. "\n") f:write("previewLayoutValue=" .. S.previewLayoutValue .. "\n") f:write("previewMode=" .. S.previewMode .. "\n") f:write("previewZoomAll=" .. S.previewZoomAll .. "\n") f:write("previewZoomOne=" .. S.previewZoomOne .. "\n") f:write("previewSingleIdx=" .. S.previewSingleIdx .. "\n") -- File associations for filepath, preset in pairs(filePresetMap) do if preset ~= "Default" then f:write("file_" .. filepath .. "=" .. preset .. "\n") end end -- Preset names list (for discovery since we can't list dir) f:write("presetNames=" .. table.concat(getAllPresetNames(), "|") .. "\n") f:close() end -- Load master file local function loadMaster() local f = io.open(MASTER_FILE, "r") if not f then return end filePresetMap = {} for line in f:lines() do local k, v = string.match(line, "^(.-)=(.+)$") if k and v then if k == "currentPreset" then S.currentPreset = v end if k == "previewWindowOpen" then S.previewWindowOpen = (v == "true") end if k == "previewLayout" then S.previewLayout = v end if k == "previewLayoutValue" then S.previewLayoutValue = tonumber(v) or 2 end if k == "previewMode" then S.previewMode = v end if k == "previewZoomAll" then S.previewZoomAll = tonumber(v) or DEFAULT_PREVIEW_ZOOM end if k == "previewZoomOne" then S.previewZoomOne = tonumber(v) or DEFAULT_PREVIEW_ZOOM end if k == "previewSingleIdx" then S.previewSingleIdx = tonumber(v) or 1 end if k == "presetNames" and v ~= "" then knownPresetNames = {} for name in string.gmatch(v, "([^|]+)") do table.insert(knownPresetNames, name) end end -- File associations: keys starting with "file_" local filePath = string.match(k, "^file_(.+)$") if filePath then filePresetMap[filePath] = v end end end f:close() -- Clamp previewSingleIdx if S.previewSingleIdx < 1 then S.previewSingleIdx = 1 end end -- Convenience: save both preset and master local function saveAll() savePreset(S.currentPreset) saveMaster() end ---------------------------------------------------------------------- -- Migration from old prefs file ---------------------------------------------------------------------- local function migrateOldPrefs() -- Check if old prefs file exists and no presets exist yet local oldF = io.open(OLD_PREFS_FILE, "r") if not oldF then return false end -- Check if Default preset already exists local defaultPath = app.fs.joinPath(PRESETS_DIR, "Default.ini") local defF = io.open(defaultPath, "r") if defF then defF:close() oldF:close() return false -- Already migrated end oldF:close() -- Load the old prefs into S using the same parsing logic local f = io.open(OLD_PREFS_FILE, "r") if not f then return false end local savedAnimName = nil for line in f:lines() do local k, v = string.match(line, "^([%w_]+)=(.+)$") if k == "tileW" then S.tileW = tonumber(v) or DEFAULT_TILE_W end if k == "tileH" then S.tileH = tonumber(v) or DEFAULT_TILE_H end if k == "animSpeed" then S.animSpeed = tonumber(v) or DEFAULT_ANIM_SPEED end if k == "previewZoom" then local pz = tonumber(v) or DEFAULT_PREVIEW_ZOOM S.previewZoomAll = pz S.previewZoomOne = pz end if k == "previewZoomAll" then S.previewZoomAll = tonumber(v) or DEFAULT_PREVIEW_ZOOM end if k == "previewZoomOne" then S.previewZoomOne = tonumber(v) or DEFAULT_PREVIEW_ZOOM end if k == "sourceZoom" then S.sourceZoom = tonumber(v) or DEFAULT_SOURCE_ZOOM end if k == "currentAnim" then S.currentAnim = tonumber(v) or 1 end if k == "currentAnimName" then savedAnimName = v end if k == "currentTab" and v ~= "" then if v == "Setup" then v = "Animations" elseif v == "Render" then v = "Animations" elseif v == "Preview" then v = "Animations" elseif v == "GB" then v = "Optimize" end S.currentTab = v end if k == "animNames" and v ~= "" then S.animNames = {} for name in string.gmatch(v, "([^|]+)") do table.insert(S.animNames, name) end end if k == "useBgColor" then S.useBgColor = (v == "true") end if k == "bgColorR" then S.bgColor = Color(tonumber(v) or 0, S.bgColor.green, S.bgColor.blue) end if k == "bgColorG" then S.bgColor = Color(S.bgColor.red, tonumber(v) or 0, S.bgColor.blue) end if k == "bgColorB" then S.bgColor = Color(S.bgColor.red, S.bgColor.green, tonumber(v) or 0) end if k == "gbFlipOpt" then S.gbFlipOpt = (v == "true") end if k == "gbOffsetOpt" then S.gbOffsetOpt = (v == "true") end if k == "gbCompress" then S.gbCompress = (v == "true") end if k == "gbSimilarThreshold" then S.gbSimilarThreshold = tonumber(v) or 80 end if k == "gbZoomOpt" then S.gbZoomOpt = tonumber(v) or 3 end if k == "gbZoomSrc" then S.gbZoomSrc = tonumber(v) or 2 end if k == "gbSilhouette" then S.gbSilhouette = (v == "true") end if k == "gbSilhouetteColorR" then S.gbSilhouetteColor = Color(tonumber(v) or 0, S.gbSilhouetteColor.green, S.gbSilhouetteColor.blue) end if k == "gbSilhouetteColorG" then S.gbSilhouetteColor = Color(S.gbSilhouetteColor.red, tonumber(v) or 0, S.gbSilhouetteColor.blue) end if k == "gbSilhouetteColorB" then S.gbSilhouetteColor = Color(S.gbSilhouetteColor.red, S.gbSilhouetteColor.green, tonumber(v) or 0) end if k == "gbSilhouetteColorSet" then S.gbSilhouetteColorSet = (v == "true") end if k == "gbAnalyzeMode" then S.gbAnalyzeMode = v end if k == "gbLastSavePath" then S.gbLastSavePath = v end if k == "gbLayerName" then S.gbLayerName = v end if k == "gbAlwaysOverwrite" then S.gbAlwaysOverwrite = (v == "true") end if k == "gbTileW" then S.gbTileW = tonumber(v) or DEFAULT_GB_TILE_W end if k == "gbTileH" then S.gbTileH = tonumber(v) or DEFAULT_GB_TILE_H end if k == "gbTileSize" then local ts = tonumber(v) or DEFAULT_GB_TILE_W S.gbTileW = ts S.gbTileH = ts end if k == "previewWindowOpen" then S.previewWindowOpen = (v == "true") end if k == "previewLayout" then S.previewLayout = v end if k == "previewLayoutValue" then S.previewLayoutValue = tonumber(v) or 2 end if k == "previewMode" then S.previewMode = v end if k == "previewSingleIdx" then S.previewSingleIdx = tonumber(v) or 1 end local animIdx = k and string.match(k, "^anim_(%d+)$") if animIdx then local idx = tonumber(animIdx) + 1 if S.animNames[idx] then S.anims[S.animNames[idx]] = deserializeFrames(v) end end end f:close() -- Sort animation names alphabetically table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) -- Resolve current animation by name if savedAnimName and savedAnimName ~= "" then for i, name in ipairs(S.animNames) do if name == savedAnimName then S.currentAnim = i break end end end if S.currentAnim < 1 then S.currentAnim = 1 end if S.currentAnim > #S.animNames then S.currentAnim = math.max(1, #S.animNames) end if S.previewSingleIdx < 1 then S.previewSingleIdx = 1 end if #S.animNames > 0 and S.previewSingleIdx > #S.animNames then S.previewSingleIdx = #S.animNames end -- Save as Default preset S.currentPreset = "Default" savePreset("Default") saveMaster() return true end ---------------------------------------------------------------------- -- Initialize: load master, then load appropriate preset ---------------------------------------------------------------------- local function initPresets() -- Try migration first local migrated = migrateOldPrefs() if migrated then return end -- Load master file loadMaster() -- Determine which preset to load local presetToLoad = S.currentPreset or "Default" -- Check if current sprite has an associated preset if app.sprite and app.sprite.filename and app.sprite.filename ~= "" then local assoc = filePresetMap[app.sprite.filename] if assoc then presetToLoad = assoc end end -- Load the preset loadPreset(presetToLoad) -- Clamp previewSingleIdx if S.previewSingleIdx < 1 then S.previewSingleIdx = 1 end if #S.animNames > 0 and S.previewSingleIdx > #S.animNames then S.previewSingleIdx = #S.animNames end end initPresets() ---------------------------------------------------------------------- -- Helpers ---------------------------------------------------------------------- local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end local function extractCell(src, sx, sy, tw, th) local cell = Image(tw, th, ColorMode.RGB) cell:clear(pc.rgba(0, 0, 0, 0)) for y = 0, th - 1 do for x = 0, tw - 1 do local rx, ry = sx + x, sy + y if rx >= 0 and rx < src.width and ry >= 0 and ry < src.height then cell:putPixel(x, y, src:getPixel(rx, ry)) end end end return cell end local function flipImageH(src) local flipped = Image(src.width, src.height, ColorMode.RGB) flipped:clear(pc.rgba(0, 0, 0, 0)) for y = 0, src.height - 1 do for x = 0, src.width - 1 do flipped:putPixel(src.width - 1 - x, y, src:getPixel(x, y)) end end return flipped end local function flipImageV(src) local flipped = Image(src.width, src.height, ColorMode.RGB) flipped:clear(pc.rgba(0, 0, 0, 0)) for y = 0, src.height - 1 do for x = 0, src.width - 1 do flipped:putPixel(x, src.height - 1 - y, src:getPixel(x, y)) end end return flipped end local function drawCheckerboard(gc, w, h, zoom, offsetX, offsetY) local ox = offsetX or 0 local oy = offsetY or 0 local cs = math.max(2, math.floor(zoom / 2)) for cy = 0, h - 1, cs do for cx = 0, w - 1, cs do local parity = (math.floor(cx / cs) + math.floor(cy / cs)) % 2 gc.color = (parity == 0) and Color(50, 50, 50) or Color(70, 70, 70) gc:fillRect(Rectangle(ox + cx, oy + cy, cs, cs)) end end end local function getSourceContentSize() local imgW = S.sourceImage and S.sourceImage.width or 0 local imgH = S.sourceImage and S.sourceImage.height or 0 return imgW * S.sourceZoom, imgH * S.sourceZoom end local function clampScroll() local contentW, contentH = getSourceContentSize() local maxScrollX = math.max(0, contentW - SOURCE_VIEWPORT_W) local maxScrollY = math.max(0, contentH - SOURCE_VIEWPORT_H) S.scrollX = clamp(S.scrollX, 0, maxScrollX) S.scrollY = clamp(S.scrollY, 0, maxScrollY) end -- Get the darkest color in the current palette (lowest luminance) local function getDarkestPaletteColor() local ok, result = pcall(function() local sp = app.sprite if not sp then return Color(0, 0, 0) end local pal = sp.palettes[1] if not pal or #pal == 0 then return Color(0, 0, 0) end local darkest = nil local darkestLum = math.huge for i = 0, #pal - 1 do local c = pal:getColor(i) if c.alpha > 0 then local lum = 0.299 * c.red + 0.587 * c.green + 0.114 * c.blue if lum < darkestLum then darkestLum = lum darkest = Color(c.red, c.green, c.blue) end end end return darkest or Color(0, 0, 0) end) if ok then return result end return Color(0, 0, 0) end ---------------------------------------------------------------------- -- Preview grid helper (module-level) ---------------------------------------------------------------------- local function getPreviewGrid(numAnims) if numAnims <= 0 then return 1, 1 end if S.previewLayout == "fixedCols" then local cols = math.max(1, S.previewLayoutValue) local rows = math.max(1, math.ceil(numAnims / cols)) return cols, rows elseif S.previewLayout == "fixedRows" then local rows = math.max(1, S.previewLayoutValue) local cols = math.max(1, math.ceil(numAnims / rows)) return cols, rows else -- auto: tend to square local cols = math.max(1, math.ceil(math.sqrt(numAnims))) local rows = math.max(1, math.ceil(numAnims / cols)) return cols, rows end end -- Get current preview zoom based on mode local function pvZoom() if S.previewMode == "single" then return S.previewZoomOne end return S.previewZoomAll end ---------------------------------------------------------------------- -- Module-level refreshSource ---------------------------------------------------------------------- local function refreshSource() if not app.sprite then return end local sp = app.sprite local rgbSpec = ImageSpec{ width = sp.width, height = sp.height, colorMode = ColorMode.RGB, transparentColor = 0 } local img = Image(rgbSpec) img:clear() img:drawSprite(sp, app.frame) S.sourceImage = img end ---------------------------------------------------------------------- -- Module-level getFrameImage ---------------------------------------------------------------------- local function getFrameImageGlobal(animName, idx) local frames = S.anims[animName] if not frames then return nil end local f = frames[idx] if not f or not S.sourceImage then return nil end local img = extractCell(S.sourceImage, f.x, f.y, S.tileW, S.tileH) if f.flipped then img = flipImageH(img) end if f.flippedV then img = flipImageV(img) end return img end ---------------------------------------------------------------------- -- GB Tile Hashing & Deduplication (parameterized by tw, th) ---------------------------------------------------------------------- -- Hash a tile at position (sx,sy) in source image local function tileHash(img, sx, sy, tw, th) local parts = {} for y = 0, th - 1 do for x = 0, tw - 1 do local rx, ry = sx + x, sy + y if rx < img.width and ry < img.height then parts[#parts + 1] = tostring(img:getPixel(rx, ry)) else parts[#parts + 1] = "0" end end end return table.concat(parts, ",") end -- Hash with horizontal flip local function tileHashFlipH(img, sx, sy, tw, th) local parts = {} for y = 0, th - 1 do for x = tw - 1, 0, -1 do local rx, ry = sx + x, sy + y if rx < img.width and ry < img.height then parts[#parts + 1] = tostring(img:getPixel(rx, ry)) else parts[#parts + 1] = "0" end end end return table.concat(parts, ",") end -- Hash with vertical flip local function tileHashFlipV(img, sx, sy, tw, th) local parts = {} for y = th - 1, 0, -1 do for x = 0, tw - 1 do local rx, ry = sx + x, sy + y if rx < img.width and ry < img.height then parts[#parts + 1] = tostring(img:getPixel(rx, ry)) else parts[#parts + 1] = "0" end end end return table.concat(parts, ",") end -- Hash with both flips local function tileHashFlipHV(img, sx, sy, tw, th) local parts = {} for y = th - 1, 0, -1 do for x = tw - 1, 0, -1 do local rx, ry = sx + x, sy + y if rx < img.width and ry < img.height then parts[#parts + 1] = tostring(img:getPixel(rx, ry)) else parts[#parts + 1] = "0" end end end return table.concat(parts, ",") end -- Check if a tile is fully transparent local function isTileEmpty(img, sx, sy, tw, th) for y = 0, th - 1 do for x = 0, tw - 1 do if pc.rgbaA(img:getPixel(sx + x, sy + y)) > 0 then return false end end end return true end -- Check if tile B (at bx,by) is tile A (at ax,ay) placed with offset+flip local function tilesMatchOffset(img, ax, ay, bx, by, ox, oy, flipH, flipV, tw, th) local txMax = tw - 1 local tyMax = th - 1 for y = 0, tyMax do for x = 0, txMax do local pb = img:getPixel(bx + x, by + y) local bPainted = pc.rgbaA(pb) > 0 local axp = x - ox local ayp = y - oy if axp >= 0 and axp <= txMax and ayp >= 0 and ayp <= tyMax then local srcAx = flipH and (txMax - axp) or axp local srcAy = flipV and (tyMax - ayp) or ayp local pa = img:getPixel(ax + srcAx, ay + srcAy) local aPainted = pc.rgbaA(pa) > 0 if bPainted ~= aPainted then return false end if bPainted and pa ~= pb then return false end else if bPainted then return false end end end end return true end -- Analyze source image and build deduplicated tile list local function analyzeTiles(img, flipOpt, offsetOpt, tw, th) local tiles = {} local hashIndex = {} local totalCount = 0 local cols = math.floor(img.width / tw) local rows = math.floor(img.height / th) local flipModes = { {false, false, "none"} } if flipOpt then flipModes[2] = {true, false, "h"} flipModes[3] = {false, true, "v"} flipModes[4] = {true, true, "hv"} end for row = 0, rows - 1 do for col = 0, cols - 1 do local sx = col * tw local sy = row * th if isTileEmpty(img, sx, sy, tw, th) then goto continueAnalyze end local hash = tileHash(img, sx, sy, tw, th) totalCount = totalCount + 1 if hashIndex[hash] then table.insert(tiles[hashIndex[hash]].positions, {x = sx, y = sy, flip = "none", ox = 0, oy = 0}) goto continueAnalyze end if flipOpt then local hashH = tileHashFlipH(img, sx, sy, tw, th) local hashV = tileHashFlipV(img, sx, sy, tw, th) local hashHV = tileHashFlipHV(img, sx, sy, tw, th) if hashIndex[hashH] then table.insert(tiles[hashIndex[hashH]].positions, {x = sx, y = sy, flip = "h", ox = 0, oy = 0}) goto continueAnalyze elseif hashIndex[hashV] then table.insert(tiles[hashIndex[hashV]].positions, {x = sx, y = sy, flip = "v", ox = 0, oy = 0}) goto continueAnalyze elseif hashIndex[hashHV] then table.insert(tiles[hashIndex[hashHV]].positions, {x = sx, y = sy, flip = "hv", ox = 0, oy = 0}) goto continueAnalyze end end if offsetOpt then local found = false for ti = 1, #tiles do local posA = tiles[ti].positions[1] for _, fm in ipairs(flipModes) do for ofy = -(th - 1), th - 1 do for ofx = -(tw - 1), tw - 1 do if ofx == 0 and ofy == 0 then goto continueOff end if tilesMatchOffset(img, posA.x, posA.y, sx, sy, ofx, ofy, fm[1], fm[2], tw, th) then table.insert(tiles[ti].positions, {x = sx, y = sy, flip = fm[3], ox = ofx, oy = ofy}) found = true end if found then break end ::continueOff:: end if found then break end end if found then break end end if found then break end end if found then goto continueAnalyze end end local idx = #tiles + 1 tiles[idx] = { hash = hash, positions = {{x = sx, y = sy, flip = "none", ox = 0, oy = 0}} } hashIndex[hash] = idx ::continueAnalyze:: end end return tiles, totalCount end -- Analyze tiles using tilemap layers (tile mode) local function analyzeTilesTileMode(sprite, frameNum) local tiles = {} local hashIndex = {} local totalCount = 0 local tileW = S.gbTileW local tileH = S.gbTileH local ok, err = pcall(function() for _, layer in ipairs(sprite.layers) do if not layer.isTilemap then goto continueLayer end local cel = layer:cel(frameNum) if not cel then goto continueLayer end local tileset = layer.tileset if not tileset then goto continueLayer end local ts = tileset.grid.tileSize tileW = ts.width tileH = ts.height local celImg = cel.image local celPos = cel.position local imgW = celImg.width local imgH = celImg.height local placements = {} for row = 0, imgH - 1 do for col = 0, imgW - 1 do local value = celImg:getPixel(col, row) local tileIdx = 0 local pOk, _ = pcall(function() tileIdx = app.pixelColor.tileI(value) end) if not pOk or tileIdx == 0 then goto continueTile end local canvasX = celPos.x + col * ts.width local canvasY = celPos.y + row * ts.height local tsId = layer.name or "ts" local hash = tsId .. ":" .. tileIdx table.insert(placements, { hash = hash, canvasX = canvasX, canvasY = canvasY, tileIdx = tileIdx, }) ::continueTile:: end end table.sort(placements, function(a, b) if a.canvasY ~= b.canvasY then return a.canvasY < b.canvasY end return a.canvasX < b.canvasX end) for _, p in ipairs(placements) do totalCount = totalCount + 1 if hashIndex[p.hash] then table.insert(tiles[hashIndex[p.hash]].positions, {x = p.canvasX, y = p.canvasY, flip = "none", ox = 0, oy = 0}) else local idx = #tiles + 1 tiles[idx] = { hash = p.hash, positions = {{x = p.canvasX, y = p.canvasY, flip = "none", ox = 0, oy = 0}} } hashIndex[p.hash] = idx end end ::continueLayer:: end end) if not ok then app.alert("Tile mode error: " .. tostring(err)) end return tiles, totalCount, tileW, tileH end -- Compare two tile regions with offset and optional flip local function tileSimilarityEx(img, ax, ay, bx, by, ox, oy, flipH, flipV, tw, th) local matches = 0 local paintedA = 0 local txMax = tw - 1 local tyMax = th - 1 for y = 0, tyMax do for x = 0, txMax do local pa = img:getPixel(ax + x, ay + y) if pc.rgbaA(pa) > 0 then paintedA = paintedA + 1 local bxp = x - ox local byp = y - oy if bxp >= 0 and bxp <= txMax and byp >= 0 and byp <= tyMax then local srcBx = flipH and (txMax - bxp) or bxp local srcBy = flipV and (tyMax - byp) or byp local pb = img:getPixel(bx + srcBx, by + srcBy) if pa == pb then matches = matches + 1 end end end end end return matches, paintedA end -- Count non-transparent pixels in a tile local function countPaintedPixels(img, sx, sy, tw, th) local count = 0 for y = 0, th - 1 do for x = 0, tw - 1 do if pc.rgbaA(img:getPixel(sx + x, sy + y)) > 0 then count = count + 1 end end end return count end -- Find pairs of unique tiles that are similar (above threshold percentage) local function findSimilarTiles(img, tiles, thresholdPct, flipOpt, offsetOpt, tw, th) local results = {} local flipModes = { {false, false, "none"} } if flipOpt then flipModes[2] = {true, false, "h"} flipModes[3] = {false, true, "v"} flipModes[4] = {true, true, "hv"} end local offsets = { {0, 0} } if offsetOpt then offsets = {} for ofy = -(th - 1), th - 1 do for ofx = -(tw - 1), tw - 1 do table.insert(offsets, {ofx, ofy}) end end end local paintedCount = {} for i = 1, #tiles do local p = tiles[i].positions[1] paintedCount[i] = countPaintedPixels(img, p.x, p.y, tw, th) end for i = 1, #tiles do local posA = tiles[i].positions[1] local paintedA = paintedCount[i] if paintedA == 0 then goto continueSI end for j = i + 1, #tiles do local posB = tiles[j].positions[1] local paintedB = paintedCount[j] if paintedB == 0 then goto continueSJ end local minPainted = math.min(paintedA, paintedB) local needed = math.ceil(minPainted * thresholdPct / 100) local bestMatch = 0 local bestFlip = "none" local bestOx, bestOy = 0, 0 for _, fm in ipairs(flipModes) do for _, off in ipairs(offsets) do local m = tileSimilarityEx( img, posA.x, posA.y, posB.x, posB.y, off[1], off[2], fm[1], fm[2], tw, th) if m > bestMatch then bestMatch = m bestFlip = fm[3] bestOx = off[1] bestOy = off[2] end if bestMatch >= minPainted then break end end if bestMatch >= minPainted then break end end if bestMatch >= needed then local pct = minPainted > 0 and math.floor(bestMatch / minPainted * 100) or 0 table.insert(results, { i = i, j = j, flip = bestFlip, ox = bestOx, oy = bestOy, match = bestMatch, pct = pct }) end ::continueSJ:: end ::continueSI:: end return results end -- Build optimized tileset image local function buildOptimizedImage(img, tiles, compress, silhouette, silhouetteColor, tw, th) local srcCols = math.floor(img.width / tw) if srcCols < 1 then srcCols = 1 end local numTiles = #tiles if numTiles == 0 then return nil, srcCols end if compress then local imgRows = math.ceil(numTiles / srcCols) local optW = srcCols * tw local optH = imgRows * th local optImg = Image(optW, optH, ColorMode.RGB) optImg:clear(pc.rgba(0, 0, 0, 0)) for i, tile in ipairs(tiles) do local pos = tile.positions[1] local destCol = (i - 1) % srcCols local destRow = math.floor((i - 1) / srcCols) local dx = destCol * tw local dy = destRow * th for y = 0, th - 1 do for x = 0, tw - 1 do local rx, ry = pos.x + x, pos.y + y if rx < img.width and ry < img.height then optImg:putPixel(dx + x, dy + y, img:getPixel(rx, ry)) end end end end return optImg, srcCols else local optImg = Image(img.width, img.height, ColorMode.RGB) optImg:clear(pc.rgba(0, 0, 0, 0)) local firstPositions = {} for _, tile in ipairs(tiles) do local pos = tile.positions[1] firstPositions[pos.x .. "," .. pos.y] = true end for _, tile in ipairs(tiles) do local pos = tile.positions[1] for y = 0, th - 1 do for x = 0, tw - 1 do local rx, ry = pos.x + x, pos.y + y if rx < img.width and ry < img.height then optImg:putPixel(rx, ry, img:getPixel(rx, ry)) end end end end if silhouette and silhouetteColor then local silR = silhouetteColor.red local silG = silhouetteColor.green local silB = silhouetteColor.blue local silPixel = pc.rgba(silR, silG, silB, 255) for _, tile in ipairs(tiles) do for pi = 2, #tile.positions do local pos = tile.positions[pi] for y = 0, th - 1 do for x = 0, tw - 1 do local rx, ry = pos.x + x, pos.y + y if rx >= 0 and rx < img.width and ry >= 0 and ry < img.height then local srcPx = img:getPixel(rx, ry) if pc.rgbaA(srcPx) > 0 then optImg:putPixel(rx, ry, silPixel) end end end end end end end return optImg, srcCols end end ---------------------------------------------------------------------- -- Lock file helpers ---------------------------------------------------------------------- local function removeLockFile() os.remove(LOCK_FILE) end ---------------------------------------------------------------------- -- Preview Window (primary window) ---------------------------------------------------------------------- openPreviewWindow = function() -- Close existing preview window if open if previewDlg then pcall(function() previewDlg:close() end) end if previewTimer then pcall(function() previewTimer:stop() end) previewTimer = nil end if pvRefreshTimer then pcall(function() pvRefreshTimer:stop() end) pvRefreshTimer = nil end local pvAnimFrame = { value = 0 } local PV_MARGIN = 2 -- Clamp previewSingleIdx if #S.animNames > 0 then if S.previewSingleIdx < 1 then S.previewSingleIdx = 1 end if S.previewSingleIdx > #S.animNames then S.previewSingleIdx = #S.animNames end else S.previewSingleIdx = 1 end -- calcPreviewSize helper local function calcPreviewSize() local z = pvZoom() local ptw = S.tileW * z local pth = S.tileH * z local cellW = ptw + PV_MARGIN * 2 local cellH = pth + PV_MARGIN * 2 if S.previewMode == "single" then return cellW, cellH + 14 -- extra space for name label else local numAnims = #S.animNames local cols, rows = getPreviewGrid(numAnims) return cols * cellW, rows * cellH end end -- Calculate initial size local pvWidth, pvHeight = calcPreviewSize() pvWidth = math.max(pvWidth, 120) pvHeight = math.max(pvHeight, 40) previewDlg = Dialog{ title = "AniPhallow (" .. S.currentPreset .. ")", onclose = function() if previewTimer then pcall(function() previewTimer:stop() end) end if pvRefreshTimer then pcall(function() pvRefreshTimer:stop() end) end previewTimer = nil pvRefreshTimer = nil previewDlg = nil S.previewWindowOpen = false saveAll() -- If main dialog is also closed, remove lock if not mainDlg then removeLockFile() end end } -- All buttons on the same row: Setup, toggle mode, Fit previewDlg:button{ id = "pvSetup", text = "Setup", onclick = function() openMainDialog() end } previewDlg:button{ id = "pvToggleMode", text = S.previewMode == "all" and "All" or "One", onclick = function() if S.previewMode == "all" then S.previewMode = "single" if #S.animNames > 0 then if S.previewSingleIdx < 1 then S.previewSingleIdx = 1 end if S.previewSingleIdx > #S.animNames then S.previewSingleIdx = #S.animNames end end else S.previewMode = "all" end -- Close and reopen to resize canvas pcall(function() previewDlg:close() end) openPreviewWindow() end } previewDlg:button{ id = "pvFit", text = "Fit", onclick = function() -- Close and reopen to refit pcall(function() previewDlg:close() end) openPreviewWindow() end } previewDlg:canvas{ id = "pvCanvas", width = pvWidth, height = pvHeight, autoscaling = false, onpaint = function(ev) local gc = ev.context local na = #S.animNames if na == 0 then return end local z = pvZoom() local pw = S.tileW * z local ph = S.tileH * z local cW = pw + PV_MARGIN * 2 local cH = ph + PV_MARGIN * 2 local af = pvAnimFrame.value if S.previewMode == "single" and S.previewSingleIdx >= 1 and S.previewSingleIdx <= na then local name = S.animNames[S.previewSingleIdx] local frames = S.anims[name] or {} if S.useBgColor then gc.color = S.bgColor gc:fillRect(Rectangle(PV_MARGIN, PV_MARGIN, pw, ph)) else drawCheckerboard(gc, pw, ph, z, PV_MARGIN, PV_MARGIN) end local totalFrames = #frames if totalFrames > 0 then local idx = (af % totalFrames) + 1 local fimg = getFrameImageGlobal(name, idx) local f = frames[idx] local offX = (f.offX or 0) * z local offY = (f.offY or 0) * z if fimg then gc:drawImage(fimg, Rectangle(0, 0, S.tileW, S.tileH), Rectangle(PV_MARGIN + offX, PV_MARGIN + offY, pw, ph)) end end else local cols, rows = getPreviewGrid(na) for i, name in ipairs(S.animNames) do local col = (i - 1) % cols local row = math.floor((i - 1) / cols) local ox = col * cW local oy = row * cH if S.useBgColor then gc.color = S.bgColor gc:fillRect(Rectangle(ox + PV_MARGIN, oy + PV_MARGIN, pw, ph)) else drawCheckerboard(gc, pw, ph, z, ox + PV_MARGIN, oy + PV_MARGIN) end local frames = S.anims[name] or {} local totalFrames = #frames if totalFrames > 0 then local idx = (af % totalFrames) + 1 local fimg = getFrameImageGlobal(name, idx) if fimg then local f = frames[idx] local fOffX = (f.offX or 0) * z local fOffY = (f.offY or 0) * z gc:drawImage(fimg, Rectangle(0, 0, S.tileW, S.tileH), Rectangle(ox + PV_MARGIN + fOffX, oy + PV_MARGIN + fOffY, pw, ph)) end end end end end, onmousedown = function(ev) -- In single mode: L-click=prev anim, R-click=next anim if S.previewMode == "single" then if ev.button == MouseButton.LEFT then if #S.animNames > 0 then S.previewSingleIdx = S.previewSingleIdx - 1 if S.previewSingleIdx < 1 then S.previewSingleIdx = #S.animNames end -- Update label text and repaint without closing/reopening pcall(function() previewDlg:modify{ id = "pvAnimName", text = S.animNames[S.previewSingleIdx] or "" } end) previewDlg:repaint() end elseif ev.button == MouseButton.RIGHT then if #S.animNames > 0 then S.previewSingleIdx = S.previewSingleIdx + 1 if S.previewSingleIdx > #S.animNames then S.previewSingleIdx = 1 end -- Update label text and repaint without closing/reopening pcall(function() previewDlg:modify{ id = "pvAnimName", text = S.animNames[S.previewSingleIdx] or "" } end) previewDlg:repaint() end end end end, onwheel = function(ev) local dz = ev.deltaY < 0 and 1 or -1 if S.previewMode == "single" then S.previewZoomOne = math.max(1, math.min(MAX_PREVIEW_ZOOM, S.previewZoomOne + dz)) else S.previewZoomAll = math.max(1, math.min(MAX_PREVIEW_ZOOM, S.previewZoomAll + dz)) end previewDlg:repaint() end } -- Animation name label for single mode local singleName = "" if S.previewSingleIdx >= 1 and S.previewSingleIdx <= #S.animNames then singleName = S.animNames[S.previewSingleIdx] end previewDlg:label{ id = "pvAnimName", text = singleName, visible = (S.previewMode == "single") } previewTimer = Timer{ interval = S.animSpeed / 1000.0, ontick = function() pvAnimFrame.value = pvAnimFrame.value + 1 pcall(function() previewDlg:repaint() end) end } previewTimer:start() pvRefreshTimer = Timer{ interval = 0.5, ontick = function() refreshSource() pcall(function() previewDlg:repaint() end) -- Auto-detect file change local currentFile = app.sprite and app.sprite.filename or "" if currentFile ~= lastSpriteFilename then lastSpriteFilename = currentFile -- Look up preset for this file local presetForFile = filePresetMap[currentFile] or "Default" if presetForFile ~= S.currentPreset then loadPreset(presetForFile) S.currentPreset = presetForFile -- Refresh UI if main dialog is open (don't touch preview - avoids repositioning) if mainDlg then pcall(function() mainDlg:close() end) openMainDialog() end end end end } pvRefreshTimer:start() S.previewWindowOpen = true saveAll() previewDlg:show{ wait = false } end ---------------------------------------------------------------------- -- Main Dialog (secondary window - setup) ---------------------------------------------------------------------- openMainDialog = function() -- If main dialog already open, just bring focus (close and reopen) if mainDlg then pcall(function() mainDlg:close() end) end local dlg local function currentAnimName() if S.currentAnim >= 1 and S.currentAnim <= #S.animNames then return S.animNames[S.currentAnim] end return nil end local function getFrameImage(animName, idx) return getFrameImageGlobal(animName, idx) end local function captureCell(pixelX, pixelY, flipped) if not S.sourceImage then return end local name = currentAnimName() if not name then app.alert("Create an animation first.") return end local gridX = math.floor(pixelX / S.tileW) * S.tileW local gridY = math.floor(pixelY / S.tileH) * S.tileH if gridX < 0 or gridX + S.tileW > S.sourceImage.width then return end if gridY < 0 or gridY + S.tileH > S.sourceImage.height then return end if not S.anims[name] then S.anims[name] = {} end table.insert(S.anims[name], { x = gridX, y = gridY, flipped = flipped, offX = 0, offY = 0 }) saveAll() dlg:repaint() end -- Switch visible tab (only Animations and Optimize) local function switchTab(tab) S.currentTab = tab local isSetup = (tab == "Animations") local isOpt = (tab == "Optimize") for _, id in ipairs(SETUP_IDS) do pcall(function() dlg:modify{ id = id, visible = isSetup } end) end for _, id in ipairs(GB_IDS) do pcall(function() dlg:modify{ id = id, visible = isOpt } end) end -- Update tab button labels for _, t in ipairs(TABS) do local label = (t == tab) and ("[" .. t .. "]") or (" " .. t .. " ") pcall(function() dlg:modify{ id = "tab" .. t, text = label } end) end dlg:repaint() end local function startAnimTimer() if mainAnimTimer then mainAnimTimer:stop() end mainAnimTimer = Timer{ interval = S.animSpeed / 1000.0, ontick = function() S.animFrame = S.animFrame + 1 pcall(function() dlg:repaint() end) end } mainAnimTimer:start() end local contentW, contentH = getSourceContentSize() local viewW = math.min(contentW, SOURCE_VIEWPORT_W) local viewH = math.min(contentH, SOURCE_VIEWPORT_H) dlg = Dialog{ title = "AniPhallow (" .. S.currentPreset .. ")", onclose = function() if mainAnimTimer then pcall(function() mainAnimTimer:stop() end) end if mainRefreshTimer then pcall(function() mainRefreshTimer:stop() end) end mainAnimTimer = nil mainRefreshTimer = nil mainDlg = nil saveAll() -- If preview is also closed, remove lock if not previewDlg then removeLockFile() end end } mainDlg = dlg ---------------------------------------------------------------- -- TAB BUTTONS (Animations, Optimize) ---------------------------------------------------------------- for _, tab in ipairs(TABS) do local label = (tab == S.currentTab) and ("[" .. tab .. "]") or (" " .. tab .. " ") dlg:button{ id = "tab" .. tab, text = label, onclick = function() switchTab(tab) end } end ---------------------------------------------------------------- -- Config button (opens sub-dialog) - visible on ALL tabs ---------------------------------------------------------------- dlg:button{ id = "btnConfig", text = "Config", onclick = function() local d = Dialog{ title = "Config" } -------------------------------------------------------- -- Presets section -------------------------------------------------------- d:separator{ text = "Presets" } d:combobox{ id = "presetSelect", option = S.currentPreset, options = getAllPresetNames() } d:button{ id = "presetNew", text = "New", onclick = function() local nd = Dialog{ title = "New Preset" } nd:entry{ id = "name", label = "Name:", text = "" } nd:button{ id = "ok", text = "OK" } nd:button{ text = "Cancel" } nd:show() if nd.data.ok and nd.data.name and nd.data.name ~= "" then local newName = nd.data.name -- Check if exists local exists = false for _, n in ipairs(getAllPresetNames()) do if n == newName then exists = true; break end end if exists then local r = app.alert{ title = "Overwrite?", text = "Preset '" .. newName .. "' already exists. Overwrite?", buttons = {"Overwrite", "Cancel"} } if r ~= 1 then return end end -- Save current preset first savePreset(S.currentPreset) -- Reset to defaults and save as new preset resetToDefaults() S.currentPreset = newName addPresetName(newName) savePreset(newName) local currentFile = app.sprite and app.sprite.filename or "" if currentFile ~= "" then filePresetMap[currentFile] = newName end saveMaster() -- Close config and reopen main d:close() if mainDlg then pcall(function() mainDlg:close() end) end openMainDialog() -- Preview title will be stale but avoids repositioning window end end } d:button{ id = "presetClone", text = "Clone", onclick = function() local cd = Dialog{ title = "Clone Preset" } cd:entry{ id = "name", label = "New name:", text = S.currentPreset .. "_copy" } cd:button{ id = "ok", text = "Clone" } cd:button{ text = "Cancel" } cd:show() if cd.data.ok and cd.data.name and cd.data.name ~= "" then local newName = cd.data.name -- Check if name already exists local exists = false for _, n in ipairs(getAllPresetNames()) do if n == newName then exists = true; break end end if exists then app.alert("Preset '" .. newName .. "' already exists.") return end -- Save current state as the new preset (clone) savePreset(newName) addPresetName(newName) S.currentPreset = newName local currentFile = app.sprite and app.sprite.filename or "" if currentFile ~= "" then filePresetMap[currentFile] = newName end saveMaster() -- Close config and reopen main d:close() if mainDlg then pcall(function() mainDlg:close() end) end openMainDialog() -- Preview title will be stale but avoids repositioning window end end } d:button{ id = "presetRename", text = "Rename", onclick = function() if S.currentPreset == "Default" then app.alert("Cannot rename the Default preset.") return end local rd = Dialog{ title = "Rename Preset" } rd:entry{ id = "name", label = "New name:", text = S.currentPreset } rd:button{ id = "ok", text = "OK" } rd:button{ text = "Cancel" } rd:show() if rd.data.ok and rd.data.name and rd.data.name ~= "" then local newName = rd.data.name if newName == S.currentPreset then return end -- Check if name already exists local exists = false for _, n in ipairs(getAllPresetNames()) do if n == newName then exists = true; break end end if exists then app.alert("Preset '" .. newName .. "' already exists.") return end local oldName = S.currentPreset -- Rename the preset file local oldPath = app.fs.joinPath(PRESETS_DIR, oldName .. ".ini") local newPath = app.fs.joinPath(PRESETS_DIR, newName .. ".ini") os.rename(oldPath, newPath) -- Update known names removePresetName(oldName) addPresetName(newName) -- Update file associations for filepath, preset in pairs(filePresetMap) do if preset == oldName then filePresetMap[filepath] = newName end end S.currentPreset = newName saveMaster() -- Close config and reopen main d:close() if mainDlg then pcall(function() mainDlg:close() end) end openMainDialog() -- Preview title will be stale but avoids repositioning window end end } d:button{ id = "presetDelete", text = "Delete", onclick = function() if S.currentPreset == "Default" then app.alert("Cannot delete the Default preset.") return end local confirm = app.alert{ title = "Delete Preset", text = "Delete preset '" .. S.currentPreset .. "'?", buttons = { "Delete", "Cancel" } } if confirm ~= 1 then return end local deleteName = S.currentPreset -- Delete the preset file local path = app.fs.joinPath(PRESETS_DIR, deleteName .. ".ini") os.remove(path) -- Remove from known names removePresetName(deleteName) -- Remove file associations pointing to this preset for filepath, preset in pairs(filePresetMap) do if preset == deleteName then filePresetMap[filepath] = nil end end -- Switch to Default loadPreset("Default") S.currentPreset = "Default" if app.sprite and app.sprite.filename and app.sprite.filename ~= "" then filePresetMap[app.sprite.filename] = nil end saveMaster() -- Close config and reopen main d:close() if mainDlg then pcall(function() mainDlg:close() end) end openMainDialog() -- Reopen preview to update title if previewDlg then pcall(function() previewDlg:close() end) openPreviewWindow() end end } -------------------------------------------------------- -- Animations section -------------------------------------------------------- d:separator{ text = "Animations" } d:number{ id = "tileW", label = "Sprite W:", text = tostring(S.tileW), decimals = 0 } d:number{ id = "tileH", label = "Sprite H:", text = tostring(S.tileH), decimals = 0 } d:slider{ id = "animSpeed", label = "Speed (ms):", min = 50, max = 1000, value = S.animSpeed } d:check{ id = "useBgColor", text = "Solid background", selected = S.useBgColor } d:color{ id = "bgColor", label = "Bg Color:", color = S.bgColor } -------------------------------------------------------- -- Optimization section -------------------------------------------------------- d:separator{ text = "Optimization" } d:number{ id = "gbTileW", label = "Tile W:", text = tostring(S.gbTileW), decimals = 0 } d:number{ id = "gbTileH", label = "Tile H:", text = tostring(S.gbTileH), decimals = 0 } local silColor = S.gbSilhouetteColor if not S.gbSilhouetteColorSet then silColor = getDarkestPaletteColor() end d:color{ id = "silhouetteColor", label = "Silhouette:", color = silColor } d:entry{ id = "layerName", label = "Layer name:", text = S.gbLayerName } d:check{ id = "alwaysOverwrite", text = "Always overwrite layer", selected = S.gbAlwaysOverwrite } -------------------------------------------------------- -- Preview section -------------------------------------------------------- d:separator{ text = "Preview" } d:combobox{ id = "previewLayout", label = "Layout:", option = S.previewLayout, options = {"auto", "fixedCols", "fixedRows"} } d:number{ id = "previewLayoutValue", label = "Value:", text = tostring(S.previewLayoutValue), decimals = 0 } d:button{ id = "ok", text = "OK" } d:button{ text = "Cancel" } d:show() if d.data.ok then local tw = d.data.tileW if tw < 4 then tw = 4 elseif tw > 128 then tw = 128 end S.tileW = tw local th = d.data.tileH if th < 4 then th = 4 elseif th > 128 then th = 128 end S.tileH = th S.animSpeed = d.data.animSpeed if mainAnimTimer then mainAnimTimer.interval = S.animSpeed / 1000.0 end if previewTimer then previewTimer.interval = S.animSpeed / 1000.0 end S.useBgColor = d.data.useBgColor S.bgColor = d.data.bgColor local gtw = d.data.gbTileW if gtw < 1 then gtw = 1 elseif gtw > 128 then gtw = 128 end S.gbTileW = gtw local gth = d.data.gbTileH if gth < 1 then gth = 1 elseif gth > 128 then gth = 128 end S.gbTileH = gth S.gbSilhouetteColor = d.data.silhouetteColor S.gbSilhouetteColorSet = true S.gbLayerName = d.data.layerName S.gbAlwaysOverwrite = d.data.alwaysOverwrite -- Preview layout settings S.previewLayout = d.data.previewLayout local plv = d.data.previewLayoutValue if plv < 1 then plv = 1 elseif plv > 20 then plv = 20 end S.previewLayoutValue = plv -- Check if preset changed via combobox local selectedPreset = d.data.presetSelect local presetChanged = false if selectedPreset and selectedPreset ~= S.currentPreset then savePreset(S.currentPreset) -- Save current first loadPreset(selectedPreset) -- Load new S.currentPreset = selectedPreset local currentFile = app.sprite and app.sprite.filename or "" if currentFile ~= "" then filePresetMap[currentFile] = selectedPreset end presetChanged = true end saveAll() if presetChanged then dlg:close() openMainDialog() -- Preview title will be stale but avoids repositioning window return end dlg:repaint() end end } ---------------------------------------------------------------- -- Animation selector (combobox, sorted alphabetically) ---------------------------------------------------------------- dlg:separator{ id = "sepAnims", text = "Animations" } dlg:combobox{ id = "cmbAnimList", option = currentAnimName() or "", options = S.animNames, onchange = function() local selected = dlg.data.cmbAnimList for i, name in ipairs(S.animNames) do if name == selected then S.currentAnim = i S.selectedFrame = 0 dlg:repaint() break end end end } dlg:button{ id = "btnNewAnim", text = "+", onclick = function() local d = Dialog{ title = "New Animation" } d:entry{ id = "name", label = "Name:", text = "" } d:button{ id = "ok", text = "OK" } d:button{ text = "Cancel" } d:show() if d.data.ok then local name = d.data.name if name and name ~= "" and not S.anims[name] then table.insert(S.animNames, name) S.anims[name] = {} -- Re-sort alphabetically table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) for i, n in ipairs(S.animNames) do if n == name then S.currentAnim = i; break end end saveAll() dlg:close() openMainDialog() end end end } dlg:button{ id = "btnDelAnim", text = "-", onclick = function() local name = currentAnimName() if not name then return end local result = app.alert{ title = "Delete Animation", text = "Delete animation '" .. name .. "'?", buttons = { "Delete", "Cancel" } } if result ~= 1 then return end S.anims[name] = nil table.remove(S.animNames, S.currentAnim) S.currentAnim = math.min(S.currentAnim, math.max(1, #S.animNames)) S.selectedFrame = 0 saveAll() dlg:close() openMainDialog() end } dlg:button{ id = "btnRenameAnim", text = "Rename", onclick = function() local name = currentAnimName() if not name then return end local d = Dialog{ title = "Rename Animation" } d:entry{ id = "newName", label = "New name:", text = name } d:button{ id = "ok", text = "OK" } d:button{ text = "Cancel" } d:show() if d.data.ok then local newName = d.data.newName if newName and newName ~= "" and newName ~= name and not S.anims[newName] then -- Transfer animation data S.anims[newName] = S.anims[name] S.anims[name] = nil S.animNames[S.currentAnim] = newName -- Re-sort alphabetically table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) for i, n in ipairs(S.animNames) do if n == newName then S.currentAnim = i; break end end saveAll() dlg:close() openMainDialog() end end end } dlg:button{ id = "btnCloneAnim", text = "Clone", onclick = function() local name = currentAnimName() if not name then return end local d = Dialog{ title = "Clone Animation" } d:entry{ id = "cloneName", label = "Name:", text = name .. "_clone" } d:check{ id = "flipX", text = "Flip X", selected = true } d:button{ id = "ok", text = "OK" } d:button{ text = "Cancel" } d:show() if d.data.ok then local cloneName = d.data.cloneName if cloneName and cloneName ~= "" and not S.anims[cloneName] then local srcFrames = S.anims[name] or {} local newFrames = {} for _, fr in ipairs(srcFrames) do local nf = { x = fr.x, y = fr.y, flipped = fr.flipped, flippedV = fr.flippedV, offX = fr.offX or 0, offY = fr.offY or 0 } if d.data.flipX then nf.flipped = not nf.flipped end table.insert(newFrames, nf) end table.insert(S.animNames, cloneName) S.anims[cloneName] = newFrames -- Re-sort alphabetically table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) for i, n in ipairs(S.animNames) do if n == cloneName then S.currentAnim = i; break end end saveAll() dlg:close() openMainDialog() end end end } ---------------------------------------------------------------- -- Source canvas (L-click=add, R-click=add flipped) ---------------------------------------------------------------- dlg:separator{ id = "sepSource", text = "Source (L-click=add, R-click=flip add) || Frames (L-click=select, R-click=remove, drag=move)" } dlg:canvas{ id = "canvasSource", width = viewW, height = viewH, autoscaling = false, onpaint = function(ev) local gc = ev.context local cW, cH = getSourceContentSize() local vw = math.min(cW, SOURCE_VIEWPORT_W) local vh = math.min(cH, SOURCE_VIEWPORT_H) drawCheckerboard(gc, vw, vh, S.sourceZoom) if S.sourceImage then local srcW = S.sourceImage.width local srcH = S.sourceImage.height gc:drawImage( S.sourceImage, Rectangle(0, 0, srcW, srcH), Rectangle(-S.scrollX, -S.scrollY, srcW * S.sourceZoom, srcH * S.sourceZoom) ) end -- Draw grid if S.sourceImage then local imgW = S.sourceImage.width local imgH = S.sourceImage.height gc.color = Color(255, 255, 255, 40) for col = 0, math.floor(imgW / S.tileW) do local lx = col * S.tileW * S.sourceZoom - S.scrollX if lx >= 0 and lx < vw then gc:fillRect(Rectangle(lx, 0, 1, vh)) end end for row = 0, math.floor(imgH / S.tileH) do local ly = row * S.tileH * S.sourceZoom - S.scrollY if ly >= 0 and ly < vh then gc:fillRect(Rectangle(0, ly, vw, 1)) end end -- Highlight tiles assigned to current animation local name = currentAnimName() if name and S.anims[name] then for _, fr in ipairs(S.anims[name]) do local rx = fr.x * S.sourceZoom - S.scrollX local ry = fr.y * S.sourceZoom - S.scrollY local rw = S.tileW * S.sourceZoom local rh = S.tileH * S.sourceZoom if rx + rw > 0 and rx < vw and ry + rh > 0 and ry < vh then gc.color = fr.flipped and Color(255, 160, 0, 160) or Color(100, 200, 255, 160) gc:fillRect(Rectangle(rx, ry, rw, 2)) gc:fillRect(Rectangle(rx, ry + rh - 2, rw, 2)) gc:fillRect(Rectangle(rx, ry, 2, rh)) gc:fillRect(Rectangle(rx + rw - 2, ry, 2, rh)) end end end end end, onmousedown = function(ev) if ev.button == MouseButton.LEFT then S.srcClickX = ev.x S.srcClickY = ev.y S.srcIsDrag = false S.dragging = true S.dragLastX = ev.x S.dragLastY = ev.y elseif ev.button == MouseButton.RIGHT then local pixelX = math.floor((ev.x + S.scrollX) / S.sourceZoom) local pixelY = math.floor((ev.y + S.scrollY) / S.sourceZoom) captureCell(pixelX, pixelY, true) end end, onmousemove = function(ev) if S.dragging then local dx = math.abs(ev.x - S.srcClickX) local dy = math.abs(ev.y - S.srcClickY) if dx > 3 or dy > 3 then S.srcIsDrag = true end if S.srcIsDrag then S.scrollX = S.scrollX + (S.dragLastX - ev.x) S.scrollY = S.scrollY + (S.dragLastY - ev.y) clampScroll() end S.dragLastX = ev.x S.dragLastY = ev.y end end, onmouseup = function(ev) if S.dragging and not S.srcIsDrag then local pixelX = math.floor((S.srcClickX + S.scrollX) / S.sourceZoom) local pixelY = math.floor((S.srcClickY + S.scrollY) / S.sourceZoom) captureCell(pixelX, pixelY, false) end S.dragging = false S.srcIsDrag = false end, onwheel = function(ev) local dz = ev.deltaY < 0 and 1 or -1 S.sourceZoom = math.max(1, math.min(10, S.sourceZoom + dz)) clampScroll() dlg:repaint() end } ---------------------------------------------------------------- -- Frame strip for current animation ---------------------------------------------------------------- local STRIP_TOTAL_W = SOURCE_VIEWPORT_W local STRIP_CELL = THUMB_SIZE + 1 local STRIP_H = STRIP_CELL * 2 + 4 dlg:canvas{ id = "canvasStrips", width = STRIP_TOTAL_W, height = STRIP_H, autoscaling = false, onpaint = function(ev) local gc = ev.context local name = currentAnimName() local frames = name and S.anims[name] or {} local cnt = #frames gc.color = Color(30, 30, 30) gc:fillRect(Rectangle(0, 0, STRIP_TOTAL_W, STRIP_H)) if cnt == 0 then gc.color = Color(60, 60, 60) gc:fillRect(Rectangle(4, 4, THUMB_SIZE, THUMB_SIZE)) return end local thumbsPerRow = math.max(1, math.floor(STRIP_TOTAL_W / STRIP_CELL)) for i = 1, cnt do local col = (i - 1) % thumbsPerRow local row = math.floor((i - 1) / thumbsPerRow) local tx = col * STRIP_CELL local ty = 2 + row * STRIP_CELL -- Checkerboard bg local cs = 2 for cy = 0, THUMB_SIZE - 1, cs do for cx = 0, THUMB_SIZE - 1, cs do local parity = (math.floor(cx / cs) + math.floor(cy / cs)) % 2 gc.color = (parity == 0) and Color(50, 50, 50) or Color(70, 70, 70) gc:fillRect(Rectangle(tx + cx, ty + cy, cs, cs)) end end local fimg = getFrameImage(name, i) if fimg then -- Pixel-perfect integer scaling local intScale = math.max(1, math.floor(math.min(THUMB_SIZE / S.tileW, THUMB_SIZE / S.tileH))) local dw = S.tileW * intScale local dh = S.tileH * intScale local dx = tx + math.floor((THUMB_SIZE - dw) / 2) local dy = ty + math.floor((THUMB_SIZE - dh) / 2) gc:drawImage( fimg, Rectangle(0, 0, S.tileW, S.tileH), Rectangle(dx, dy, dw, dh) ) end -- Flipped indicator (orange dot) if frames[i].flipped then gc.color = Color(255, 160, 0, 220) gc:fillRect(Rectangle(tx + THUMB_SIZE - 4, ty, 4, 4)) end -- Flip V indicator (green dot) if frames[i].flippedV then gc.color = Color(0, 200, 0, 220) gc:fillRect(Rectangle(tx, ty, 4, 4)) end -- Offset indicator (cyan dot, bottom-left) if (frames[i].offX or 0) ~= 0 or (frames[i].offY or 0) ~= 0 then gc.color = Color(0, 200, 255, 220) gc:fillRect(Rectangle(tx, ty + THUMB_SIZE - 4, 4, 4)) end -- Current playback frame indicator if cnt > 0 and ((S.animFrame % cnt)) == (i - 1) then gc.color = Color(100, 200, 255, 200) gc:fillRect(Rectangle(tx, ty - 1, THUMB_SIZE, 2)) end -- Selected frame highlight (yellow border) if S.selectedFrame == i then gc.color = Color(255, 255, 0, 220) gc:fillRect(Rectangle(tx, ty, THUMB_SIZE, 2)) gc:fillRect(Rectangle(tx, ty + THUMB_SIZE - 2, THUMB_SIZE, 2)) gc:fillRect(Rectangle(tx, ty, 2, THUMB_SIZE)) gc:fillRect(Rectangle(tx + THUMB_SIZE - 2, ty, 2, THUMB_SIZE)) end end -- Drag insertion indicator if S.frameDragging and S.frameDragTo >= 1 and S.frameDragFrom ~= S.frameDragTo then local insertIdx = S.frameDragTo local col = (insertIdx - 1) % thumbsPerRow local row = math.floor((insertIdx - 1) / thumbsPerRow) local lx = col * STRIP_CELL local ly = 2 + row * STRIP_CELL gc.color = Color(255, 100, 100, 220) gc:fillRect(Rectangle(lx - 1, ly, 2, THUMB_SIZE)) end end, onmousedown = function(ev) local name = currentAnimName() if not name then return end local frames = S.anims[name] if not frames or #frames == 0 then return end local thumbsPerRow = math.max(1, math.floor(STRIP_TOTAL_W / STRIP_CELL)) local col = math.floor(ev.x / STRIP_CELL) local row = math.floor((ev.y - 2) / STRIP_CELL) local idx = row * thumbsPerRow + col + 1 if idx >= 1 and idx <= #frames then if ev.button == MouseButton.LEFT then -- Click on already-selected frame: deselect if S.selectedFrame == idx then S.selectedFrame = 0 dlg:repaint() return -- don't start drag end S.selectedFrame = idx S.frameDragging = true S.frameDragFrom = idx S.frameDragTo = idx dlg:repaint() elseif ev.button == MouseButton.RIGHT then table.remove(frames, idx) if S.selectedFrame > #frames then S.selectedFrame = #frames end if #frames == 0 then S.selectedFrame = 0 end saveAll() dlg:repaint() end end end, onmousemove = function(ev) if S.frameDragging then local name = currentAnimName() local frames = name and S.anims[name] or {} local thumbsPerRow = math.max(1, math.floor(STRIP_TOTAL_W / STRIP_CELL)) local col = math.floor(ev.x / STRIP_CELL) local row = math.floor((ev.y - 2) / STRIP_CELL) local idx = row * thumbsPerRow + col + 1 S.frameDragTo = clamp(idx, 1, #frames) dlg:repaint() end end, onmouseup = function(ev) if S.frameDragging then local from = S.frameDragFrom local to = S.frameDragTo if from ~= to then local name = currentAnimName() if name and S.anims[name] then local frames = S.anims[name] local frame = table.remove(frames, from) table.insert(frames, to, frame) S.selectedFrame = to saveAll() end end S.frameDragging = false dlg:repaint() end end, onwheel = function(ev) local dz = ev.deltaY < 0 and 1 or -1 S.sourceZoom = math.max(1, math.min(10, S.sourceZoom + dz)) clampScroll() dlg:repaint() end } ---------------------------------------------------------------- -- Frame action buttons (no separator - combined into sepSource) ---------------------------------------------------------------- dlg:button{ id = "btnFlipX", text = "FlipX", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame > #frames then return end frames[S.selectedFrame].flipped = not frames[S.selectedFrame].flipped saveAll() dlg:repaint() end } dlg:button{ id = "btnFlipY", text = "FlipY", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame > #frames then return end frames[S.selectedFrame].flippedV = not frames[S.selectedFrame].flippedV saveAll() dlg:repaint() end } dlg:button{ id = "btnMoveLeft", text = "<-", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 2 then return end local frames = S.anims[name] if not frames then return end local idx = S.selectedFrame frames[idx], frames[idx - 1] = frames[idx - 1], frames[idx] S.selectedFrame = idx - 1 saveAll() dlg:repaint() end } dlg:button{ id = "btnMoveRight", text = "->", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame >= #frames then return end local idx = S.selectedFrame frames[idx], frames[idx + 1] = frames[idx + 1], frames[idx] S.selectedFrame = idx + 1 saveAll() dlg:repaint() end } dlg:button{ id = "btnRemoveFrame", text = "Del", onclick = function() local name = currentAnimName() if not name then return end local frames = S.anims[name] if not frames then return end if #frames == 0 then -- No frames: delete the animation (like Remove Animation) local result = app.alert{ title = "Delete Animation", text = "Animation '" .. name .. "' has no frames. Delete it?", buttons = { "Delete", "Cancel" } } if result ~= 1 then return end S.anims[name] = nil table.remove(S.animNames, S.currentAnim) S.currentAnim = math.min(S.currentAnim, math.max(1, #S.animNames)) S.selectedFrame = 0 saveAll() dlg:close() openMainDialog() return end local idx = S.selectedFrame if idx < 1 then -- No frame selected: remove the last frame idx = #frames end if idx > #frames then return end table.remove(frames, idx) if S.selectedFrame > #frames then S.selectedFrame = #frames end if #frames == 0 then S.selectedFrame = 0 end saveAll() dlg:repaint() end } ---------------------------------------------------------------- -- Frame offset buttons ---------------------------------------------------------------- dlg:newrow() dlg:button{ id = "btnFrameUp", text = "Up", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame > #frames then return end frames[S.selectedFrame].offY = (frames[S.selectedFrame].offY or 0) - 1 saveAll() dlg:repaint() end } dlg:button{ id = "btnFrameDown", text = "Down", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame > #frames then return end frames[S.selectedFrame].offY = (frames[S.selectedFrame].offY or 0) + 1 saveAll() dlg:repaint() end } dlg:button{ id = "btnFrameLeft", text = "Left", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame > #frames then return end frames[S.selectedFrame].offX = (frames[S.selectedFrame].offX or 0) - 1 saveAll() dlg:repaint() end } dlg:button{ id = "btnFrameRight", text = "Right", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame > #frames then return end frames[S.selectedFrame].offX = (frames[S.selectedFrame].offX or 0) + 1 saveAll() dlg:repaint() end } dlg:button{ id = "btnFrameReset", text = "Reset", onclick = function() local name = currentAnimName() if not name or S.selectedFrame < 1 then return end local frames = S.anims[name] if not frames or S.selectedFrame > #frames then return end frames[S.selectedFrame].offX = 0 frames[S.selectedFrame].offY = 0 saveAll() dlg:repaint() end } ---------------------------------------------------------------- -- OPTIMIZE TAB ---------------------------------------------------------------- dlg:separator{ id = "sepGb", text = "Tile Optimizer", visible = false } -- Row: Flip + Offset + Silhouette + Compress (reordered) dlg:check{ id = "gbFlipOpt", text = "Flip", selected = S.gbFlipOpt, visible = false, onclick = function() S.gbFlipOpt = dlg.data.gbFlipOpt end } dlg:check{ id = "gbOffsetOpt", text = "Offset", selected = S.gbOffsetOpt, visible = false, onclick = function() S.gbOffsetOpt = dlg.data.gbOffsetOpt end } dlg:check{ id = "gbSilhouette", text = "Silhouette", selected = S.gbSilhouette, visible = false, onclick = function() S.gbSilhouette = dlg.data.gbSilhouette end } dlg:check{ id = "gbCompress", text = "Compress", selected = S.gbCompress, visible = false, onclick = function() S.gbCompress = dlg.data.gbCompress end } dlg:combobox{ id = "gbAnalyzeMode", option = S.gbAnalyzeMode, options = {"pixel", "tile"}, visible = false, onchange = function() S.gbAnalyzeMode = dlg.data.gbAnalyzeMode end } -- Row: Analyze + Find Similars + Threshold dlg:button{ id = "btnAnalyze", text = "Analyze", visible = false, onclick = function() if not S.sourceImage then return end local usedTileW = S.gbTileW local usedTileH = S.gbTileH if S.gbAnalyzeMode == "tile" then local rtw, rth S.gbTiles, S.gbTotalTiles, rtw, rth = analyzeTilesTileMode(app.sprite, app.frame.frameNumber) if rtw then usedTileW = rtw end if rth then usedTileH = rth end else S.gbTiles, S.gbTotalTiles = analyzeTiles(S.sourceImage, S.gbFlipOpt, S.gbOffsetOpt, usedTileW, usedTileH) end S.gbTileW = usedTileW S.gbTileH = usedTileH S.gbOptImage, S.gbCols = buildOptimizedImage( S.sourceImage, S.gbTiles, S.gbCompress, S.gbSilhouette, S.gbSilhouetteColor, usedTileW, usedTileH) S.gbSelectedTile = 0 S.gbScrollX = 0 S.gbScrollY = 0 local unique = #S.gbTiles local total = S.gbTotalTiles dlg:modify{ id = "btnAnalyze", text = "Analyze (" .. unique .. "/" .. total .. ")" } if S.gbOptImage then local z = S.gbZoomOpt dlg:modify{ id = "canvasGbOpt", width = math.min(S.gbOptImage.width * z, SOURCE_VIEWPORT_W), height = math.min(S.gbOptImage.height * z, GB_OPT_H) } end saveAll() dlg:repaint() end } dlg:button{ id = "btnFindSimilar", text = "Find similars", visible = false, onclick = function() if #S.gbTiles == 0 then app.alert("Run Analyze first.") return end S.gbSimilarPairs = findSimilarTiles( S.sourceImage, S.gbTiles, S.gbSimilarThreshold, S.gbFlipOpt, S.gbOffsetOpt, S.gbTileW, S.gbTileH) dlg:modify{ id = "btnFindSimilar", text = "Find similars (" .. #S.gbSimilarPairs .. ")" } dlg:repaint() end } dlg:button{ id = "gbSimilarThreshold", text = "Threshold " .. S.gbSimilarThreshold .. "%", visible = false, onclick = function() local d = Dialog{ title = "Similarity Threshold" } d:number{ id = "val", label = "% of painted pixels that must match (1-100):", text = tostring(S.gbSimilarThreshold), decimals = 0 } d:button{ id = "ok", text = "OK" } d:button{ text = "Cancel" } d:show() if d.data.ok then local v = d.data.val if v < 1 then v = 1 end if v > 100 then v = 100 end S.gbSimilarThreshold = v dlg:modify{ id = "gbSimilarThreshold", text = "Threshold " .. v .. "%" } end end } ---------------------------------------------------------------- -- Occurrences in Source canvas (BEFORE Optimized) ---------------------------------------------------------------- dlg:separator{ id = "sepGbSource", text = "Source || Optimized (L-click=check occurrences, R-click=deselect)", visible = false } local GB_SRC_W = SOURCE_VIEWPORT_W -- Flip colors local FLIP_COLORS = { none = Color(255, 0, 0, 180), h = Color(255, 160, 0, 180), v = Color(0, 200, 0, 180), hv = Color(255, 255, 0, 180), } dlg:canvas{ id = "canvasGbSource", width = GB_SRC_W, height = GB_SRC_H, autoscaling = false, visible = false, onpaint = function(ev) local gc = ev.context local tw = S.gbTileW local th = S.gbTileH gc.color = Color(30, 30, 30) gc:fillRect(Rectangle(0, 0, GB_SRC_W, GB_SRC_H)) if not S.sourceImage then return end local z = S.gbZoomSrc local srcW = S.sourceImage.width local srcH = S.sourceImage.height drawCheckerboard(gc, math.min(srcW * z, GB_SRC_W), math.min(srcH * z, GB_SRC_H), z) gc:drawImage( S.sourceImage, Rectangle(0, 0, srcW, srcH), Rectangle(-S.gbSrcScrollX, -S.gbSrcScrollY, srcW * z, srcH * z) ) -- Draw tile grid gc.color = Color(255, 255, 255, 20) for col = 0, math.floor(srcW / tw) do local lx = col * tw * z - S.gbSrcScrollX if lx >= 0 and lx < GB_SRC_W then gc:fillRect(Rectangle(lx, 0, 1, math.min(srcH * z, GB_SRC_H))) end end for row = 0, math.floor(srcH / th) do local ly = row * th * z - S.gbSrcScrollY if ly >= 0 and ly < GB_SRC_H then gc:fillRect(Rectangle(0, ly, math.min(srcW * z, GB_SRC_W), 1)) end end -- Highlight all occurrences of selected tile if S.gbSelectedTile > 0 and S.gbSelectedTile <= #S.gbTiles then local tile = S.gbTiles[S.gbSelectedTile] for _, pos in ipairs(tile.positions) do local rx = pos.x * z - S.gbSrcScrollX local ry = pos.y * z - S.gbSrcScrollY local rw = tw * z local rh = th * z if rx + rw > 0 and rx < GB_SRC_W and ry + rh > 0 and ry < GB_SRC_H then local c = FLIP_COLORS[pos.flip] or FLIP_COLORS.none gc.color = Color(c.red, c.green, c.blue, 60) gc:fillRect(Rectangle(rx, ry, rw, rh)) gc.color = c gc:fillRect(Rectangle(rx, ry, rw, 2)) gc:fillRect(Rectangle(rx, ry + rh - 2, rw, 2)) gc:fillRect(Rectangle(rx, ry, 2, rh)) gc:fillRect(Rectangle(rx + rw - 2, ry, 2, rh)) end end end end, onmousedown = function(ev) if ev.button == MouseButton.LEFT then S.gbSrcClickX = ev.x S.gbSrcClickY = ev.y S.gbSrcIsDrag = false S.gbSrcDragging = true S.gbSrcDragLastX = ev.x S.gbSrcDragLastY = ev.y elseif ev.button == MouseButton.RIGHT then -- R-click: deselect if S.gbSelectedTile > 0 then S.gbSelectedTile = 0 dlg:repaint() end end end, onmousemove = function(ev) if S.gbSrcDragging then local dx = math.abs(ev.x - S.gbSrcClickX) local dy = math.abs(ev.y - S.gbSrcClickY) if dx > 3 or dy > 3 then S.gbSrcIsDrag = true end if S.gbSrcIsDrag then S.gbSrcScrollX = S.gbSrcScrollX + (S.gbSrcDragLastX - ev.x) S.gbSrcScrollY = S.gbSrcScrollY + (S.gbSrcDragLastY - ev.y) S.gbSrcScrollX = math.max(0, S.gbSrcScrollX) S.gbSrcScrollY = math.max(0, S.gbSrcScrollY) end S.gbSrcDragLastX = ev.x S.gbSrcDragLastY = ev.y end end, onmouseup = function(ev) if S.gbSrcDragging and not S.gbSrcIsDrag then -- Click: select tile at this position local z = S.gbZoomSrc local tw = S.gbTileW local th = S.gbTileH local pixelX = math.floor((S.gbSrcClickX + S.gbSrcScrollX) / z) local pixelY = math.floor((S.gbSrcClickY + S.gbSrcScrollY) / z) -- Find all tiles whose positions cover this pixel local candidates = {} for i, tile in ipairs(S.gbTiles) do for _, pos in ipairs(tile.positions) do if pixelX >= pos.x and pixelX < pos.x + tw and pixelY >= pos.y and pixelY < pos.y + th then -- Check if pixel is non-transparent at this position local isOpaque = false if S.sourceImage and pixelX >= 0 and pixelX < S.sourceImage.width and pixelY >= 0 and pixelY < S.sourceImage.height then isOpaque = pc.rgbaA(S.sourceImage:getPixel(pixelX, pixelY)) > 0 end table.insert(candidates, { tileIdx = i, opaque = isOpaque }) end end end if #candidates > 0 then -- Sort: opaque tiles first table.sort(candidates, function(a, b) if a.opaque ~= b.opaque then return a.opaque end return a.tileIdx < b.tileIdx end) -- Cycle through candidates if current selection is among them local found = false for ci, cand in ipairs(candidates) do if cand.tileIdx == S.gbSelectedTile then -- Select next candidate local nextIdx = (ci % #candidates) + 1 S.gbSelectedTile = candidates[nextIdx].tileIdx found = true break end end if not found then S.gbSelectedTile = candidates[1].tileIdx end end end S.gbSrcDragging = false dlg:repaint() end, onwheel = function(ev) local dz = ev.deltaY < 0 and 1 or -1 local newZoom = math.max(1, math.min(10, S.gbZoomSrc + dz)) S.gbZoomSrc = newZoom S.gbZoomOpt = newZoom dlg:repaint() end } ---------------------------------------------------------------- -- Optimized Spritesheet canvas (L-click=select, R-click=deselect) ---------------------------------------------------------------- local GB_OPT_W = SOURCE_VIEWPORT_W dlg:canvas{ id = "canvasGbOpt", width = GB_OPT_W, height = GB_OPT_H, autoscaling = false, visible = false, onpaint = function(ev) local gc = ev.context local z = S.gbZoomOpt local tw = S.gbTileW local th = S.gbTileH gc.color = Color(30, 30, 30) gc:fillRect(Rectangle(0, 0, GB_OPT_W, GB_OPT_H)) if not S.gbOptImage then return end local imgW = S.gbOptImage.width local imgH = S.gbOptImage.height drawCheckerboard(gc, math.min(imgW * z, GB_OPT_W), math.min(imgH * z, GB_OPT_H), z) gc:drawImage( S.gbOptImage, Rectangle(0, 0, imgW, imgH), Rectangle(-S.gbScrollX, -S.gbScrollY, imgW * z, imgH * z) ) -- Draw tile grid gc.color = Color(255, 255, 255, 30) for col = 0, math.floor(imgW / tw) do local lx = col * tw * z - S.gbScrollX if lx >= 0 and lx < GB_OPT_W then gc:fillRect(Rectangle(lx, 0, 1, math.min(imgH * z, GB_OPT_H))) end end for row = 0, math.floor(imgH / th) do local ly = row * th * z - S.gbScrollY if ly >= 0 and ly < GB_OPT_H then gc:fillRect(Rectangle(0, ly, math.min(imgW * z, GB_OPT_W), 1)) end end -- Helper: get pixel position of tile i in optimized image local function tilePos(i) if S.gbCompress then local c = (i - 1) % S.gbCols local r = math.floor((i - 1) / S.gbCols) return c * tw, r * th else local pos = S.gbTiles[i].positions[1] return pos.x, pos.y end end -- Highlight tiles involved in similar pairs with distinct group colors if #S.gbSimilarPairs > 0 then local parent = {} local function find(x) if not parent[x] then parent[x] = x end while parent[x] ~= x do parent[x] = parent[parent[x]] x = parent[x] end return x end local function union(a, b) local ra, rb = find(a), find(b) if ra ~= rb then parent[ra] = rb end end for _, pair in ipairs(S.gbSimilarPairs) do union(pair.i, pair.j) end local groupColors = {} local colorIdx = 0 local GROUP_HUES = { Color(255, 0, 255, 150), Color(0, 200, 255, 150), Color(255, 128, 0, 150), Color(0, 255, 128, 150), Color(255, 255, 0, 150), Color(128, 0, 255, 150), Color(255, 0, 128, 150), Color(0, 255, 0, 150), Color(0, 128, 255, 150), Color(255, 64, 64, 150), Color(128, 255, 0, 150), Color(255, 0, 64, 150), Color(0, 255, 255, 150), Color(200, 100, 255, 150), Color(255, 200, 0, 150), Color(100, 255, 200, 150), } local similarSet = {} for _, pair in ipairs(S.gbSimilarPairs) do local root = find(pair.i) if not groupColors[root] then colorIdx = colorIdx + 1 groupColors[root] = GROUP_HUES[((colorIdx - 1) % #GROUP_HUES) + 1] end similarSet[pair.i] = groupColors[find(pair.i)] similarSet[pair.j] = groupColors[find(pair.j)] end local rw = tw * z local rh = th * z for idx, c in pairs(similarSet) do local ttx, tty = tilePos(idx) local srx = ttx * z - S.gbScrollX local sry = tty * z - S.gbScrollY gc.color = Color(c.red, c.green, c.blue, 40) gc:fillRect(Rectangle(srx, sry, rw, rh)) gc.color = c gc:fillRect(Rectangle(srx, sry, rw, 1)) gc:fillRect(Rectangle(srx, sry + rh - 1, rw, 1)) gc:fillRect(Rectangle(srx, sry, 1, rh)) gc:fillRect(Rectangle(srx + rw - 1, sry, 1, rh)) end if S.gbSelectedTile > 0 then for _, pair in ipairs(S.gbSimilarPairs) do local partner = nil if pair.i == S.gbSelectedTile then partner = pair.j end if pair.j == S.gbSelectedTile then partner = pair.i end if partner then local ptx, pty = tilePos(partner) local prx = ptx * z - S.gbScrollX local pry = pty * z - S.gbScrollY gc.color = Color(255, 255, 0, 80) gc:fillRect(Rectangle(prx, pry, rw, rh)) gc.color = Color(255, 255, 0, 220) gc:fillRect(Rectangle(prx, pry, rw, 2)) gc:fillRect(Rectangle(prx, pry + rh - 2, rw, 2)) gc:fillRect(Rectangle(prx, pry, 2, rh)) gc:fillRect(Rectangle(prx + rw - 2, pry, 2, rh)) end end end end -- Highlight selected tile if S.gbSelectedTile > 0 and S.gbSelectedTile <= #S.gbTiles then local ttx, tty = tilePos(S.gbSelectedTile) local rx = ttx * z - S.gbScrollX local ry = tty * z - S.gbScrollY local rw = tw * z local rh = th * z gc.color = Color(255, 0, 0, 220) gc:fillRect(Rectangle(rx, ry, rw, 2)) gc:fillRect(Rectangle(rx, ry + rh - 2, rw, 2)) gc:fillRect(Rectangle(rx, ry, 2, rh)) gc:fillRect(Rectangle(rx + rw - 2, ry, 2, rh)) end end, onmousedown = function(ev) if ev.button == MouseButton.LEFT then -- Click vs drag detection S.gbOptClickX = ev.x S.gbOptClickY = ev.y S.gbOptIsDrag = false S.gbDragging = true S.gbDragLastX = ev.x S.gbDragLastY = ev.y elseif ev.button == MouseButton.RIGHT then -- R-click: deselect if S.gbSelectedTile > 0 then S.gbSelectedTile = 0 dlg:repaint() end end end, onmousemove = function(ev) if S.gbDragging then local dx = math.abs(ev.x - S.gbOptClickX) local dy = math.abs(ev.y - S.gbOptClickY) if dx > 3 or dy > 3 then S.gbOptIsDrag = true end if S.gbOptIsDrag then S.gbScrollX = S.gbScrollX + (S.gbDragLastX - ev.x) S.gbScrollY = S.gbScrollY + (S.gbDragLastY - ev.y) S.gbScrollX = math.max(0, S.gbScrollX) S.gbScrollY = math.max(0, S.gbScrollY) end S.gbDragLastX = ev.x S.gbDragLastY = ev.y end end, onmouseup = function(ev) if S.gbDragging and not S.gbOptIsDrag then -- Click: select tile using candidate-based logic if not S.gbOptImage then S.gbDragging = false return end local z = S.gbZoomOpt local tw = S.gbTileW local th = S.gbTileH local pixelX = math.floor((S.gbOptClickX + S.gbScrollX) / z) local pixelY = math.floor((S.gbOptClickY + S.gbScrollY) / z) -- Find all tiles whose positions cover the clicked pixel local candidates = {} if S.gbCompress then -- In compress mode, tiles are laid out in a grid local col = math.floor(pixelX / tw) local row = math.floor(pixelY / th) local idx = row * S.gbCols + col + 1 if idx >= 1 and idx <= #S.gbTiles then local isOpaque = false if pixelX >= 0 and pixelX < S.gbOptImage.width and pixelY >= 0 and pixelY < S.gbOptImage.height then isOpaque = pc.rgbaA(S.gbOptImage:getPixel(pixelX, pixelY)) > 0 end table.insert(candidates, { tileIdx = idx, opaque = isOpaque }) end else -- Non-compress mode: candidate-based click like Occurrences canvas for i, tile in ipairs(S.gbTiles) do local pos = tile.positions[1] if pixelX >= pos.x and pixelX < pos.x + tw and pixelY >= pos.y and pixelY < pos.y + th then local isOpaque = false if S.gbOptImage and pixelX >= 0 and pixelX < S.gbOptImage.width and pixelY >= 0 and pixelY < S.gbOptImage.height then isOpaque = pc.rgbaA(S.gbOptImage:getPixel(pixelX, pixelY)) > 0 end table.insert(candidates, { tileIdx = i, opaque = isOpaque }) end end -- Also check silhouette positions (duplicate tile positions rendered as silhouettes) if S.gbSilhouette then for i, tile in ipairs(S.gbTiles) do for pi = 2, #tile.positions do local pos = tile.positions[pi] if pixelX >= pos.x and pixelX < pos.x + tw and pixelY >= pos.y and pixelY < pos.y + th then local isOpaque = false if S.gbOptImage and pixelX >= 0 and pixelX < S.gbOptImage.width and pixelY >= 0 and pixelY < S.gbOptImage.height then isOpaque = pc.rgbaA(S.gbOptImage:getPixel(pixelX, pixelY)) > 0 end -- Check if not already in candidates local alreadyIn = false for _, c in ipairs(candidates) do if c.tileIdx == i then alreadyIn = true; break end end if not alreadyIn then table.insert(candidates, { tileIdx = i, opaque = isOpaque }) end end end end end end if #candidates > 0 then -- Sort: opaque tiles first table.sort(candidates, function(a, b) if a.opaque ~= b.opaque then return a.opaque end return a.tileIdx < b.tileIdx end) -- Cycle through candidates if current selection is among them local found = false for ci, cand in ipairs(candidates) do if cand.tileIdx == S.gbSelectedTile then local nextIdx = (ci % #candidates) + 1 S.gbSelectedTile = candidates[nextIdx].tileIdx found = true break end end if not found then S.gbSelectedTile = candidates[1].tileIdx end end end S.gbDragging = false dlg:repaint() end, onwheel = function(ev) local dz = ev.deltaY < 0 and 1 or -1 local newZoom = math.max(1, math.min(10, S.gbZoomOpt + dz)) S.gbZoomOpt = newZoom S.gbZoomSrc = newZoom dlg:repaint() end } -- Save as Layer button dlg:button{ id = "btnGbSaveLayer", text = "Save as Layer", visible = false, onclick = function() if not S.gbOptImage then app.alert("Run Analyze first.") return end local sp = app.sprite if not sp then return end local layerName = S.gbLayerName if not layerName or layerName == "" then layerName = "auto-optimized-tiles" end local existingLayer = nil for i, layer in ipairs(sp.layers) do if layer.name == layerName then existingLayer = layer break end end local imgConverted = Image(S.gbOptImage.width, S.gbOptImage.height, sp.colorMode) imgConverted:clear() imgConverted:drawImage(S.gbOptImage, Point(0, 0)) app.transaction("Save Optimized Layer", function() if existingLayer then if S.gbAlwaysOverwrite then existingLayer.isEditable = true for _, cel in ipairs(existingLayer.cels) do sp:deleteCel(cel) end sp:newCel(existingLayer, app.frame, imgConverted, Point(0, 0)) existingLayer.isEditable = false else local suffix = 2 local newName = layerName .. "-" .. suffix local nameExists = true while nameExists do nameExists = false for _, layer in ipairs(sp.layers) do if layer.name == newName then nameExists = true suffix = suffix + 1 newName = layerName .. "-" .. suffix break end end end local newLayer = sp:newLayer() newLayer.name = newName sp:newCel(newLayer, app.frame, imgConverted, Point(0, 0)) newLayer.isEditable = false end else local newLayer = sp:newLayer() newLayer.name = layerName sp:newCel(newLayer, app.frame, imgConverted, Point(0, 0)) newLayer.isEditable = false end end) end } -- Copy to clipboard button (preserves transparency) dlg:button{ id = "btnGbCopyClipboard", text = "Copy to Clipboard", visible = false, onclick = function() if not S.gbOptImage then app.alert("Run Analyze first.") return end local optW = S.gbOptImage.width local optH = S.gbOptImage.height local tmpSprite = Sprite(optW, optH, ColorMode.RGB) app.command.LayerFromBackground() local layer = tmpSprite.layers[1] for _, c in ipairs(layer.cels) do tmpSprite:deleteCel(c) end tmpSprite:newCel(layer, 1, S.gbOptImage:clone(), Point(0, 0)) app.command.MaskAll() app.command.Copy() tmpSprite:close() end } -- Save PNG button dlg:button{ id = "btnGbSave", text = "Save PNG", visible = false, onclick = function() if not S.gbOptImage then app.alert("Run Analyze first.") return end local defaultFile = "tileset_optimized.png" if S.gbLastSavePath ~= "" then defaultFile = S.gbLastSavePath end local saveDlg = Dialog{ title = "Save Optimized Tileset" } saveDlg:file{ id = "path", save = true, filename = defaultFile, filetypes = { "png" }, } saveDlg:button{ id = "ok", text = "Save" } saveDlg:button{ text = "Cancel" } saveDlg:show() if not saveDlg.data.ok then return end local path = saveDlg.data.path if not path or path == "" then return end S.gbLastSavePath = path saveAll() local optW = S.gbOptImage.width local optH = S.gbOptImage.height local tmpSprite = Sprite(optW, optH, ColorMode.RGB) local cel = tmpSprite.cels[1] cel.image:drawImage(S.gbOptImage) tmpSprite:saveCopyAs(path) tmpSprite:close() app.alert("Saved: " .. path) end } ---------------------------------------------------------------- -- Timers for main dialog ---------------------------------------------------------------- mainAnimTimer = Timer{ interval = S.animSpeed / 1000.0, ontick = function() S.animFrame = S.animFrame + 1 pcall(function() dlg:repaint() end) end } mainAnimTimer:start() local gbRefreshCounter = 0 mainRefreshTimer = Timer{ interval = 0.5, ontick = function() local isDragging = S.dragging or S.gbDragging or S.gbSrcDragging if S.currentTab == "Optimize" and not isDragging then gbRefreshCounter = gbRefreshCounter + 1 if gbRefreshCounter >= 4 then gbRefreshCounter = 0 refreshSource() end pcall(function() dlg:repaint() end) else gbRefreshCounter = 0 if not isDragging then refreshSource() end pcall(function() dlg:repaint() end) end -- Auto-detect file change (also in main dialog timer) local currentFile = app.sprite and app.sprite.filename or "" if currentFile ~= lastSpriteFilename then lastSpriteFilename = currentFile local presetForFile = filePresetMap[currentFile] or "Default" if presetForFile ~= S.currentPreset then loadPreset(presetForFile) S.currentPreset = presetForFile pcall(function() dlg:close() end) openMainDialog() end end end } mainRefreshTimer:start() startAnimTimer() switchTab(S.currentTab) dlg:show{ wait = false } end ---------------------------------------------------------------------- -- Entry point ---------------------------------------------------------------------- local function run() if not app.sprite then app.alert("No sprite is open.") return end -- Check if already running via lock file local lockF = io.open(LOCK_FILE, "r") if lockF then lockF:close() app.alert("AniPhallow is already running.") return end -- Create lock file local lf = io.open(LOCK_FILE, "w") if lf then lf:write("running"); lf:close() end S.currentTab = "Animations" refreshSource() -- Always open preview window on launch openPreviewWindow() end run()