aniphallow/aniphallow.lua

3302 lines
128 KiB
Lua

----------------------------------------------------------------------
-- 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
if mainDlg then
pcall(function() mainDlg:close() end)
openMainDialog()
end
-- Reopen preview to update title
pcall(function() previewDlg:close() end)
openPreviewWindow()
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()
-- Reopen preview to update title
if previewDlg then
pcall(function() previewDlg:close() end)
openPreviewWindow()
end
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()
-- Reopen preview to update title
if previewDlg then
pcall(function() previewDlg:close() end)
openPreviewWindow()
end
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()
-- Reopen preview to update title
if previewDlg then
pcall(function() previewDlg:close() end)
openPreviewWindow()
end
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()
-- Reopen preview to update title
if previewDlg then
pcall(function() previewDlg:close() end)
openPreviewWindow()
end
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()
-- Reopen preview to update title
if previewDlg then
pcall(function() previewDlg:close() end)
openPreviewWindow()
end
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()