diff --git a/aniphallow.lua b/aniphallow.lua index a48ab31..f92f5ab 100644 --- a/aniphallow.lua +++ b/aniphallow.lua @@ -24,7 +24,7 @@ local SOURCE_VIEWPORT_H = 250 local MAX_ANIMS = 20 -- max number of dynamic animations local TABS = { "Animations", "Preview", "GB" } -local GB_TILE = 8 -- Game Boy tile size (fixed 8x8) +local DEFAULT_GB_TILE = 8 -- Default Game Boy tile size local GB_COLS = 16 -- tiles per row in optimized image (128px = GB standard) ---------------------------------------------------------------------- @@ -79,11 +79,16 @@ local S = { 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) - sourceViewportH = 250, -- configurable source viewport height - sourceAutoResize = false, -- auto-resize source with zoom + gbTileSize = DEFAULT_GB_TILE, -- configurable tile size for GB analysis + -- Click vs drag for GB canvases + gbOptClickX = 0, + gbOptClickY = 0, + gbOptIsDrag = false, + gbSrcClickX = 0, + gbSrcClickY = 0, + gbSrcIsDrag = false, -- Dynamic animations: each frame is {x, y, flipped, flippedV} - animNames = {}, -- ordered list of animation names + animNames = {}, -- ordered list of animation names (sorted alphabetically) anims = {}, -- name -> list of {x, y, flipped, flippedV} -- Frame selection & drag selectedFrame = 0, @@ -99,28 +104,26 @@ local S = { ---------------------------------------------------------------------- -- Tab widget IDs (for show/hide) ---------------------------------------------------------------------- --- Setup tab elements (static IDs, dynamic ones added at runtime) +-- Setup tab elements local SETUP_IDS = { - "sepAnims", "btnNewAnim", "btnDelAnim", - "btnMoveAnimUp", "btnMoveAnimDown", + "btnConfig", "sepAnims", "cmbAnimList", "btnNewAnim", "btnDelAnim", "btnRenameAnim", "sepSource", "canvasSource", "sepFrames", "canvasStrips", - "sepActions", "btnFlipX", "btnFlipY", "btnMoveLeft", "btnMoveRight", "btnRemoveFrame", "btnClearAnim", - "sepPreviewAnim", "canvasPreviewAnim", + "sepActions", "btnFlipX", "btnFlipY", "btnMoveLeft", "btnMoveRight", "btnRemoveFrame", } --- Render tab elements (static, dynamic anim canvases handled separately) +-- Render tab elements local RENDER_IDS = { - "sepRender", "canvasRender", + "sepRender", "btnSeparateWindow", "canvasRender", } -- GB tab elements local GB_IDS = { "sepGb", "btnAnalyze", "gbFlipOpt", "gbOffsetOpt", "gbCompress", "gbSilhouette", "gbAnalyzeMode", - "gbSimilarThreshold", "btnFindSimilar", "lblSimilarStats", - "sepGbOpt", "canvasGbOpt", + "gbSimilarThreshold", "btnFindSimilar", "sepGbSource", "canvasGbSource", + "sepGbOpt", "canvasGbOpt", "btnGbSaveLayer", "btnGbCopyClipboard", "btnGbSave", } @@ -158,6 +161,7 @@ end local function loadPrefs() local f = io.open(PREFS_FILE, "r") if not f then return end + local savedAnimName = nil for line in f:lines() do local k, v = string.match(line, "^([%w_]+)=(.+)$") if k == "tileW" then S.tileW = tonumber(v) or DEFAULT_TILE_W end @@ -165,7 +169,9 @@ local function loadPrefs() 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 + -- Legacy: currentAnim as index (for migration) if k == "currentAnim" then S.currentAnim = tonumber(v) or 1 end + if k == "currentAnimName" then savedAnimName = v end if k == "currentTab" and v ~= "" then -- Migrate old tab names if v == "Setup" then v = "Animations" @@ -185,7 +191,7 @@ local function loadPrefs() 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 == "gbSimilarThreshold" then S.gbSimilarThreshold = tonumber(v) or 80 end if k == "gbZoomOpt" then S.gbZoomOpt = tonumber(v) or 3 end if k == "gbZoomSrc" then S.gbZoomSrc = tonumber(v) or 2 end if k == "gbSilhouette" then S.gbSilhouette = (v == "true") end @@ -197,8 +203,7 @@ local function loadPrefs() if k == "gbLastSavePath" then S.gbLastSavePath = v end if k == "gbLayerName" then S.gbLayerName = v end if k == "gbAlwaysOverwrite" then S.gbAlwaysOverwrite = (v == "true") end - if k == "sourceViewportH" then S.sourceViewportH = tonumber(v) or 250 end - if k == "sourceAutoResize" then S.sourceAutoResize = (v == "true") end + if k == "gbTileSize" then S.gbTileSize = tonumber(v) or DEFAULT_GB_TILE end -- Dynamic anim frames: anim_0, anim_1, ... local animIdx = k and string.match(k, "^anim_(%d+)$") if animIdx then @@ -209,6 +214,22 @@ local function loadPrefs() end end f:close() + + -- Sort animation names alphabetically + table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) + + -- Resolve current animation by name + if savedAnimName and savedAnimName ~= "" then + for i, name in ipairs(S.animNames) do + if name == savedAnimName then + S.currentAnim = i + break + end + end + end + -- Clamp currentAnim to valid range + if S.currentAnim < 1 then S.currentAnim = 1 end + if S.currentAnim > #S.animNames then S.currentAnim = math.max(1, #S.animNames) end end local function savePrefs() @@ -219,7 +240,12 @@ local function savePrefs() f:write("animSpeed=" .. S.animSpeed .. "\n") f:write("previewZoom=" .. S.previewZoom .. "\n") f:write("sourceZoom=" .. S.sourceZoom .. "\n") - f:write("currentAnim=" .. S.currentAnim .. "\n") + -- Save current animation name for robust resolution after sort + local curName = "" + if S.currentAnim >= 1 and S.currentAnim <= #S.animNames then + curName = S.animNames[S.currentAnim] + end + f:write("currentAnimName=" .. curName .. "\n") f:write("currentTab=" .. S.currentTab .. "\n") f:write("animNames=" .. table.concat(S.animNames, "|") .. "\n") f:write("useBgColor=" .. tostring(S.useBgColor) .. "\n") @@ -241,8 +267,7 @@ local function savePrefs() f:write("gbLastSavePath=" .. S.gbLastSavePath .. "\n") f:write("gbLayerName=" .. S.gbLayerName .. "\n") f:write("gbAlwaysOverwrite=" .. tostring(S.gbAlwaysOverwrite) .. "\n") - f:write("sourceViewportH=" .. S.sourceViewportH .. "\n") - f:write("sourceAutoResize=" .. tostring(S.sourceAutoResize) .. "\n") + f:write("gbTileSize=" .. S.gbTileSize .. "\n") for i, name in ipairs(S.animNames) do f:write("anim_" .. (i - 1) .. "=" .. serializeFrames(S.anims[name] or {}) .. "\n") end @@ -316,7 +341,7 @@ end local function clampScroll() local contentW, contentH = getSourceContentSize() local maxScrollX = math.max(0, contentW - SOURCE_VIEWPORT_W) - local maxScrollY = math.max(0, contentH - S.sourceViewportH) + local maxScrollY = math.max(0, contentH - SOURCE_VIEWPORT_H) S.scrollX = clamp(S.scrollX, 0, maxScrollX) S.scrollY = clamp(S.scrollY, 0, maxScrollY) end @@ -348,14 +373,15 @@ end ---------------------------------------------------------------------- --- GB Tile Hashing & Deduplication +-- GB Tile Hashing & Deduplication (parameterized by tileSize) ---------------------------------------------------------------------- --- Hash an 8x8 tile at position (sx,sy) in source image -local function tileHash(img, sx, sy) +-- Hash a tile at position (sx,sy) in source image +local function tileHash(img, sx, sy, tileSize) + local ts = tileSize or S.gbTileSize local parts = {} - for y = 0, GB_TILE - 1 do - for x = 0, GB_TILE - 1 do + for y = 0, ts - 1 do + for x = 0, ts - 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)) @@ -368,10 +394,11 @@ local function tileHash(img, sx, sy) end -- Hash with horizontal flip -local function tileHashFlipH(img, sx, sy) +local function tileHashFlipH(img, sx, sy, tileSize) + local ts = tileSize or S.gbTileSize local parts = {} - for y = 0, GB_TILE - 1 do - for x = GB_TILE - 1, 0, -1 do + for y = 0, ts - 1 do + for x = ts - 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)) @@ -384,10 +411,11 @@ local function tileHashFlipH(img, sx, sy) end -- Hash with vertical flip -local function tileHashFlipV(img, sx, sy) +local function tileHashFlipV(img, sx, sy, tileSize) + local ts = tileSize or S.gbTileSize local parts = {} - for y = GB_TILE - 1, 0, -1 do - for x = 0, GB_TILE - 1 do + for y = ts - 1, 0, -1 do + for x = 0, ts - 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)) @@ -400,10 +428,11 @@ local function tileHashFlipV(img, sx, sy) end -- Hash with both flips -local function tileHashFlipHV(img, sx, sy) +local function tileHashFlipHV(img, sx, sy, tileSize) + local ts = tileSize or S.gbTileSize local parts = {} - for y = GB_TILE - 1, 0, -1 do - for x = GB_TILE - 1, 0, -1 do + for y = ts - 1, 0, -1 do + for x = ts - 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)) @@ -415,10 +444,11 @@ local function tileHashFlipHV(img, sx, sy) 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 +-- Check if a tile is fully transparent +local function isTileEmpty(img, sx, sy, tileSize) + local ts = tileSize or S.gbTileSize + for y = 0, ts - 1 do + for x = 0, ts - 1 do if pc.rgbaA(img:getPixel(sx + x, sy + y)) > 0 then return false end @@ -428,16 +458,14 @@ local function isTileEmpty(img, sx, sy) 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 +local function tilesMatchOffset(img, ax, ay, bx, by, ox, oy, flipH, flipV, tileSize) + local ts = tileSize or S.gbTileSize + local t = ts - 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 @@ -445,11 +473,9 @@ local function tilesMatchOffset(img, ax, ay, bx, by, ox, oy, flipH, flipV) 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 @@ -458,15 +484,15 @@ local function tilesMatchOffset(img, ax, ay, bx, by, ox, oy, flipH, flipV) 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 function analyzeTiles(img, flipOpt, offsetOpt, tileSize) + local ts = tileSize or S.gbTileSize + local tiles = {} + local hashIndex = {} local totalCount = 0 - local cols = math.floor(img.width / GB_TILE) - local rows = math.floor(img.height / GB_TILE) + local cols = math.floor(img.width / ts) + local rows = math.floor(img.height / ts) - -- Build flip modes for offset matching local flipModes = { {false, false, "none"} } if flipOpt then flipModes[2] = {true, false, "h"} @@ -476,27 +502,24 @@ local function analyzeTiles(img, flipOpt, offsetOpt) for row = 0, rows - 1 do for col = 0, cols - 1 do - local sx = col * GB_TILE - local sy = row * GB_TILE + local sx = col * ts + local sy = row * ts - -- Skip fully transparent tiles - if isTileEmpty(img, sx, sy) then goto continueAnalyze end + if isTileEmpty(img, sx, sy, ts) then goto continueAnalyze end - local hash = tileHash(img, sx, sy) + local hash = tileHash(img, sx, sy, ts) 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) + local hashH = tileHashFlipH(img, sx, sy, ts) + local hashV = tileHashFlipV(img, sx, sy, ts) + local hashHV = tileHashFlipHV(img, sx, sy, ts) if hashIndex[hashH] then table.insert(tiles[hashIndex[hashH]].positions, {x = sx, y = sy, flip = "h", ox = 0, oy = 0}) @@ -512,17 +535,16 @@ local function analyzeTiles(img, flipOpt, offsetOpt) 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 + for oy = -(ts - 1), ts - 1 do + for ox = -(ts - 1), ts - 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 + sx, sy, ox, oy, fm[1], fm[2], ts) then table.insert(tiles[ti].positions, {x = sx, y = sy, flip = fm[3], ox = ox, oy = oy}) found = true @@ -539,7 +561,6 @@ local function analyzeTiles(img, flipOpt, offsetOpt) 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 @@ -556,7 +577,7 @@ local function analyzeTilesTileMode(sprite, frameNum) local tiles = {} local hashIndex = {} local totalCount = 0 - local tileSize = GB_TILE + local tileSize = S.gbTileSize local ok, err = pcall(function() for _, layer in ipairs(sprite.layers) do @@ -576,7 +597,6 @@ local function analyzeTilesTileMode(sprite, frameNum) 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 @@ -590,7 +610,6 @@ local function analyzeTilesTileMode(sprite, frameNum) 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 @@ -605,13 +624,11 @@ local function analyzeTilesTileMode(sprite, frameNum) 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 @@ -638,23 +655,19 @@ local function analyzeTilesTileMode(sprite, frameNum) 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) +-- Compare two tile regions with offset and optional flip +local function tileSimilarityEx(img, ax, ay, bx, by, ox, oy, flipH, flipV, tileSize) + local ts = tileSize or S.gbTileSize local matches = 0 local paintedA = 0 - local t = GB_TILE - 1 + local t = ts - 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 @@ -669,11 +682,12 @@ local function tileSimilarityEx(img, ax, ay, bx, by, ox, oy, flipH, flipV) return matches, paintedA end --- Count non-transparent pixels in an 8x8 tile -local function countPaintedPixels(img, sx, sy) +-- Count non-transparent pixels in a tile +local function countPaintedPixels(img, sx, sy, tileSize) + local ts = tileSize or S.gbTileSize local count = 0 - for y = 0, GB_TILE - 1 do - for x = 0, GB_TILE - 1 do + for y = 0, ts - 1 do + for x = 0, ts - 1 do if pc.rgbaA(img:getPixel(sx + x, sy + y)) > 0 then count = count + 1 end @@ -683,8 +697,8 @@ local function countPaintedPixels(img, sx, sy) 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 function findSimilarTiles(img, tiles, thresholdPct, flipOpt, offsetOpt, tileSize) + local ts = tileSize or S.gbTileSize local results = {} local flipModes = { {false, false, "none"} } if flipOpt then @@ -696,18 +710,17 @@ local function findSimilarTiles(img, tiles, thresholdPct, flipOpt, offsetOpt) 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 + for oy = -(ts - 1), ts - 1 do + for ox = -(ts - 1), ts - 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) + paintedCount[i] = countPaintedPixels(img, p.x, p.y, ts) end for i = 1, #tiles do @@ -731,7 +744,7 @@ local function findSimilarTiles(img, tiles, thresholdPct, flipOpt, offsetOpt) 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]) + off[1], off[2], fm[1], fm[2], ts) if m > bestMatch then bestMatch = m bestFlip = fm[3] @@ -760,14 +773,9 @@ local function findSimilarTiles(img, tiles, thresholdPct, flipOpt, offsetOpt) 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) +-- Build optimized tileset image local function buildOptimizedImage(img, tiles, compress, silhouette, silhouetteColor, tileSize) - local ts = tileSize or GB_TILE + local ts = tileSize or S.gbTileSize local srcCols = math.floor(img.width / ts) if srcCols < 1 then srcCols = 1 end local numTiles = #tiles @@ -800,18 +808,15 @@ local function buildOptimizedImage(img, tiles, compress, silhouette, silhouetteC 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 @@ -824,7 +829,6 @@ local function buildOptimizedImage(img, tiles, compress, silhouette, silhouetteC end end - -- Draw duplicate positions as silhouettes if silhouette and silhouetteColor then local silR = silhouetteColor.red local silG = silhouetteColor.green @@ -852,6 +856,12 @@ local function buildOptimizedImage(img, tiles, compress, silhouette, silhouetteC end end +---------------------------------------------------------------------- +-- Separate preview window (module-level to persist) +---------------------------------------------------------------------- +local previewDlg = nil +local previewTimer = nil + ---------------------------------------------------------------------- -- Dialog ---------------------------------------------------------------------- @@ -883,7 +893,7 @@ local function run() local contentW, contentH = getSourceContentSize() local viewW = math.min(contentW, SOURCE_VIEWPORT_W) - local viewH = math.min(contentH, S.sourceViewportH) + local viewH = math.min(contentH, SOURCE_VIEWPORT_H) local dlg = Dialog{ title = "AniPhallow - Animation Builder", @@ -914,15 +924,6 @@ local function run() return nil end - local function updateAnimLabel() - for i = 1, #S.animNames do - pcall(function() - local label = (i == S.currentAnim) and ("[" .. S.animNames[i] .. "]") or S.animNames[i] - dlg:modify{ id = "btnAnim" .. i, text = label } - end) - end - end - local function startAnimTimer() if animTimer then animTimer:stop() end animTimer = Timer{ @@ -958,17 +959,13 @@ local function run() local isRender = (tab == "Preview") local isGB = (tab == "GB") for _, id in ipairs(SETUP_IDS) do - dlg:modify{ id = id, visible = isSetup } + pcall(function() dlg:modify{ id = id, visible = isSetup } end) end for _, id in ipairs(RENDER_IDS) do - dlg:modify{ id = id, visible = isRender } + pcall(function() dlg:modify{ id = id, visible = isRender } end) 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) + pcall(function() dlg:modify{ id = id, visible = isGB } end) end -- Update tab button labels for _, t in ipairs(TABS) do @@ -1010,10 +1007,8 @@ local function run() 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:number{ id = "sourceViewportH", label = "Source Height (px):", text = tostring(S.sourceViewportH), decimals = 0 } - d:check{ id = "sourceAutoResize", text = "Auto-resize source with zoom", selected = S.sourceAutoResize } d:separator{ text = "GB Options" } - -- Auto-detect darkest palette color on first open + d:number{ id = "gbTileSize", label = "GB Tile Size:", text = tostring(S.gbTileSize), decimals = 0 } local silColor = S.gbSilhouetteColor if not S.gbSilhouetteColorSet then silColor = getDarkestPaletteColor() @@ -1038,10 +1033,9 @@ local function run() S.previewZoom = pz S.useBgColor = d.data.useBgColor S.bgColor = d.data.bgColor - local svh = d.data.sourceViewportH - if svh < 50 then svh = 50 elseif svh > 600 then svh = 600 end - S.sourceViewportH = svh - S.sourceAutoResize = d.data.sourceAutoResize + local gts = d.data.gbTileSize + if gts < 1 then gts = 1 elseif gts > 128 then gts = 128 end + S.gbTileSize = gts S.gbSilhouetteColor = d.data.silhouetteColor S.gbSilhouetteColorSet = true S.gbLayerName = d.data.layerName @@ -1052,13 +1046,30 @@ local function run() } ---------------------------------------------------------------- - -- Animation selector (dynamic) + -- Animation selector (combobox, sorted alphabetically) ---------------------------------------------------------------- dlg:separator{ id = "sepAnims", text = "Animations" } + dlg:combobox{ + id = "cmbAnimList", + option = currentAnimName() or "", + options = S.animNames, + onchange = function() + local selected = dlg.data.cmbAnimList + for i, name in ipairs(S.animNames) do + if name == selected then + S.currentAnim = i + S.selectedFrame = 0 + dlg:repaint() + break + end + end + end + } + dlg:button{ id = "btnNewAnim", - text = "Add Animation", + text = "Add", onclick = function() local d = Dialog{ title = "New Animation" } d:entry{ id = "name", label = "Name:", text = "" } @@ -1070,7 +1081,11 @@ local function run() if name and name ~= "" and not S.anims[name] then table.insert(S.animNames, name) S.anims[name] = {} - S.currentAnim = #S.animNames + -- Re-sort alphabetically + table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) + for i, n in ipairs(S.animNames) do + if n == name then S.currentAnim = i; break end + end savePrefs() dlg:close() run() @@ -1081,7 +1096,7 @@ local function run() dlg:button{ id = "btnDelAnim", - text = "Remove Animation", + text = "Remove", onclick = function() local name = currentAnimName() if not name then return end @@ -1102,54 +1117,40 @@ local function run() } dlg:button{ - id = "btnMoveAnimUp", - text = "Move Up", + id = "btnRenameAnim", + text = "Rename", onclick = function() - if S.currentAnim <= 1 then return end - local i = S.currentAnim - S.animNames[i], S.animNames[i - 1] = S.animNames[i - 1], S.animNames[i] - S.currentAnim = i - 1 - savePrefs() - dlg:close() - run() - end - } - - dlg:button{ - id = "btnMoveAnimDown", - text = "Move Down", - onclick = function() - if S.currentAnim < 1 or S.currentAnim >= #S.animNames then return end - local i = S.currentAnim - S.animNames[i], S.animNames[i + 1] = S.animNames[i + 1], S.animNames[i] - S.currentAnim = i + 1 - savePrefs() - dlg:close() - run() - end - } - - dlg:newrow() - - -- Create buttons for existing animations (selected one gets [brackets]) - for i = 1, #S.animNames do - local label = (i == S.currentAnim) and ("[" .. S.animNames[i] .. "]") or S.animNames[i] - dlg:button{ - id = "btnAnim" .. i, - text = label, - onclick = function() - S.currentAnim = i - S.selectedFrame = 0 - updateAnimLabel() - dlg:repaint() + local name = currentAnimName() + if not name then return end + local d = Dialog{ title = "Rename Animation" } + d:entry{ id = "newName", label = "New name:", text = name } + d:button{ id = "ok", text = "OK" } + d:button{ text = "Cancel" } + d:show() + if d.data.ok then + local newName = d.data.newName + if newName and newName ~= "" and newName ~= name and not S.anims[newName] then + -- Transfer animation data + S.anims[newName] = S.anims[name] + S.anims[name] = nil + S.animNames[S.currentAnim] = newName + -- Re-sort alphabetically + table.sort(S.animNames, function(a, b) return a:lower() < b:lower() end) + for i, n in ipairs(S.animNames) do + if n == newName then S.currentAnim = i; break end + end + savePrefs() + dlg:close() + run() + end end - } - end + end + } ---------------------------------------------------------------- - -- Source canvas (L-click=add, R-click=add flipped, M-click=scroll) + -- Source canvas (L-click=add, R-click=add flipped) ---------------------------------------------------------------- - dlg:separator{ id = "sepSource", text = "Source (left-click=add frame, right-click=add flipped)" } + dlg:separator{ id = "sepSource", text = "Source (L-click=add frame, R-click=add flipped)" } dlg:canvas{ id = "canvasSource", @@ -1256,10 +1257,6 @@ local function run() local dz = ev.deltaY < 0 and 1 or -1 S.sourceZoom = math.max(1, math.min(10, S.sourceZoom + dz)) clampScroll() - if S.sourceAutoResize then - local newH = math.max(60, math.floor(S.sourceViewportH / S.sourceZoom)) - dlg:modify{ id = "canvasSource", height = newH } - end dlg:repaint() end } @@ -1267,7 +1264,7 @@ local function run() ---------------------------------------------------------------- -- Frame strip for current animation ---------------------------------------------------------------- - dlg:separator{ id = "sepFrames", text = "Frames (left-click=select, right-click=remove, drag=reorder)" } + dlg:separator{ id = "sepFrames", text = "Frames (L-click=select, R-click=remove, drag=reorder)" } local STRIP_TOTAL_W = SOURCE_VIEWPORT_W local STRIP_CELL = THUMB_SIZE + 1 @@ -1312,10 +1309,16 @@ local function run() local fimg = getFrameImage(name, i) if fimg then + -- Maintain aspect ratio within thumbnail + local scale = math.min(THUMB_SIZE / S.tileW, THUMB_SIZE / S.tileH) + local dw = math.max(1, math.floor(S.tileW * scale)) + local dh = math.max(1, math.floor(S.tileH * scale)) + local dx = tx + math.floor((THUMB_SIZE - dw) / 2) + local dy = ty + math.floor((THUMB_SIZE - dh) / 2) gc:drawImage( fimg, Rectangle(0, 0, S.tileW, S.tileH), - Rectangle(tx, ty, THUMB_SIZE, THUMB_SIZE) + Rectangle(dx, dy, dw, dh) ) end @@ -1481,10 +1484,35 @@ local function run() text = "Remove", onclick = function() local name = currentAnimName() - if not name or S.selectedFrame < 1 then return end + if not name then return end local frames = S.anims[name] - if not frames or S.selectedFrame > #frames then return end - table.remove(frames, S.selectedFrame) + if not frames then return end + + if #frames == 0 then + -- No frames: delete the animation (like Remove Animation) + local result = app.alert{ + title = "Delete Animation", + text = "Animation '" .. name .. "' has no frames. Delete it?", + buttons = { "Delete", "Cancel" } + } + if result ~= 1 then return end + S.anims[name] = nil + table.remove(S.animNames, S.currentAnim) + S.currentAnim = math.min(S.currentAnim, math.max(1, #S.animNames)) + S.selectedFrame = 0 + savePrefs() + dlg:close() + run() + return + end + + local idx = S.selectedFrame + if idx < 1 then + -- No frame selected: remove the last frame + idx = #frames + end + if idx > #frames then return end + table.remove(frames, idx) if S.selectedFrame > #frames then S.selectedFrame = #frames end @@ -1493,82 +1521,103 @@ local function run() end } - dlg:button{ - id = "btnClearAnim", - text = "Clear Anim", - onclick = function() - local name = currentAnimName() - if not name then return end - local result = app.alert{ - title = "Clear Animation", - text = "Clear all frames from '" .. name .. "'?", - buttons = { "Clear", "Cancel" } - } - if result ~= 1 then return end - S.anims[name] = {} - S.selectedFrame = 0 - dlg:repaint() - end - } - ---------------------------------------------------------------- - -- Preview canvas (animation playback in Animations tab) - ---------------------------------------------------------------- - dlg:separator{ id = "sepPreviewAnim", text = "Preview" } - - local PREVIEW_ANIM_H = 60 - - dlg:canvas{ - id = "canvasPreviewAnim", - width = SOURCE_VIEWPORT_W, - height = PREVIEW_ANIM_H, - autoscaling = false, - onpaint = function(ev) - local gc = ev.context - local name = currentAnimName() - - gc.color = Color(30, 30, 30) - gc:fillRect(Rectangle(0, 0, SOURCE_VIEWPORT_W, PREVIEW_ANIM_H)) - - if not name then return end - local frames = S.anims[name] or {} - local totalFrames = #frames - if totalFrames == 0 then return end - - local tw = S.tileW * S.previewZoom - local th = S.tileH * S.previewZoom - local ox = math.max(0, math.floor((SOURCE_VIEWPORT_W - tw) / 2)) - local oy = math.max(0, math.floor((PREVIEW_ANIM_H - th) / 2)) - - if S.useBgColor then - gc.color = S.bgColor - gc:fillRect(Rectangle(ox, oy, tw, th)) - else - drawCheckerboard(gc, tw, th, S.previewZoom, ox, oy) - end - - 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, oy, tw, th) - ) - 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 - } - - ---------------------------------------------------------------- - -- RENDER TAB (single canvas with all animations in 2 columns) + -- RENDER TAB (Preview - all animations) ---------------------------------------------------------------- dlg:separator{ id = "sepRender", text = "All Animations Preview", visible = false } + dlg:button{ + id = "btnSeparateWindow", + text = "Separate Window", + visible = false, + onclick = function() + -- Close existing preview window if open + if previewDlg then + pcall(function() previewDlg:close() end) + end + if previewTimer then + pcall(function() previewTimer:stop() end) + end + + local pvAnimFrame = { value = 0 } + local RENDER_MARGIN = 2 + + previewDlg = Dialog{ + title = "AniPhallow Preview", + onclose = function() + if previewTimer then + pcall(function() previewTimer:stop() end) + end + previewTimer = nil + previewDlg = nil + end + } + + previewDlg:canvas{ + id = "pvCanvas", + width = SOURCE_VIEWPORT_W, + height = SOURCE_VIEWPORT_H, + autoscaling = 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 af = pvAnimFrame.value + + 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 + + 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 + + local frames = S.anims[name] or {} + local totalFrames = #frames + if totalFrames > 0 then + local idx = (af % 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)) + pcall(function() previewDlg:repaint() end) + end + } + + previewTimer = Timer{ + interval = S.animSpeed / 1000.0, + ontick = function() + pvAnimFrame.value = pvAnimFrame.value + 1 + pcall(function() previewDlg:repaint() end) + end + } + previewTimer:start() + previewDlg:show{ wait = false } + end + } + local RENDER_MARGIN = 2 dlg:canvas{ @@ -1587,7 +1636,6 @@ local function run() 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 @@ -1595,7 +1643,6 @@ local function run() 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)) @@ -1604,7 +1651,6 @@ local function run() ox + RENDER_MARGIN, oy + RENDER_MARGIN) end - -- Draw current frame local frames = S.anims[name] or {} local totalFrames = #frames if totalFrames > 0 then @@ -1668,20 +1714,21 @@ local function run() visible = false, onchange = function() S.gbAnalyzeMode = dlg.data.gbAnalyzeMode end } - -- Row: Analyze Duplicates + Analyze Similars + Similarity threshold (all on one line) + + -- Row: Analyze + Find Similars + Threshold dlg:button{ id = "btnAnalyze", - text = "Analyze Duplicates", + text = "Analyze", visible = false, onclick = function() if not S.sourceImage then return end - local usedTileSize = GB_TILE + local usedTileSize = S.gbTileSize 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) + S.gbTiles, S.gbTotalTiles = analyzeTiles(S.sourceImage, S.gbFlipOpt, S.gbOffsetOpt, usedTileSize) end S.gbTileSize = usedTileSize S.gbOptImage, S.gbCols = buildOptimizedImage( @@ -1692,7 +1739,7 @@ local function run() S.gbScrollY = 0 local unique = #S.gbTiles local total = S.gbTotalTiles - dlg:modify{ id = "btnAnalyze", text = "Analyze Duplicates (" .. unique .. "/" .. total .. ")" } + dlg:modify{ id = "btnAnalyze", text = "Analyze (" .. unique .. "/" .. total .. ")" } if S.gbOptImage then local z = S.gbZoomOpt dlg:modify{ id = "canvasGbOpt", @@ -1704,7 +1751,7 @@ local function run() } dlg:button{ id = "btnFindSimilar", - text = "Analyze Similars", + text = "Find similars", visible = false, onclick = function() if #S.gbTiles == 0 then @@ -1713,15 +1760,15 @@ local function run() end S.gbSimilarPairs = findSimilarTiles( S.sourceImage, S.gbTiles, S.gbSimilarThreshold, - S.gbFlipOpt, S.gbOffsetOpt) + S.gbFlipOpt, S.gbOffsetOpt, S.gbTileSize) dlg:modify{ id = "btnFindSimilar", - text = "Analyze Similars (" .. #S.gbSimilarPairs .. ")" } + text = "Find similars (" .. #S.gbSimilarPairs .. ")" } dlg:repaint() end } dlg:button{ id = "gbSimilarThreshold", - text = "Similarity >=" .. S.gbSimilarThreshold .. "%", + text = "Threshold " .. S.gbSimilarThreshold .. "%", visible = false, onclick = function() local d = Dialog{ title = "Similarity Threshold" } @@ -1735,14 +1782,179 @@ local function run() if v < 1 then v = 1 end if v > 100 then v = 100 end S.gbSimilarThreshold = v - dlg:modify{ id = "gbSimilarThreshold", text = "Similarity >=" .. v .. "%" } + dlg:modify{ id = "gbSimilarThreshold", text = "Threshold " .. v .. "%" } end end } - dlg:label{ id = "lblSimilarStats", text = "", visible = false } - -- Optimized tileset canvas - dlg:separator{ id = "sepGbOpt", text = "Optimized Tileset (R-click to select)", visible = false } + ---------------------------------------------------------------- + -- Occurrences in Source canvas (BEFORE Optimized) + ---------------------------------------------------------------- + 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 + + 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 + gc.color = Color(c.red, c.green, c.blue, 60) + gc:fillRect(Rectangle(rx, ry, rw, rh)) + gc.color = c + gc:fillRect(Rectangle(rx, ry, rw, 2)) + gc:fillRect(Rectangle(rx, ry + rh - 2, rw, 2)) + gc:fillRect(Rectangle(rx, ry, 2, rh)) + gc:fillRect(Rectangle(rx + rw - 2, ry, 2, rh)) + end + end + end + end, + onmousedown = function(ev) + if ev.button == MouseButton.LEFT then + S.gbSrcClickX = ev.x + S.gbSrcClickY = ev.y + S.gbSrcIsDrag = false + S.gbSrcDragging = true + S.gbSrcDragLastX = ev.x + S.gbSrcDragLastY = ev.y + end + end, + onmousemove = function(ev) + if S.gbSrcDragging then + local dx = math.abs(ev.x - S.gbSrcClickX) + local dy = math.abs(ev.y - S.gbSrcClickY) + if dx > 3 or dy > 3 then + S.gbSrcIsDrag = true + end + if S.gbSrcIsDrag then + S.gbSrcScrollX = S.gbSrcScrollX + (S.gbSrcDragLastX - ev.x) + S.gbSrcScrollY = S.gbSrcScrollY + (S.gbSrcDragLastY - ev.y) + S.gbSrcScrollX = math.max(0, S.gbSrcScrollX) + S.gbSrcScrollY = math.max(0, S.gbSrcScrollY) + end + S.gbSrcDragLastX = ev.x + S.gbSrcDragLastY = ev.y + end + end, + onmouseup = function(ev) + if S.gbSrcDragging and not S.gbSrcIsDrag then + -- Click: select tile at this position + local z = S.gbZoomSrc + local ts = S.gbTileSize + local pixelX = math.floor((S.gbSrcClickX + S.gbSrcScrollX) / z) + local pixelY = math.floor((S.gbSrcClickY + S.gbSrcScrollY) / z) + + -- Find all tiles whose positions cover this pixel + local candidates = {} + for i, tile in ipairs(S.gbTiles) do + for _, pos in ipairs(tile.positions) do + if pixelX >= pos.x and pixelX < pos.x + ts and + pixelY >= pos.y and pixelY < pos.y + ts then + -- Check if pixel is non-transparent at this position + local isOpaque = false + if S.sourceImage and pixelX >= 0 and pixelX < S.sourceImage.width + and pixelY >= 0 and pixelY < S.sourceImage.height then + isOpaque = pc.rgbaA(S.sourceImage:getPixel(pixelX, pixelY)) > 0 + end + table.insert(candidates, { tileIdx = i, opaque = isOpaque }) + end + end + end + + if #candidates > 0 then + -- Sort: opaque tiles first + table.sort(candidates, function(a, b) + if a.opaque ~= b.opaque then return a.opaque end + return a.tileIdx < b.tileIdx + end) + + -- Cycle through candidates if current selection is among them + local found = false + for ci, cand in ipairs(candidates) do + if cand.tileIdx == S.gbSelectedTile then + -- Select next candidate + local nextIdx = (ci % #candidates) + 1 + S.gbSelectedTile = candidates[nextIdx].tileIdx + found = true + break + end + end + if not found then + S.gbSelectedTile = candidates[1].tileIdx + end + end + end + S.gbSrcDragging = false + dlg:repaint() + end, + onwheel = function(ev) + local dz = ev.deltaY < 0 and 1 or -1 + S.gbZoomSrc = math.max(1, math.min(10, S.gbZoomSrc + dz)) + dlg:repaint() + end + } + + ---------------------------------------------------------------- + -- Optimized Spritesheet canvas (L-click to select) + ---------------------------------------------------------------- + dlg:separator{ id = "sepGbOpt", text = "Optimized Spritesheet (L-click to select)", visible = false } local GB_OPT_W = SOURCE_VIEWPORT_W local GB_OPT_H = 120 @@ -1756,9 +1968,8 @@ local function run() onpaint = function(ev) local gc = ev.context local z = S.gbZoomOpt - local ts = S.gbTileSize or GB_TILE + local ts = S.gbTileSize - -- Background gc.color = Color(30, 30, 30) gc:fillRect(Rectangle(0, 0, GB_OPT_W, GB_OPT_H)) @@ -1804,7 +2015,6 @@ local function run() -- Highlight tiles involved in similar pairs with distinct group colors if #S.gbSimilarPairs > 0 then - -- Build groups using union-find local parent = {} local function find(x) if not parent[x] then parent[x] = x end @@ -1823,27 +2033,25 @@ local function run() union(pair.i, pair.j) end - -- Assign color index to each group root local groupColors = {} local colorIdx = 0 - -- Predefined distinct hues local GROUP_HUES = { - Color(255, 0, 255, 150), -- magenta - Color(0, 200, 255, 150), -- cyan - Color(255, 128, 0, 150), -- orange - Color(0, 255, 128, 150), -- spring green - Color(255, 255, 0, 150), -- yellow - Color(128, 0, 255, 150), -- purple - Color(255, 0, 128, 150), -- pink - Color(0, 255, 0, 150), -- green - Color(0, 128, 255, 150), -- blue - Color(255, 64, 64, 150), -- red - Color(128, 255, 0, 150), -- lime - Color(255, 0, 64, 150), -- crimson - Color(0, 255, 255, 150), -- aqua - Color(200, 100, 255, 150), -- lavender - Color(255, 200, 0, 150), -- gold - Color(100, 255, 200, 150), -- mint + Color(255, 0, 255, 150), + Color(0, 200, 255, 150), + Color(255, 128, 0, 150), + Color(0, 255, 128, 150), + Color(255, 255, 0, 150), + Color(128, 0, 255, 150), + Color(255, 0, 128, 150), + Color(0, 255, 0, 150), + Color(0, 128, 255, 150), + Color(255, 64, 64, 150), + Color(128, 255, 0, 150), + Color(255, 0, 64, 150), + Color(0, 255, 255, 150), + Color(200, 100, 255, 150), + Color(255, 200, 0, 150), + Color(100, 255, 200, 150), } local similarSet = {} @@ -1872,7 +2080,6 @@ local function run() 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 @@ -1910,17 +2117,43 @@ local function run() end, onmousedown = function(ev) if ev.button == MouseButton.LEFT then - -- LEFT = drag scroll + -- Click vs drag detection + S.gbOptClickX = ev.x + S.gbOptClickY = ev.y + S.gbOptIsDrag = false S.gbDragging = true S.gbDragLastX = ev.x S.gbDragLastY = ev.y - elseif ev.button == MouseButton.RIGHT then - -- RIGHT = select tile - if not S.gbOptImage then return end + end + end, + onmousemove = function(ev) + if S.gbDragging then + local dx = math.abs(ev.x - S.gbOptClickX) + local dy = math.abs(ev.y - S.gbOptClickY) + if dx > 3 or dy > 3 then + S.gbOptIsDrag = true + end + if S.gbOptIsDrag then + S.gbScrollX = S.gbScrollX + (S.gbDragLastX - ev.x) + S.gbScrollY = S.gbScrollY + (S.gbDragLastY - ev.y) + S.gbScrollX = math.max(0, S.gbScrollX) + S.gbScrollY = math.max(0, S.gbScrollY) + end + S.gbDragLastX = ev.x + S.gbDragLastY = ev.y + end + end, + onmouseup = function(ev) + if S.gbDragging and not S.gbOptIsDrag then + -- Click: select tile + if not S.gbOptImage then + S.gbDragging = false + 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) + local ts = S.gbTileSize + local pixelX = math.floor((S.gbOptClickX + S.gbScrollX) / z) + local pixelY = math.floor((S.gbOptClickY + S.gbScrollY) / z) if S.gbCompress then local col = math.floor(pixelX / ts) @@ -1930,7 +2163,6 @@ local function run() 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 @@ -1941,20 +2173,7 @@ local function run() 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, @@ -1965,114 +2184,6 @@ local function run() 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", @@ -2089,13 +2200,10 @@ local function run() 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 @@ -2142,7 +2250,7 @@ local function run() end } - -- Copy to clipboard button + -- Copy to clipboard button (preserves transparency) dlg:button{ id = "btnGbCopyClipboard", text = "Copy to Clipboard", @@ -2156,11 +2264,19 @@ local function run() local optW = S.gbOptImage.width local optH = S.gbOptImage.height local tmpSprite = Sprite(optW, optH, ColorMode.RGB) + -- Convert background layer to normal layer to preserve transparency + app.command.LayerFromBackground() local cel = tmpSprite.cels[1] - cel.image = S.gbOptImage:clone() + local img = Image(optW, optH, ColorMode.RGB) + img:clear() + img:drawImage(S.gbOptImage, Point(0, 0)) + cel.image = img app.command.MaskAll() app.command.CopyMerged() tmpSprite:close() + if origSprite then + app.sprite = origSprite + end end } @@ -2208,18 +2324,6 @@ local function run() end } - ---------------------------------------------------------------- - -- CLOSE BUTTON (always visible) - ---------------------------------------------------------------- - dlg:newrow() - dlg:separator() - dlg:button{ - text = "Close", - onclick = function() - dlg:close() - end - } - ---------------------------------------------------------------- -- Timers ---------------------------------------------------------------- @@ -2238,7 +2342,6 @@ local function run() 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