commit 23d74a6f2a9e0ee365af6c21c120daf63eef2b37 Author: Cidwel Highwind Date: Fri Apr 3 12:11:29 2026 +0200 Initial commit: add aniphallow.lua Aseprite tool Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/aniphallow.lua b/aniphallow.lua new file mode 100644 index 0000000..cbab7aa --- /dev/null +++ b/aniphallow.lua @@ -0,0 +1,1935 @@ +---------------------------------------------------------------------- +-- 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()