aniphallow/aniphallow.lua

1936 lines
71 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 = 16
local SOURCE_VIEWPORT_W = 300
local SOURCE_VIEWPORT_H = 250
local MAX_ANIMS = 20 -- max number of dynamic animations
local TABS = { "Setup", "Render", "GB" }
local GB_TILE = 8 -- Game Boy tile size (fixed 8x8)
local GB_COLS = 16 -- tiles per row in optimized image (128px = GB standard)
----------------------------------------------------------------------
-- State
----------------------------------------------------------------------
local pc = app.pixelColor
local S = {
tileW = DEFAULT_TILE_W,
tileH = DEFAULT_TILE_H,
animSpeed = DEFAULT_ANIM_SPEED,
previewZoom = DEFAULT_PREVIEW_ZOOM,
sourceZoom = DEFAULT_SOURCE_ZOOM,
animFrame = 0,
currentAnim = 1, -- index into animNames
currentTab = "Setup",
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
gbTileSize = GB_TILE, -- current tile size used (8 for pixel, from tileset for tile)
-- Dynamic animations: each frame is {x, y, flipped}
animNames = {}, -- ordered list of animation names
anims = {}, -- name -> list of {x, y, flipped}
}
----------------------------------------------------------------------
-- Tab widget IDs (for show/hide)
----------------------------------------------------------------------
-- Setup tab elements (static IDs, dynamic ones added at runtime)
local SETUP_IDS = {
"btnConfig",
"sepAnims", "lblCurrentAnim", "btnNewAnim", "btnDelAnim",
"sepSource", "canvasSource",
"sepFrames", "canvasStrips",
"sepActions", "btnClearAnim", "btnClearAll",
}
-- Render tab elements (static, dynamic anim canvases handled separately)
local RENDER_IDS = {
"sepRender", "canvasRender",
}
-- GB tab elements
local GB_IDS = {
"sepGb", "btnAnalyze", "gbFlipOpt", "gbOffsetOpt", "gbCompress",
"gbSilhouette", "gbAnalyzeMode",
"gbSimilarThreshold", "btnFindSimilar", "lblSimilarStats",
"sepGbOpt", "canvasGbOpt",
"sepGbSource", "canvasGbSource",
"btnGbSaveLayer", "btnGbCopyClipboard", "btnGbSave",
}
----------------------------------------------------------------------
-- Preferences
----------------------------------------------------------------------
local PREFS_FILE = app.fs.joinPath(app.fs.userConfigPath, "aniphallow_prefs.ini")
-- Serialize frames list: "x1,y1,f;x2,y2,f;..." (f=0 normal, f=1 flipped)
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"))
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, fl = string.match(part, "([%d-]+),([%d-]+),?(%d?)")
if x and y then
table.insert(frames, {
x = tonumber(x), y = tonumber(y),
flipped = (fl == "1")
})
end
end
return frames
end
local function loadPrefs()
local f = io.open(PREFS_FILE, "r")
if not f then return end
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 S.previewZoom = 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 == "currentTab" and v ~= "" then 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 3 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
-- 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()
end
local function savePrefs()
local f = io.open(PREFS_FILE, "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("previewZoom=" .. S.previewZoom .. "\n")
f:write("sourceZoom=" .. S.sourceZoom .. "\n")
f:write("currentAnim=" .. S.currentAnim .. "\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")
for i, name in ipairs(S.animNames) do
f:write("anim_" .. (i - 1) .. "=" .. serializeFrames(S.anims[name] or {}) .. "\n")
end
f:close()
end
loadPrefs()
----------------------------------------------------------------------
-- Helpers
----------------------------------------------------------------------
local function clamp(v, lo, hi)
return math.max(lo, math.min(hi, v))
end
local function extractCell(src, sx, sy, tw, th)
local cell = Image(tw, th, ColorMode.RGB)
cell:clear(pc.rgba(0, 0, 0, 0))
for y = 0, th - 1 do
for x = 0, tw - 1 do
local rx, ry = sx + x, sy + y
if rx >= 0 and rx < src.width and ry >= 0 and ry < src.height then
cell:putPixel(x, y, src:getPixel(rx, ry))
end
end
end
return cell
end
local function flipImageH(src)
local flipped = Image(src.width, src.height, ColorMode.RGB)
flipped:clear(pc.rgba(0, 0, 0, 0))
for y = 0, src.height - 1 do
for x = 0, src.width - 1 do
flipped:putPixel(src.width - 1 - x, y, src:getPixel(x, y))
end
end
return flipped
end
local function 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
----------------------------------------------------------------------
-- GB Tile Hashing & Deduplication
----------------------------------------------------------------------
-- Hash an 8x8 tile at position (sx,sy) in source image
local function tileHash(img, sx, sy)
local parts = {}
for y = 0, GB_TILE - 1 do
for x = 0, GB_TILE - 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)
local parts = {}
for y = 0, GB_TILE - 1 do
for x = GB_TILE - 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)
local parts = {}
for y = GB_TILE - 1, 0, -1 do
for x = 0, GB_TILE - 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)
local parts = {}
for y = GB_TILE - 1, 0, -1 do
for x = GB_TILE - 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 an 8x8 tile is fully transparent
local function isTileEmpty(img, sx, sy)
for y = 0, GB_TILE - 1 do
for x = 0, GB_TILE - 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
-- All painted pixels must match perfectly in the overlap, and
-- no painted pixels can exist outside the overlap area
local function tilesMatchOffset(img, ax, ay, bx, by, ox, oy, flipH, flipV)
local t = GB_TILE - 1
for y = 0, t do
for x = 0, t do
local pb = img:getPixel(bx + x, by + y)
local bPainted = pc.rgbaA(pb) > 0
-- Where in A does this B pixel come from?
local axp = x - ox
local ayp = y - oy
if axp >= 0 and axp <= t and ayp >= 0 and ayp <= t then
local srcAx = flipH and (t - axp) or axp
local srcAy = flipV and (t - ayp) or ayp
local pa = img:getPixel(ax + srcAx, ay + srcAy)
local aPainted = pc.rgbaA(pa) > 0
-- In overlap: both must agree (both painted+same, or both transparent)
if bPainted ~= aPainted then return false end
if bPainted and pa ~= pb then return false end
else
-- Outside overlap: B must not have painted pixels here
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)
local tiles = {} -- list of {hash, positions}
local hashIndex = {} -- hash -> tile index in tiles list
local totalCount = 0
local cols = math.floor(img.width / GB_TILE)
local rows = math.floor(img.height / GB_TILE)
-- Build flip modes for offset matching
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 * GB_TILE
local sy = row * GB_TILE
-- Skip fully transparent tiles
if isTileEmpty(img, sx, sy) then goto continueAnalyze end
local hash = tileHash(img, sx, sy)
totalCount = totalCount + 1
-- Check direct hash match
if hashIndex[hash] then
table.insert(tiles[hashIndex[hash]].positions,
{x = sx, y = sy, flip = "none", ox = 0, oy = 0})
goto continueAnalyze
end
-- Check flipped hash matches
if flipOpt then
local hashH = tileHashFlipH(img, sx, sy)
local hashV = tileHashFlipV(img, sx, sy)
local hashHV = tileHashFlipHV(img, sx, sy)
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
-- Check offset matches (brute force against all existing unique tiles)
if offsetOpt then
local found = false
for ti = 1, #tiles do
local posA = tiles[ti].positions[1]
for _, fm in ipairs(flipModes) do
for oy = -(GB_TILE - 1), GB_TILE - 1 do
for ox = -(GB_TILE - 1), GB_TILE - 1 do
if ox == 0 and oy == 0 then goto continueOff end
if tilesMatchOffset(img, posA.x, posA.y,
sx, sy, ox, oy, fm[1], fm[2]) then
table.insert(tiles[ti].positions,
{x = sx, y = sy, flip = fm[3], ox = ox, oy = oy})
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
-- New unique tile
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 tileSize = GB_TILE
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
tileSize = ts.width -- assume square tiles
local celImg = cel.image
local celPos = cel.position
local imgW = celImg.width
local imgH = celImg.height
-- Collect all tile placements
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
-- Use tileset name/index + tile index as identity
local tsId = layer.name or "ts"
local hash = tsId .. ":" .. tileIdx
table.insert(placements, {
hash = hash,
canvasX = canvasX,
canvasY = canvasY,
tileIdx = tileIdx,
})
::continueTile::
end
end
-- Sort by reading order (top-to-bottom, left-to-right)
table.sort(placements, function(a, b)
if a.canvasY ~= b.canvasY then return a.canvasY < b.canvasY end
return a.canvasX < b.canvasX
end)
-- Keep only first occurrence of each unique tile identity
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, tileSize
end
-- Compare two 8x8 regions with offset and optional flip
-- ox, oy: offset applied to tile B
-- flipH, flipV: flip tile B before comparing
-- Returns: matches (coinciding painted pixels), paintedA (total painted in A)
local function tileSimilarityEx(img, ax, ay, bx, by, ox, oy, flipH, flipV)
local matches = 0
local paintedA = 0
local t = GB_TILE - 1
for y = 0, t do
for x = 0, t do
local pa = img:getPixel(ax + x, ay + y)
if pc.rgbaA(pa) > 0 then
paintedA = paintedA + 1
-- Position in tile B with offset
local bxp = x - ox
local byp = y - oy
-- No overflow: skip if out of tile bounds
if bxp >= 0 and bxp <= t and byp >= 0 and byp <= t then
local srcBx = flipH and (t - bxp) or bxp
local srcBy = flipV and (t - 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 an 8x8 tile
local function countPaintedPixels(img, sx, sy)
local count = 0
for y = 0, GB_TILE - 1 do
for x = 0, GB_TILE - 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)
-- thresholdPct: percentage of painted pixels that must match (1-100)
local function findSimilarTiles(img, tiles, thresholdPct, flipOpt, offsetOpt)
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 oy = -(GB_TILE - 1), GB_TILE - 1 do
for ox = -(GB_TILE - 1), GB_TILE - 1 do
table.insert(offsets, {ox, oy})
end
end
end
-- Pre-compute painted pixel counts
local paintedCount = {}
for i = 1, #tiles do
local p = tiles[i].positions[1]
paintedCount[i] = countPaintedPixels(img, p.x, p.y)
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])
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
-- compress=true: pack unique tiles left-to-right, same column count as source
-- compress=false: same size as source, only first occurrence drawn, dupes left transparent
-- silhouette: if true and not compress, draw duplicates as silhouettes
-- silhouetteColor: color to use for silhouette pixels
-- tileSize: tile size to use (default GB_TILE)
local function buildOptimizedImage(img, tiles, compress, silhouette, silhouetteColor, tileSize)
local ts = tileSize or GB_TILE
local srcCols = math.floor(img.width / ts)
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 * ts
local optH = imgRows * ts
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 * ts
local dy = destRow * ts
for y = 0, ts - 1 do
for x = 0, ts - 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
-- Same size as source, only draw first occurrence of each unique tile
local optImg = Image(img.width, img.height, ColorMode.RGB)
optImg:clear(pc.rgba(0, 0, 0, 0))
-- Build set of first-occurrence positions
local firstPositions = {}
for _, tile in ipairs(tiles) do
local pos = tile.positions[1]
firstPositions[pos.x .. "," .. pos.y] = true
end
-- Copy only first-occurrence tiles
for _, tile in ipairs(tiles) do
local pos = tile.positions[1]
for y = 0, ts - 1 do
for x = 0, ts - 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
-- Draw duplicate positions as silhouettes
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, ts - 1 do
for x = 0, ts - 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
----------------------------------------------------------------------
-- Dialog
----------------------------------------------------------------------
local function run()
if not app.sprite then
app.alert("No sprite is open.")
return
end
local animTimer = nil
local refreshTimer = nil
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
refreshSource()
local contentW, contentH = getSourceContentSize()
local viewW = math.min(contentW, SOURCE_VIEWPORT_W)
local viewH = math.min(contentH, SOURCE_VIEWPORT_H)
local dlg = Dialog{
title = "AniPhallow - Animation Builder",
onclose = function()
if animTimer then animTimer:stop() end
if refreshTimer then refreshTimer:stop() end
savePrefs()
end
}
-- Get a frame image for an animation at index (handles flipped frames)
local function getFrameImage(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
return img
end
-- Get current animation name
local function currentAnimName()
if S.currentAnim >= 1 and S.currentAnim <= #S.animNames then
return S.animNames[S.currentAnim]
end
return nil
end
local function updateAnimLabel()
local name = currentAnimName() or "(none)"
dlg:modify{ id = "lblCurrentAnim", text = "-> " .. name }
end
local function startAnimTimer()
if animTimer then animTimer:stop() end
animTimer = Timer{
interval = S.animSpeed / 1000.0,
ontick = function()
S.animFrame = S.animFrame + 1
dlg:repaint()
end
}
animTimer:start()
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 })
dlg:repaint()
end
-- Switch visible tab
local function switchTab(tab)
S.currentTab = tab
local isSetup = (tab == "Setup")
local isRender = (tab == "Render")
local isGB = (tab == "GB")
for _, id in ipairs(SETUP_IDS) do
dlg:modify{ id = id, visible = isSetup }
end
for _, id in ipairs(RENDER_IDS) do
dlg:modify{ id = id, visible = isRender }
end
for _, id in ipairs(GB_IDS) do
dlg:modify{ id = id, visible = isGB }
end
-- Anim selector buttons (dynamic)
for i = 1, #S.animNames do
pcall(function() dlg:modify{ id = "btnAnim" .. i, visible = isSetup } end)
end
-- Update tab button labels
for _, t in ipairs(TABS) do
local label = (t == tab) and ("[" .. t .. "]") or (" " .. t .. " ")
dlg:modify{ id = "tab" .. t, text = label }
end
dlg:repaint()
end
----------------------------------------------------------------
-- TAB BUTTONS
----------------------------------------------------------------
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
----------------------------------------------------------------
-- SETUP TAB
----------------------------------------------------------------
----------------------------------------------------------------
-- Config button (opens sub-dialog)
----------------------------------------------------------------
dlg:button{
id = "btnConfig",
text = "Config",
onclick = function()
local d = Dialog{ title = "Config" }
d:number{ id = "tileW", label = "Tile W:", text = tostring(S.tileW), decimals = 0 }
d:number{ id = "tileH", label = "Tile H:", text = tostring(S.tileH), decimals = 0 }
d:slider{ id = "animSpeed", label = "Speed (ms):", min = 50, max = 1000, value = S.animSpeed }
d:number{ id = "previewZoom", label = "Zoom:", text = tostring(S.previewZoom), decimals = 0 }
d:check{ id = "useBgColor", text = "Solid background", selected = S.useBgColor }
d:color{ id = "bgColor", label = "Bg Color:", color = S.bgColor }
d:separator{ text = "GB Options" }
-- Auto-detect darkest palette color on first open
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 }
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 animTimer then animTimer.interval = S.animSpeed / 1000.0 end
local pz = d.data.previewZoom
if pz < 1 then pz = 1 elseif pz > MAX_PREVIEW_ZOOM then pz = MAX_PREVIEW_ZOOM end
S.previewZoom = pz
S.useBgColor = d.data.useBgColor
S.bgColor = d.data.bgColor
S.gbSilhouetteColor = d.data.silhouetteColor
S.gbSilhouetteColorSet = true
S.gbLayerName = d.data.layerName
S.gbAlwaysOverwrite = d.data.alwaysOverwrite
dlg:repaint()
end
end
}
----------------------------------------------------------------
-- Animation selector (dynamic)
----------------------------------------------------------------
dlg:separator{ id = "sepAnims", text = "Animations" }
dlg:label{
id = "lblCurrentAnim",
text = "-> " .. (currentAnimName() or "(none)"),
}
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] = {}
S.currentAnim = #S.animNames
savePrefs()
dlg:close()
run()
end
end
end
}
dlg:button{
id = "btnDelAnim",
text = "-",
onclick = function()
local name = currentAnimName()
if not name then return end
S.anims[name] = nil
table.remove(S.animNames, S.currentAnim)
S.currentAnim = math.min(S.currentAnim, math.max(1, #S.animNames))
savePrefs()
dlg:close()
run()
end
}
dlg:newrow()
-- Create buttons only for existing animations
for i = 1, #S.animNames do
dlg:button{
id = "btnAnim" .. i,
text = S.animNames[i],
onclick = function()
S.currentAnim = i
updateAnimLabel()
dlg:repaint()
end
}
end
----------------------------------------------------------------
-- Source canvas (L-click=add, R-click=add flipped, M-click=scroll)
----------------------------------------------------------------
dlg:separator{ id = "sepSource", text = "Source (L=add, R=add flipped)" }
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 _, f in ipairs(S.anims[name]) do
local rx = f.x * S.sourceZoom - S.scrollX
local ry = f.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
-- Blue for normal, orange for flipped
gc.color = f.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
local pixelX = math.floor((ev.x + S.scrollX) / S.sourceZoom)
local pixelY = math.floor((ev.y + S.scrollY) / S.sourceZoom)
captureCell(pixelX, pixelY, false)
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)
else
S.dragging = true
S.dragLastX = ev.x
S.dragLastY = ev.y
end
end,
onmousemove = function(ev)
if S.dragging then
S.scrollX = S.scrollX + (S.dragLastX - ev.x)
S.scrollY = S.scrollY + (S.dragLastY - ev.y)
clampScroll()
S.dragLastX = ev.x
S.dragLastY = ev.y
end
end,
onmouseup = function(ev)
S.dragging = false
end
}
----------------------------------------------------------------
-- Frame strip for current animation
----------------------------------------------------------------
dlg:separator{ id = "sepFrames", text = "Frames (right-click to remove)" }
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
gc:drawImage(
fimg,
Rectangle(0, 0, S.tileW, S.tileH),
Rectangle(tx, ty, THUMB_SIZE, THUMB_SIZE)
)
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
-- Current 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
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.RIGHT then
table.remove(frames, idx)
dlg:repaint()
end
end
end
}
----------------------------------------------------------------
-- Clear buttons
----------------------------------------------------------------
dlg:separator{ id = "sepActions" }
dlg:button{
id = "btnClearAnim",
text = "Clear Anim",
onclick = function()
local name = currentAnimName()
if name then S.anims[name] = {} end
dlg:repaint()
end
}
dlg:button{
id = "btnClearAll",
text = "Clear All",
onclick = function()
for _, name in ipairs(S.animNames) do
S.anims[name] = {}
end
dlg:repaint()
end
}
----------------------------------------------------------------
-- RENDER TAB (single canvas with all animations in 2 columns)
----------------------------------------------------------------
dlg:separator{ id = "sepRender", text = "Animations", visible = false }
local RENDER_MARGIN = 2
dlg:canvas{
id = "canvasRender",
width = SOURCE_VIEWPORT_W,
height = SOURCE_VIEWPORT_H,
autoscaling = false,
visible = false,
onpaint = function(ev)
local gc = ev.context
local numAnims = #S.animNames
if numAnims == 0 then return end
local tw = S.tileW * S.previewZoom
local th = S.tileH * S.previewZoom
local cellW = tw + RENDER_MARGIN * 2
local cellH = th + RENDER_MARGIN * 2
local cols = 2
local totalW = cols * cellW
for i, name in ipairs(S.animNames) do
local col = (i - 1) % cols
local row = math.floor((i - 1) / cols)
local ox = col * cellW
local oy = row * cellH
-- Background
if S.useBgColor then
gc.color = S.bgColor
gc:fillRect(Rectangle(ox + RENDER_MARGIN, oy + RENDER_MARGIN, tw, th))
else
drawCheckerboard(gc, tw, th, S.previewZoom,
ox + RENDER_MARGIN, oy + RENDER_MARGIN)
end
-- Draw current frame
local frames = S.anims[name] or {}
local totalFrames = #frames
if totalFrames > 0 then
local idx = (S.animFrame % totalFrames) + 1
local fimg = getFrameImage(name, idx)
if fimg then
gc:drawImage(
fimg,
Rectangle(0, 0, S.tileW, S.tileH),
Rectangle(ox + RENDER_MARGIN, oy + RENDER_MARGIN, tw, th)
)
end
end
end
end,
onwheel = function(ev)
local dz = ev.deltaY < 0 and 1 or -1
S.previewZoom = math.max(1, math.min(MAX_PREVIEW_ZOOM, S.previewZoom + dz))
dlg:repaint()
end
}
----------------------------------------------------------------
-- GB TAB
----------------------------------------------------------------
dlg:separator{ id = "sepGb", text = "GB Tile Optimizer", visible = false }
-- Row: Flip + Offset + Analyze
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 = "gbCompress",
text = "Compress",
selected = S.gbCompress,
visible = false,
onclick = function() S.gbCompress = dlg.data.gbCompress end
}
dlg:check{
id = "gbSilhouette",
text = "Silhouette",
selected = S.gbSilhouette,
visible = false,
onclick = function() S.gbSilhouette = dlg.data.gbSilhouette end
}
dlg:combobox{
id = "gbAnalyzeMode",
option = S.gbAnalyzeMode,
options = {"pixel", "tile"},
visible = false,
onchange = function() S.gbAnalyzeMode = dlg.data.gbAnalyzeMode end
}
-- Row: Analyze + >=threshold + Similar (all on one line)
dlg:button{
id = "btnAnalyze",
text = "Analyze",
visible = false,
onclick = function()
if not S.sourceImage then return end
local usedTileSize = GB_TILE
if S.gbAnalyzeMode == "tile" then
local ts
S.gbTiles, S.gbTotalTiles, ts = analyzeTilesTileMode(app.sprite, app.frame.frameNumber)
if ts then usedTileSize = ts end
else
S.gbTiles, S.gbTotalTiles = analyzeTiles(S.sourceImage, S.gbFlipOpt, S.gbOffsetOpt)
end
S.gbTileSize = usedTileSize
S.gbOptImage, S.gbCols = buildOptimizedImage(
S.sourceImage, S.gbTiles, S.gbCompress,
S.gbSilhouette, S.gbSilhouetteColor, usedTileSize)
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, 120) }
end
dlg:repaint()
end
}
dlg:button{
id = "gbSimilarThreshold",
text = ">=" .. 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 = ">=" .. v .. "%" }
end
end
}
dlg:button{
id = "btnFindSimilar",
text = "Similar",
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)
dlg:modify{ id = "btnFindSimilar",
text = "Similar (" .. #S.gbSimilarPairs .. ")" }
dlg:repaint()
end
}
dlg:label{ id = "lblSimilarStats", text = "", visible = false }
-- Optimized tileset canvas
dlg:separator{ id = "sepGbOpt", text = "Optimized Tileset (R-click to select)", visible = false }
local GB_OPT_W = SOURCE_VIEWPORT_W
local GB_OPT_H = 120
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 ts = S.gbTileSize or GB_TILE
-- Background
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 / ts) do
local lx = col * ts * 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 / ts) do
local ly = row * ts * 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 * ts, r * ts
else
local pos = S.gbTiles[i].positions[1]
return pos.x, pos.y
end
end
-- Highlight tiles involved in similar pairs
if #S.gbSimilarPairs > 0 then
local similarSet = {}
for _, pair in ipairs(S.gbSimilarPairs) do
similarSet[pair.i] = true
similarSet[pair.j] = true
end
local rw = ts * z
local rh = ts * z
for idx, _ in pairs(similarSet) do
local tx, ty = tilePos(idx)
local srx = tx * z - S.gbScrollX
local sry = ty * z - S.gbScrollY
gc.color = Color(255, 0, 255, 40)
gc:fillRect(Rectangle(srx, sry, rw, rh))
gc.color = Color(255, 0, 255, 150)
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 a tile is selected and is in a similar pair, highlight its partner(s)
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 tx, ty = tilePos(S.gbSelectedTile)
local rx = tx * z - S.gbScrollX
local ry = ty * z - S.gbScrollY
local rw = ts * z
local rh = ts * 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
-- LEFT = drag scroll
S.gbDragging = true
S.gbDragLastX = ev.x
S.gbDragLastY = ev.y
elseif ev.button == MouseButton.RIGHT then
-- RIGHT = select tile
if not S.gbOptImage then return end
local z = S.gbZoomOpt
local ts = S.gbTileSize or GB_TILE
local pixelX = math.floor((ev.x + S.gbScrollX) / z)
local pixelY = math.floor((ev.y + S.gbScrollY) / z)
if S.gbCompress then
local col = math.floor(pixelX / ts)
local row = math.floor(pixelY / ts)
local idx = row * S.gbCols + col + 1
if idx >= 1 and idx <= #S.gbTiles then
S.gbSelectedTile = idx
end
else
-- Find which unique tile is at this pixel position
local clickTileX = math.floor(pixelX / ts) * ts
local clickTileY = math.floor(pixelY / ts) * ts
for i, tile in ipairs(S.gbTiles) do
local pos = tile.positions[1]
if pos.x == clickTileX and pos.y == clickTileY then
S.gbSelectedTile = i
break
end
end
end
dlg:repaint()
end
end,
onmousemove = function(ev)
if S.gbDragging 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)
S.gbDragLastX = ev.x
S.gbDragLastY = ev.y
end
end,
onmouseup = function(ev)
S.gbDragging = false
dlg:repaint()
end,
onwheel = function(ev)
local dz = ev.deltaY < 0 and 1 or -1
S.gbZoomOpt = math.max(1, math.min(10, S.gbZoomOpt + dz))
dlg:repaint()
end
}
-- Source canvas showing occurrences of selected tile
dlg:separator{ id = "sepGbSource", text = "Occurrences in source", visible = false }
local GB_SRC_W = SOURCE_VIEWPORT_W
local GB_SRC_H = 150
-- 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 ts = S.gbTileSize or GB_TILE
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 / ts) do
local lx = col * ts * 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 / ts) do
local ly = row * ts * 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 = ts * z
local rh = ts * 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
-- Semi-transparent fill
gc.color = Color(c.red, c.green, c.blue, 60)
gc:fillRect(Rectangle(rx, ry, rw, rh))
-- Border
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
-- LEFT = drag scroll
S.gbSrcDragging = true
S.gbSrcDragLastX = ev.x
S.gbSrcDragLastY = ev.y
end
end,
onmousemove = function(ev)
if S.gbSrcDragging 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)
S.gbSrcDragLastX = ev.x
S.gbSrcDragLastY = ev.y
end
end,
onmouseup = function(ev)
S.gbSrcDragging = false
dlg:repaint()
end,
onwheel = function(ev)
local dz = ev.deltaY < 0 and 1 or -1
S.gbZoomSrc = math.max(1, math.min(10, S.gbZoomSrc + dz))
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
-- Check if layer with that name exists
local existingLayer = nil
local existingIdx = nil
for i, layer in ipairs(sp.layers) do
if layer.name == layerName then
existingLayer = layer
existingIdx = i
break
end
end
app.transaction("Save Optimized Layer", function()
if existingLayer then
if S.gbAlwaysOverwrite then
-- Overwrite: delete old cels, add new cel with image
for _, cel in ipairs(existingLayer.cels) do
sp:deleteCel(cel)
end
existingLayer.isEditable = true
sp:newCel(existingLayer, app.frame, S.gbOptImage, Point(0, 0))
existingLayer.isEditable = false
else
-- Create new layer with incremented name
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
-- Move just above existing layer
if existingIdx then
sp:newCel(newLayer, app.frame, S.gbOptImage, Point(0, 0))
newLayer.isEditable = false
end
end
else
-- Create at top of layer stack
local newLayer = sp:newLayer()
newLayer.name = layerName
sp:newCel(newLayer, app.frame, S.gbOptImage, Point(0, 0))
newLayer.isEditable = false
end
end)
app.alert("Layer saved: " .. layerName)
end
}
-- Copy to clipboard button
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)
local cel = tmpSprite.cels[1]
cel.image:drawImage(S.gbOptImage)
app.command.SelectAll()
app.command.CopyMerged()
tmpSprite:close()
app.alert("Copied!")
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
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
}
----------------------------------------------------------------
-- CLOSE BUTTON (always visible)
----------------------------------------------------------------
dlg:newrow()
dlg:separator()
dlg:button{
text = "Close",
onclick = function()
dlg:close()
end
}
----------------------------------------------------------------
-- Timers
----------------------------------------------------------------
animTimer = Timer{
interval = S.animSpeed / 1000.0,
ontick = function()
S.animFrame = S.animFrame + 1
dlg:repaint()
end
}
animTimer:start()
local gbRefreshCounter = 0
refreshTimer = Timer{
interval = 0.5,
ontick = function()
local isDragging = S.dragging or S.gbDragging or S.gbSrcDragging
if S.currentTab == "GB" and not isDragging then
-- On GB tab and not dragging: only refresh every 2 seconds (4 ticks at 0.5s)
gbRefreshCounter = gbRefreshCounter + 1
if gbRefreshCounter >= 4 then
gbRefreshCounter = 0
refreshSource()
end
dlg:repaint()
else
gbRefreshCounter = 0
if not isDragging then
refreshSource()
end
dlg:repaint()
end
end
}
refreshTimer:start()
startAnimTimer()
switchTab(S.currentTab)
dlg:show{ wait = false }
end
run()