3460 lines
135 KiB
Lua
3460 lines
135 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,
|
|
previewZoomSta = 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", "single", or "static"
|
|
previewSingleIdx = 1, -- which animation to show in single mode
|
|
-- Per-mode preview window size (0 = auto/not set yet)
|
|
pvWinWAll = 0, pvWinHAll = 0,
|
|
pvWinWOne = 0, pvWinHOne = 0,
|
|
pvWinWSta = 0, pvWinHSta = 0,
|
|
-- Static preview scroll/pan
|
|
previewStaScrollX = 0,
|
|
previewStaScrollY = 0,
|
|
previewStaDragging = false,
|
|
previewStaDragLastX = 0,
|
|
previewStaDragLastY = 0,
|
|
-- 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")
|
|
f:write("pvWinWAll=" .. S.pvWinWAll .. "\n")
|
|
f:write("pvWinHAll=" .. S.pvWinHAll .. "\n")
|
|
f:write("pvWinWOne=" .. S.pvWinWOne .. "\n")
|
|
f:write("pvWinHOne=" .. S.pvWinHOne .. "\n")
|
|
f:write("pvWinWSta=" .. S.pvWinWSta .. "\n")
|
|
f:write("pvWinHSta=" .. S.pvWinHSta .. "\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
|
|
if k == "pvWinWAll" then S.pvWinWAll = tonumber(v) or 0 end
|
|
if k == "pvWinHAll" then S.pvWinHAll = tonumber(v) or 0 end
|
|
if k == "pvWinWOne" then S.pvWinWOne = tonumber(v) or 0 end
|
|
if k == "pvWinHOne" then S.pvWinHOne = tonumber(v) or 0 end
|
|
if k == "pvWinWSta" then S.pvWinWSta = tonumber(v) or 0 end
|
|
if k == "pvWinHSta" then S.pvWinHSta = tonumber(v) or 0 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("previewZoomSta=" .. S.previewZoomSta .. "\n")
|
|
f:write("previewStaScrollX=" .. S.previewStaScrollX .. "\n")
|
|
f:write("previewStaScrollY=" .. S.previewStaScrollY .. "\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 == "previewZoomSta" then S.previewZoomSta = tonumber(v) or DEFAULT_PREVIEW_ZOOM end
|
|
if k == "previewStaScrollX" then S.previewStaScrollX = tonumber(v) or 0 end
|
|
if k == "previewStaScrollY" then S.previewStaScrollY = tonumber(v) or 0 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
|
|
----------------------------------------------------------------------
|
|
-- Zoom levels: fractional for large tilesets, integer for normal
|
|
local ZOOM_LEVELS = { 0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }
|
|
|
|
local function zoomStep(currentZoom, delta)
|
|
-- Find closest level
|
|
local bestIdx = 1
|
|
local bestDist = math.huge
|
|
for i, z in ipairs(ZOOM_LEVELS) do
|
|
local dist = math.abs(z - currentZoom)
|
|
if dist < bestDist then bestDist = dist; bestIdx = i end
|
|
end
|
|
local newIdx = bestIdx + delta
|
|
if newIdx < 1 then newIdx = 1 end
|
|
if newIdx > #ZOOM_LEVELS then newIdx = #ZOOM_LEVELS end
|
|
return ZOOM_LEVELS[newIdx]
|
|
end
|
|
|
|
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
|
|
if S.previewMode == "static" then return S.previewZoomSta 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
|
|
|
|
local function refreshLockFile()
|
|
local lf = io.open(LOCK_FILE, "w")
|
|
if lf then lf:write(tostring(os.time())); lf:close() end
|
|
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: compute "natural" content size for Fit (All/One only)
|
|
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
|
|
else
|
|
local numAnims = #S.animNames
|
|
local cols, rows = getPreviewGrid(numAnims)
|
|
return cols * cellW, rows * cellH
|
|
end
|
|
end
|
|
|
|
-- Save current window bounds into S for the given mode
|
|
local function saveBoundsForMode(mode)
|
|
if not previewDlg then return end
|
|
local ok, b = pcall(function() return previewDlg.bounds end)
|
|
if not ok or not b then return end
|
|
if mode == "all" then S.pvWinWAll = b.width; S.pvWinHAll = b.height
|
|
elseif mode == "single" then S.pvWinWOne = b.width; S.pvWinHOne = b.height
|
|
elseif mode == "static" then S.pvWinWSta = b.width; S.pvWinHSta = b.height
|
|
end
|
|
end
|
|
|
|
-- Get saved size for current mode, or fallback to calcPreviewSize
|
|
local function getWinSize()
|
|
local w, h = 0, 0
|
|
if S.previewMode == "all" then w = S.pvWinWAll; h = S.pvWinHAll
|
|
elseif S.previewMode == "single" then w = S.pvWinWOne; h = S.pvWinHOne
|
|
elseif S.previewMode == "static" then w = S.pvWinWSta; h = S.pvWinHSta
|
|
end
|
|
if w > 0 and h > 0 then return w, h end
|
|
-- No saved size: use content fit (Sta defaults to 200x200)
|
|
if S.previewMode == "static" then return 200, 200 end
|
|
local cw, ch = calcPreviewSize()
|
|
return math.max(cw, 120), math.max(ch, 40)
|
|
end
|
|
|
|
-- Calculate initial canvas size from saved window size
|
|
local pvWidth, pvHeight = getWinSize()
|
|
-- Canvas is smaller than window (buttons/decorations), but we approximate
|
|
pvWidth = math.max(pvWidth, 120)
|
|
pvHeight = math.max(pvHeight, 40)
|
|
|
|
previewDlg = Dialog{
|
|
title = "AniPhallow (" .. S.currentPreset .. ")",
|
|
onclose = function()
|
|
saveBoundsForMode(S.previewMode)
|
|
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
|
|
}
|
|
|
|
-- Buttons: Mode, Fit, Setup
|
|
local function getModeLabel()
|
|
if S.previewMode == "single" then return "One"
|
|
elseif S.previewMode == "static" then return "Static"
|
|
else return "All" end
|
|
end
|
|
|
|
previewDlg:button{
|
|
id = "pvToggleMode",
|
|
text = getModeLabel(),
|
|
onclick = function()
|
|
-- Save current bounds before switching
|
|
saveBoundsForMode(S.previewMode)
|
|
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
|
|
elseif S.previewMode == "single" then
|
|
S.previewMode = "static"
|
|
refreshSource()
|
|
else
|
|
S.previewMode = "all"
|
|
end
|
|
-- Close and reopen with new mode's saved size
|
|
pcall(function() previewDlg:close() end)
|
|
openPreviewWindow()
|
|
end
|
|
}
|
|
|
|
previewDlg:button{
|
|
id = "pvFit",
|
|
text = "Fit",
|
|
enabled = (S.previewMode ~= "static"),
|
|
onclick = function()
|
|
if S.previewMode == "static" then return end
|
|
-- Clear saved size so it recalculates from content
|
|
if S.previewMode == "all" then S.pvWinWAll = 0; S.pvWinHAll = 0
|
|
elseif S.previewMode == "single" then S.pvWinWOne = 0; S.pvWinHOne = 0
|
|
end
|
|
pcall(function() previewDlg:close() end)
|
|
openPreviewWindow()
|
|
end
|
|
}
|
|
|
|
previewDlg:button{
|
|
id = "pvSetup",
|
|
text = "Setup",
|
|
onclick = function()
|
|
openMainDialog()
|
|
end
|
|
}
|
|
|
|
previewDlg:canvas{
|
|
id = "pvCanvas",
|
|
width = pvWidth,
|
|
height = pvHeight,
|
|
autoscaling = false,
|
|
onpaint = function(ev)
|
|
local gc = ev.context
|
|
|
|
-- Static mode: render full sprite canvas with scroll offset
|
|
if S.previewMode == "static" then
|
|
if S.sourceImage then
|
|
local z = pvZoom()
|
|
local sx = S.previewStaScrollX
|
|
local sy = S.previewStaScrollY
|
|
local sw = S.sourceImage.width * z
|
|
local sh = S.sourceImage.height * z
|
|
-- Use solid bg instead of checkerboard for performance
|
|
gc.color = S.useBgColor and S.bgColor or Color(50, 50, 50)
|
|
gc:fillRect(Rectangle(sx, sy, sw, sh))
|
|
gc:drawImage(S.sourceImage,
|
|
Rectangle(0, 0, S.sourceImage.width, S.sourceImage.height),
|
|
Rectangle(sx, sy, sw, sh))
|
|
end
|
|
return
|
|
end
|
|
|
|
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)
|
|
-- Static mode: right-click drag to scroll
|
|
if S.previewMode == "static" then
|
|
if ev.button == MouseButton.RIGHT then
|
|
S.previewStaDragging = true
|
|
S.previewStaDragLastX = ev.x
|
|
S.previewStaDragLastY = ev.y
|
|
end
|
|
return
|
|
end
|
|
-- 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,
|
|
onmousemove = function(ev)
|
|
if S.previewMode == "static" and S.previewStaDragging then
|
|
local dx = ev.x - S.previewStaDragLastX
|
|
local dy = ev.y - S.previewStaDragLastY
|
|
S.previewStaScrollX = S.previewStaScrollX + dx
|
|
S.previewStaScrollY = S.previewStaScrollY + dy
|
|
S.previewStaDragLastX = ev.x
|
|
S.previewStaDragLastY = ev.y
|
|
previewDlg:repaint()
|
|
end
|
|
end,
|
|
onmouseup = function(ev)
|
|
if S.previewMode == "static" then
|
|
S.previewStaDragging = false
|
|
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))
|
|
elseif S.previewMode == "static" then
|
|
S.previewZoomSta = math.max(1, math.min(MAX_PREVIEW_ZOOM, S.previewZoomSta + 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()
|
|
if S.previewMode ~= "static" then
|
|
pvAnimFrame.value = pvAnimFrame.value + 1
|
|
pcall(function() previewDlg:repaint() end)
|
|
end
|
|
end
|
|
}
|
|
previewTimer:start()
|
|
|
|
local pvStaLastFile = ""
|
|
local pvStaLastFrame = 0
|
|
local pvStaTickCount = 0
|
|
|
|
pvRefreshTimer = Timer{
|
|
interval = 0.5,
|
|
ontick = function()
|
|
refreshLockFile()
|
|
if S.previewMode == "static" then
|
|
-- Detect file or frame change cheaply
|
|
local curFile = app.sprite and app.sprite.filename or ""
|
|
local curFrame = app.frame and app.frame.frameNumber or 0
|
|
local changed = (curFile ~= pvStaLastFile or curFrame ~= pvStaLastFrame)
|
|
pvStaLastFile = curFile
|
|
pvStaLastFrame = curFrame
|
|
-- Also refresh every 5 ticks (2.5s) to catch edits
|
|
pvStaTickCount = pvStaTickCount + 1
|
|
if changed or pvStaTickCount >= 5 then
|
|
pvStaTickCount = 0
|
|
refreshSource()
|
|
pcall(function() previewDlg:repaint() end)
|
|
end
|
|
else
|
|
refreshSource()
|
|
pcall(function() previewDlg:repaint() end)
|
|
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
|
|
-- Clamp previewSingleIdx to new preset's anims
|
|
if #S.animNames == 0 then
|
|
S.previewSingleIdx = 1
|
|
elseif S.previewSingleIdx > #S.animNames then
|
|
S.previewSingleIdx = #S.animNames
|
|
end
|
|
-- Update preview label for One mode
|
|
pcall(function()
|
|
local name = ""
|
|
if S.previewSingleIdx >= 1 and S.previewSingleIdx <= #S.animNames then
|
|
name = S.animNames[S.previewSingleIdx]
|
|
end
|
|
previewDlg:modify{ id = "pvAnimName", text = name }
|
|
end)
|
|
-- Refresh UI if main dialog is open (don't touch preview - avoids repositioning)
|
|
if mainDlg then
|
|
pcall(function() mainDlg:close() end)
|
|
openMainDialog()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
}
|
|
pvRefreshTimer:start()
|
|
|
|
S.previewWindowOpen = true
|
|
saveAll()
|
|
|
|
previewDlg:show{ wait = false }
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Main Dialog (secondary window - setup)
|
|
----------------------------------------------------------------------
|
|
openMainDialog = function()
|
|
-- If main dialog already open, just bring focus (close and reopen)
|
|
if mainDlg then
|
|
pcall(function() mainDlg:close() end)
|
|
end
|
|
|
|
local dlg
|
|
|
|
local function currentAnimName()
|
|
if S.currentAnim >= 1 and S.currentAnim <= #S.animNames then
|
|
return S.animNames[S.currentAnim]
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function getFrameImage(animName, idx)
|
|
return getFrameImageGlobal(animName, idx)
|
|
end
|
|
|
|
local function captureCell(pixelX, pixelY, flipped)
|
|
if not S.sourceImage then return end
|
|
local name = currentAnimName()
|
|
if not name then
|
|
app.alert("Create an animation first.")
|
|
return
|
|
end
|
|
local gridX = math.floor(pixelX / S.tileW) * S.tileW
|
|
local gridY = math.floor(pixelY / S.tileH) * S.tileH
|
|
if gridX < 0 or gridX + S.tileW > S.sourceImage.width then return end
|
|
if gridY < 0 or gridY + S.tileH > S.sourceImage.height then return end
|
|
if not S.anims[name] then S.anims[name] = {} end
|
|
table.insert(S.anims[name], { x = gridX, y = gridY, flipped = flipped, offX = 0, offY = 0 })
|
|
saveAll()
|
|
dlg:repaint()
|
|
end
|
|
|
|
-- Switch visible tab (only Animations and Optimize)
|
|
local function switchTab(tab)
|
|
S.currentTab = tab
|
|
local isSetup = (tab == "Animations")
|
|
local isOpt = (tab == "Optimize")
|
|
for _, id in ipairs(SETUP_IDS) do
|
|
pcall(function() dlg:modify{ id = id, visible = isSetup } end)
|
|
end
|
|
for _, id in ipairs(GB_IDS) do
|
|
pcall(function() dlg:modify{ id = id, visible = isOpt } end)
|
|
end
|
|
-- Update tab button labels
|
|
for _, t in ipairs(TABS) do
|
|
local label = (t == tab) and ("[" .. t .. "]") or (" " .. t .. " ")
|
|
pcall(function() dlg:modify{ id = "tab" .. t, text = label } end)
|
|
end
|
|
dlg:repaint()
|
|
end
|
|
|
|
local function startAnimTimer()
|
|
if mainAnimTimer then mainAnimTimer:stop() end
|
|
mainAnimTimer = Timer{
|
|
interval = S.animSpeed / 1000.0,
|
|
ontick = function()
|
|
S.animFrame = S.animFrame + 1
|
|
pcall(function() dlg:repaint() end)
|
|
end
|
|
}
|
|
mainAnimTimer:start()
|
|
end
|
|
|
|
local contentW, contentH = getSourceContentSize()
|
|
local viewW = math.min(contentW, SOURCE_VIEWPORT_W)
|
|
local viewH = math.min(contentH, SOURCE_VIEWPORT_H)
|
|
|
|
dlg = Dialog{
|
|
title = "AniPhallow (" .. S.currentPreset .. ")",
|
|
onclose = function()
|
|
if mainAnimTimer then pcall(function() mainAnimTimer:stop() end) end
|
|
if mainRefreshTimer then pcall(function() mainRefreshTimer:stop() end) end
|
|
mainAnimTimer = nil
|
|
mainRefreshTimer = nil
|
|
mainDlg = nil
|
|
saveAll()
|
|
-- If preview is also closed, remove lock
|
|
if not previewDlg then removeLockFile() end
|
|
end
|
|
}
|
|
mainDlg = dlg
|
|
|
|
----------------------------------------------------------------
|
|
-- TAB BUTTONS (Animations, Optimize)
|
|
----------------------------------------------------------------
|
|
for _, tab in ipairs(TABS) do
|
|
local label = (tab == S.currentTab) and ("[" .. tab .. "]") or (" " .. tab .. " ")
|
|
dlg:button{
|
|
id = "tab" .. tab,
|
|
text = label,
|
|
onclick = function()
|
|
switchTab(tab)
|
|
end
|
|
}
|
|
end
|
|
|
|
----------------------------------------------------------------
|
|
-- Config button (opens sub-dialog) - visible on ALL tabs
|
|
----------------------------------------------------------------
|
|
dlg:button{
|
|
id = "btnConfig",
|
|
text = "Config",
|
|
onclick = function()
|
|
local d = Dialog{ title = "Config" }
|
|
|
|
--------------------------------------------------------
|
|
-- Presets section
|
|
--------------------------------------------------------
|
|
d:separator{ text = "Presets" }
|
|
d:combobox{ id = "presetSelect", option = S.currentPreset, options = getAllPresetNames(),
|
|
onchange = function()
|
|
local selected = d.data.presetSelect
|
|
if selected and selected ~= S.currentPreset then
|
|
savePreset(S.currentPreset)
|
|
loadPreset(selected)
|
|
S.currentPreset = selected
|
|
local currentFile = app.sprite and app.sprite.filename or ""
|
|
if currentFile ~= "" then filePresetMap[currentFile] = selected end
|
|
saveMaster()
|
|
d:close()
|
|
if mainDlg then pcall(function() mainDlg:close() end) end
|
|
openMainDialog()
|
|
end
|
|
end
|
|
}
|
|
d:button{ id = "presetNew", text = "New", onclick = function()
|
|
local nd = Dialog{ title = "New Preset" }
|
|
nd:entry{ id = "name", label = "Name:", text = "" }
|
|
nd:button{ id = "ok", text = "OK" }
|
|
nd:button{ text = "Cancel" }
|
|
nd:show()
|
|
if nd.data.ok and nd.data.name and nd.data.name ~= "" then
|
|
local newName = nd.data.name
|
|
-- Check if exists
|
|
local exists = false
|
|
for _, n in ipairs(getAllPresetNames()) do
|
|
if n == newName then exists = true; break end
|
|
end
|
|
if exists then
|
|
local r = app.alert{ title = "Overwrite?", text = "Preset '" .. newName .. "' already exists. Overwrite?", buttons = {"Overwrite", "Cancel"} }
|
|
if r ~= 1 then return end
|
|
end
|
|
-- Save current preset first
|
|
savePreset(S.currentPreset)
|
|
-- Reset to defaults and save as new preset
|
|
resetToDefaults()
|
|
S.currentPreset = newName
|
|
addPresetName(newName)
|
|
savePreset(newName)
|
|
local currentFile = app.sprite and app.sprite.filename or ""
|
|
if currentFile ~= "" then filePresetMap[currentFile] = newName end
|
|
saveMaster()
|
|
-- Close config and reopen main
|
|
d:close()
|
|
if mainDlg then pcall(function() mainDlg:close() end) end
|
|
openMainDialog()
|
|
-- Preview title will be stale but avoids repositioning window
|
|
end
|
|
end }
|
|
d:button{ id = "presetClone", text = "Clone", onclick = function()
|
|
local cd = Dialog{ title = "Clone Preset" }
|
|
cd:entry{ id = "name", label = "New name:", text = S.currentPreset .. "_copy" }
|
|
cd:button{ id = "ok", text = "Clone" }
|
|
cd:button{ text = "Cancel" }
|
|
cd:show()
|
|
if cd.data.ok and cd.data.name and cd.data.name ~= "" then
|
|
local newName = cd.data.name
|
|
-- Check if name already exists
|
|
local exists = false
|
|
for _, n in ipairs(getAllPresetNames()) do
|
|
if n == newName then exists = true; break end
|
|
end
|
|
if exists then
|
|
app.alert("Preset '" .. newName .. "' already exists.")
|
|
return
|
|
end
|
|
-- Save current state as the new preset (clone)
|
|
savePreset(newName)
|
|
addPresetName(newName)
|
|
S.currentPreset = newName
|
|
local currentFile = app.sprite and app.sprite.filename or ""
|
|
if currentFile ~= "" then filePresetMap[currentFile] = newName end
|
|
saveMaster()
|
|
-- Close config and reopen main
|
|
d:close()
|
|
if mainDlg then pcall(function() mainDlg:close() end) end
|
|
openMainDialog()
|
|
-- Preview title will be stale but avoids repositioning window
|
|
end
|
|
end }
|
|
d:button{ id = "presetRename", text = "Rename", onclick = function()
|
|
if S.currentPreset == "Default" then
|
|
app.alert("Cannot rename the Default preset.")
|
|
return
|
|
end
|
|
local rd = Dialog{ title = "Rename Preset" }
|
|
rd:entry{ id = "name", label = "New name:", text = S.currentPreset }
|
|
rd:button{ id = "ok", text = "OK" }
|
|
rd:button{ text = "Cancel" }
|
|
rd:show()
|
|
if rd.data.ok and rd.data.name and rd.data.name ~= "" then
|
|
local newName = rd.data.name
|
|
if newName == S.currentPreset then return end
|
|
-- Check if name already exists
|
|
local exists = false
|
|
for _, n in ipairs(getAllPresetNames()) do
|
|
if n == newName then exists = true; break end
|
|
end
|
|
if exists then
|
|
app.alert("Preset '" .. newName .. "' already exists.")
|
|
return
|
|
end
|
|
local oldName = S.currentPreset
|
|
-- Rename the preset file
|
|
local oldPath = app.fs.joinPath(PRESETS_DIR, oldName .. ".ini")
|
|
local newPath = app.fs.joinPath(PRESETS_DIR, newName .. ".ini")
|
|
os.rename(oldPath, newPath)
|
|
-- Update known names
|
|
removePresetName(oldName)
|
|
addPresetName(newName)
|
|
-- Update file associations
|
|
for filepath, preset in pairs(filePresetMap) do
|
|
if preset == oldName then
|
|
filePresetMap[filepath] = newName
|
|
end
|
|
end
|
|
S.currentPreset = newName
|
|
saveMaster()
|
|
-- Close config and reopen main
|
|
d:close()
|
|
if mainDlg then pcall(function() mainDlg:close() end) end
|
|
openMainDialog()
|
|
-- Preview title will be stale but avoids repositioning window
|
|
end
|
|
end }
|
|
d:button{ id = "presetDelete", text = "Delete", onclick = function()
|
|
if S.currentPreset == "Default" then
|
|
app.alert("Cannot delete the Default preset.")
|
|
return
|
|
end
|
|
local confirm = app.alert{
|
|
title = "Delete Preset",
|
|
text = "Delete preset '" .. S.currentPreset .. "'?",
|
|
buttons = { "Delete", "Cancel" }
|
|
}
|
|
if confirm ~= 1 then return end
|
|
local deleteName = S.currentPreset
|
|
-- Delete the preset file
|
|
local path = app.fs.joinPath(PRESETS_DIR, deleteName .. ".ini")
|
|
os.remove(path)
|
|
-- Remove from known names
|
|
removePresetName(deleteName)
|
|
-- Remove file associations pointing to this preset
|
|
for filepath, preset in pairs(filePresetMap) do
|
|
if preset == deleteName then
|
|
filePresetMap[filepath] = nil
|
|
end
|
|
end
|
|
-- Switch to Default
|
|
loadPreset("Default")
|
|
S.currentPreset = "Default"
|
|
if app.sprite and app.sprite.filename and app.sprite.filename ~= "" then
|
|
filePresetMap[app.sprite.filename] = nil
|
|
end
|
|
saveMaster()
|
|
-- Close config and reopen main
|
|
d:close()
|
|
if mainDlg then pcall(function() mainDlg:close() end) end
|
|
openMainDialog()
|
|
-- Reopen preview to update title
|
|
if previewDlg then
|
|
pcall(function() previewDlg:close() end)
|
|
openPreviewWindow()
|
|
end
|
|
end }
|
|
|
|
--------------------------------------------------------
|
|
-- Animations section
|
|
--------------------------------------------------------
|
|
d:separator{ text = "Animations" }
|
|
d:number{ id = "tileW", label = "Sprite W:", text = tostring(S.tileW), decimals = 0 }
|
|
d:number{ id = "tileH", label = "Sprite H:", text = tostring(S.tileH), decimals = 0 }
|
|
d:slider{ id = "animSpeed", label = "Speed (ms):", min = 50, max = 1000, value = S.animSpeed }
|
|
d:check{ id = "useBgColor", text = "Solid background", selected = S.useBgColor }
|
|
d:color{ id = "bgColor", label = "Bg Color:", color = S.bgColor }
|
|
|
|
--------------------------------------------------------
|
|
-- Optimization section
|
|
--------------------------------------------------------
|
|
d:separator{ text = "Optimization" }
|
|
d:number{ id = "gbTileW", label = "Tile W:", text = tostring(S.gbTileW), decimals = 0 }
|
|
d:number{ id = "gbTileH", label = "Tile H:", text = tostring(S.gbTileH), decimals = 0 }
|
|
local silColor = S.gbSilhouetteColor
|
|
if not S.gbSilhouetteColorSet then
|
|
silColor = getDarkestPaletteColor()
|
|
end
|
|
d:color{ id = "silhouetteColor", label = "Silhouette:", color = silColor }
|
|
d:entry{ id = "layerName", label = "Layer name:", text = S.gbLayerName }
|
|
d:check{ id = "alwaysOverwrite", text = "Always overwrite layer", selected = S.gbAlwaysOverwrite }
|
|
|
|
--------------------------------------------------------
|
|
-- Preview section
|
|
--------------------------------------------------------
|
|
d:separator{ text = "Preview" }
|
|
d:combobox{ id = "previewLayout", label = "Layout:", option = S.previewLayout, options = {"auto", "fixedCols", "fixedRows"} }
|
|
d:number{ id = "previewLayoutValue", label = "Value:", text = tostring(S.previewLayoutValue), decimals = 0 }
|
|
d:button{ id = "ok", text = "OK" }
|
|
d:button{ text = "Cancel" }
|
|
d:show()
|
|
if d.data.ok then
|
|
local tw = d.data.tileW
|
|
if tw < 4 then tw = 4 elseif tw > 128 then tw = 128 end
|
|
S.tileW = tw
|
|
local th = d.data.tileH
|
|
if th < 4 then th = 4 elseif th > 128 then th = 128 end
|
|
S.tileH = th
|
|
S.animSpeed = d.data.animSpeed
|
|
if mainAnimTimer then mainAnimTimer.interval = S.animSpeed / 1000.0 end
|
|
if previewTimer then previewTimer.interval = S.animSpeed / 1000.0 end
|
|
S.useBgColor = d.data.useBgColor
|
|
S.bgColor = d.data.bgColor
|
|
local gtw = d.data.gbTileW
|
|
if gtw < 1 then gtw = 1 elseif gtw > 128 then gtw = 128 end
|
|
S.gbTileW = gtw
|
|
local gth = d.data.gbTileH
|
|
if gth < 1 then gth = 1 elseif gth > 128 then gth = 128 end
|
|
S.gbTileH = gth
|
|
S.gbSilhouetteColor = d.data.silhouetteColor
|
|
S.gbSilhouetteColorSet = true
|
|
S.gbLayerName = d.data.layerName
|
|
S.gbAlwaysOverwrite = d.data.alwaysOverwrite
|
|
-- Preview layout settings
|
|
S.previewLayout = d.data.previewLayout
|
|
local plv = d.data.previewLayoutValue
|
|
if plv < 1 then plv = 1 elseif plv > 20 then plv = 20 end
|
|
S.previewLayoutValue = plv
|
|
saveAll()
|
|
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 = zoomStep(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 = zoomStep(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 = zoomStep(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 = zoomStep(S.gbZoomOpt, dz)
|
|
S.gbZoomOpt = newZoom
|
|
S.gbZoomSrc = newZoom
|
|
dlg:repaint()
|
|
end
|
|
}
|
|
|
|
-- Save as Layer button
|
|
dlg:button{
|
|
id = "btnGbSaveLayer",
|
|
text = "Save as Layer",
|
|
visible = false,
|
|
onclick = function()
|
|
if not S.gbOptImage then
|
|
app.alert("Run Analyze first.")
|
|
return
|
|
end
|
|
local sp = app.sprite
|
|
if not sp then return end
|
|
|
|
local layerName = S.gbLayerName
|
|
if not layerName or layerName == "" then layerName = "auto-optimized-tiles" end
|
|
|
|
local existingLayer = nil
|
|
for i, layer in ipairs(sp.layers) do
|
|
if layer.name == layerName then
|
|
existingLayer = layer
|
|
break
|
|
end
|
|
end
|
|
|
|
local imgConverted = Image(S.gbOptImage.width, S.gbOptImage.height, sp.colorMode)
|
|
imgConverted:clear()
|
|
imgConverted:drawImage(S.gbOptImage, Point(0, 0))
|
|
app.transaction("Save Optimized Layer", function()
|
|
if existingLayer then
|
|
if S.gbAlwaysOverwrite then
|
|
existingLayer.isEditable = true
|
|
for _, cel in ipairs(existingLayer.cels) do
|
|
sp:deleteCel(cel)
|
|
end
|
|
sp:newCel(existingLayer, app.frame, imgConverted, Point(0, 0))
|
|
existingLayer.isEditable = false
|
|
else
|
|
local suffix = 2
|
|
local newName = layerName .. "-" .. suffix
|
|
local nameExists = true
|
|
while nameExists do
|
|
nameExists = false
|
|
for _, layer in ipairs(sp.layers) do
|
|
if layer.name == newName then
|
|
nameExists = true
|
|
suffix = suffix + 1
|
|
newName = layerName .. "-" .. suffix
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local newLayer = sp:newLayer()
|
|
newLayer.name = newName
|
|
sp:newCel(newLayer, app.frame, imgConverted, Point(0, 0))
|
|
newLayer.isEditable = false
|
|
end
|
|
else
|
|
local newLayer = sp:newLayer()
|
|
newLayer.name = layerName
|
|
sp:newCel(newLayer, app.frame, imgConverted, Point(0, 0))
|
|
newLayer.isEditable = false
|
|
end
|
|
end)
|
|
end
|
|
}
|
|
|
|
-- Copy to clipboard button (preserves transparency)
|
|
dlg:button{
|
|
id = "btnGbCopyClipboard",
|
|
text = "Copy to Clipboard",
|
|
visible = false,
|
|
onclick = function()
|
|
if not S.gbOptImage then
|
|
app.alert("Run Analyze first.")
|
|
return
|
|
end
|
|
local optW = S.gbOptImage.width
|
|
local optH = S.gbOptImage.height
|
|
local tmpSprite = Sprite(optW, optH, ColorMode.RGB)
|
|
app.command.LayerFromBackground()
|
|
local layer = tmpSprite.layers[1]
|
|
for _, c in ipairs(layer.cels) do tmpSprite:deleteCel(c) end
|
|
tmpSprite:newCel(layer, 1, S.gbOptImage:clone(), Point(0, 0))
|
|
app.command.MaskAll()
|
|
app.command.Copy()
|
|
tmpSprite:close()
|
|
end
|
|
}
|
|
|
|
-- Save PNG button
|
|
dlg:button{
|
|
id = "btnGbSave",
|
|
text = "Save PNG",
|
|
visible = false,
|
|
onclick = function()
|
|
if not S.gbOptImage then
|
|
app.alert("Run Analyze first.")
|
|
return
|
|
end
|
|
|
|
local defaultFile = "tileset_optimized.png"
|
|
if S.gbLastSavePath ~= "" then
|
|
defaultFile = S.gbLastSavePath
|
|
end
|
|
|
|
local saveDlg = Dialog{ title = "Save Optimized Tileset" }
|
|
saveDlg:file{
|
|
id = "path",
|
|
save = true,
|
|
filename = defaultFile,
|
|
filetypes = { "png" },
|
|
}
|
|
saveDlg:button{ id = "ok", text = "Save" }
|
|
saveDlg:button{ text = "Cancel" }
|
|
saveDlg:show()
|
|
|
|
if not saveDlg.data.ok then return end
|
|
local path = saveDlg.data.path
|
|
if not path or path == "" then return end
|
|
|
|
S.gbLastSavePath = path
|
|
saveAll()
|
|
|
|
local optW = S.gbOptImage.width
|
|
local optH = S.gbOptImage.height
|
|
local tmpSprite = Sprite(optW, optH, ColorMode.RGB)
|
|
local cel = tmpSprite.cels[1]
|
|
cel.image:drawImage(S.gbOptImage)
|
|
tmpSprite:saveCopyAs(path)
|
|
tmpSprite:close()
|
|
app.alert("Saved: " .. path)
|
|
end
|
|
}
|
|
|
|
----------------------------------------------------------------
|
|
-- Timers for main dialog
|
|
----------------------------------------------------------------
|
|
mainAnimTimer = Timer{
|
|
interval = S.animSpeed / 1000.0,
|
|
ontick = function()
|
|
S.animFrame = S.animFrame + 1
|
|
pcall(function() dlg:repaint() end)
|
|
end
|
|
}
|
|
mainAnimTimer:start()
|
|
|
|
local gbRefreshCounter = 0
|
|
mainRefreshTimer = Timer{
|
|
interval = 0.5,
|
|
ontick = function()
|
|
local isDragging = S.dragging or S.gbDragging or S.gbSrcDragging
|
|
if S.currentTab == "Optimize" and not isDragging then
|
|
gbRefreshCounter = gbRefreshCounter + 1
|
|
if gbRefreshCounter >= 4 then
|
|
gbRefreshCounter = 0
|
|
refreshSource()
|
|
end
|
|
pcall(function() dlg:repaint() end)
|
|
else
|
|
gbRefreshCounter = 0
|
|
if not isDragging then
|
|
refreshSource()
|
|
end
|
|
pcall(function() dlg:repaint() end)
|
|
end
|
|
|
|
-- Auto-detect file change (also in main dialog timer)
|
|
local currentFile = app.sprite and app.sprite.filename or ""
|
|
if currentFile ~= lastSpriteFilename then
|
|
lastSpriteFilename = currentFile
|
|
local presetForFile = filePresetMap[currentFile] or "Default"
|
|
if presetForFile ~= S.currentPreset then
|
|
loadPreset(presetForFile)
|
|
S.currentPreset = presetForFile
|
|
pcall(function() dlg:close() end)
|
|
openMainDialog()
|
|
end
|
|
end
|
|
end
|
|
}
|
|
mainRefreshTimer:start()
|
|
|
|
startAnimTimer()
|
|
switchTab(S.currentTab)
|
|
dlg:show{ wait = false }
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Entry point
|
|
----------------------------------------------------------------------
|
|
local function run()
|
|
if not app.sprite then
|
|
app.alert("No sprite is open.")
|
|
return
|
|
end
|
|
-- Check if already running via lock file - silently ignore
|
|
-- Lock file contains a timestamp; if older than 5 seconds, consider it stale
|
|
local lockF = io.open(LOCK_FILE, "r")
|
|
if lockF then
|
|
local content = lockF:read("*a")
|
|
lockF:close()
|
|
local lockTime = tonumber(content)
|
|
if lockTime and (os.time() - lockTime) < 5 then
|
|
return -- Still running, ignore
|
|
end
|
|
-- Stale lock, remove and continue
|
|
os.remove(LOCK_FILE)
|
|
end
|
|
-- Create lock file with current timestamp
|
|
local lf = io.open(LOCK_FILE, "w")
|
|
if lf then lf:write(tostring(os.time())); lf:close() end
|
|
|
|
S.currentTab = "Animations"
|
|
refreshSource()
|
|
-- Always open preview window on launch
|
|
openPreviewWindow()
|
|
end
|
|
|
|
run()
|