diff --git a/aniphallow.lua b/aniphallow.lua index 0b7dd0a..a48ab31 100644 --- a/aniphallow.lua +++ b/aniphallow.lua @@ -80,6 +80,8 @@ local S = { 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 -- Dynamic animations: each frame is {x, y, flipped, flippedV} animNames = {}, -- ordered list of animation names anims = {}, -- name -> list of {x, y, flipped, flippedV} @@ -100,10 +102,10 @@ local S = { -- Setup tab elements (static IDs, dynamic ones added at runtime) local SETUP_IDS = { "sepAnims", "btnNewAnim", "btnDelAnim", - "lblSelectedAnim", + "btnMoveAnimUp", "btnMoveAnimDown", "sepSource", "canvasSource", "sepFrames", "canvasStrips", - "sepActions", "btnFlipX", "btnFlipY", "btnMoveLeft", "btnMoveRight", "btnClearAnim", + "sepActions", "btnFlipX", "btnFlipY", "btnMoveLeft", "btnMoveRight", "btnRemoveFrame", "btnClearAnim", "sepPreviewAnim", "canvasPreviewAnim", } @@ -195,6 +197,8 @@ 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 -- Dynamic anim frames: anim_0, anim_1, ... local animIdx = k and string.match(k, "^anim_(%d+)$") if animIdx then @@ -237,6 +241,8 @@ 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") for i, name in ipairs(S.animNames) do f:write("anim_" .. (i - 1) .. "=" .. serializeFrames(S.anims[name] or {}) .. "\n") end @@ -310,7 +316,7 @@ 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) + local maxScrollY = math.max(0, contentH - S.sourceViewportH) S.scrollX = clamp(S.scrollX, 0, maxScrollX) S.scrollY = clamp(S.scrollY, 0, maxScrollY) end @@ -877,7 +883,7 @@ local function run() local contentW, contentH = getSourceContentSize() local viewW = math.min(contentW, SOURCE_VIEWPORT_W) - local viewH = math.min(contentH, SOURCE_VIEWPORT_H) + local viewH = math.min(contentH, S.sourceViewportH) local dlg = Dialog{ title = "AniPhallow - Animation Builder", @@ -909,8 +915,6 @@ local function run() end local function updateAnimLabel() - local name = currentAnimName() or "(none)" - dlg:modify{ id = "lblSelectedAnim", text = "Selected -> " .. name } for i = 1, #S.animNames do pcall(function() local label = (i == S.currentAnim) and ("[" .. S.animNames[i] .. "]") or S.animNames[i] @@ -1006,6 +1010,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 local silColor = S.gbSilhouetteColor @@ -1032,6 +1038,10 @@ 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 S.gbSilhouetteColor = d.data.silhouetteColor S.gbSilhouetteColorSet = true S.gbLayerName = d.data.layerName @@ -1091,11 +1101,32 @@ local function run() end } - dlg:newrow() + dlg:button{ + id = "btnMoveAnimUp", + text = "Move Up", + 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:label{ - id = "lblSelectedAnim", - text = "Selected -> " .. (currentAnimName() or "(none)"), + 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() @@ -1225,6 +1256,10 @@ 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 } @@ -1232,7 +1267,7 @@ local function run() ---------------------------------------------------------------- -- Frame strip for current animation ---------------------------------------------------------------- - dlg:separator{ id = "sepFrames", text = "Frames (click to select, drag to reorder)" } + dlg:separator{ id = "sepFrames", text = "Frames (left-click=select, right-click=remove, drag=reorder)" } local STRIP_TOTAL_W = SOURCE_VIEWPORT_W local STRIP_CELL = THUMB_SIZE + 1 @@ -1339,6 +1374,13 @@ local function run() S.frameDragFrom = idx S.frameDragTo = idx dlg:repaint() + elseif ev.button == MouseButton.RIGHT then + table.remove(frames, idx) + if S.selectedFrame > #frames then + S.selectedFrame = #frames + end + if #frames == 0 then S.selectedFrame = 0 end + dlg:repaint() end end end, @@ -1434,6 +1476,23 @@ local function run() end } + dlg:button{ + id = "btnRemoveFrame", + text = "Remove", + onclick = function() + local name = currentAnimName() + if not name or S.selectedFrame < 1 then return end + local frames = S.anims[name] + if not frames or S.selectedFrame > #frames then return end + table.remove(frames, S.selectedFrame) + if S.selectedFrame > #frames then + S.selectedFrame = #frames + end + if #frames == 0 then S.selectedFrame = 0 end + dlg:repaint() + end + } + dlg:button{ id = "btnClearAnim", text = "Clear Anim", @@ -1609,10 +1668,10 @@ local function run() visible = false, onchange = function() S.gbAnalyzeMode = dlg.data.gbAnalyzeMode end } - -- Row: Analyze + >=threshold + Similar (all on one line) + -- Row: Analyze Duplicates + Analyze Similars + Similarity threshold (all on one line) dlg:button{ id = "btnAnalyze", - text = "Analyze", + text = "Analyze Duplicates", visible = false, onclick = function() if not S.sourceImage then return end @@ -1633,7 +1692,7 @@ local function run() S.gbScrollY = 0 local unique = #S.gbTiles local total = S.gbTotalTiles - dlg:modify{ id = "btnAnalyze", text = "Analyze (" .. unique .. "/" .. total .. ")" } + dlg:modify{ id = "btnAnalyze", text = "Analyze Duplicates (" .. unique .. "/" .. total .. ")" } if S.gbOptImage then local z = S.gbZoomOpt dlg:modify{ id = "canvasGbOpt", @@ -1643,9 +1702,26 @@ local function run() dlg:repaint() end } + dlg:button{ + id = "btnFindSimilar", + text = "Analyze Similars", + visible = false, + onclick = function() + if #S.gbTiles == 0 then + app.alert("Run Analyze first.") + return + end + S.gbSimilarPairs = findSimilarTiles( + S.sourceImage, S.gbTiles, S.gbSimilarThreshold, + S.gbFlipOpt, S.gbOffsetOpt) + dlg:modify{ id = "btnFindSimilar", + text = "Analyze Similars (" .. #S.gbSimilarPairs .. ")" } + dlg:repaint() + end + } dlg:button{ id = "gbSimilarThreshold", - text = ">=" .. S.gbSimilarThreshold .. "%", + text = "Similarity >=" .. S.gbSimilarThreshold .. "%", visible = false, onclick = function() local d = Dialog{ title = "Similarity Threshold" } @@ -1659,27 +1735,10 @@ 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 = ">=" .. v .. "%" } + dlg:modify{ id = "gbSimilarThreshold", text = "Similarity >=" .. 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 @@ -1743,22 +1802,70 @@ local function run() end end - -- Highlight tiles involved in similar pairs + -- 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 + while parent[x] ~= x do + parent[x] = parent[parent[x]] + x = parent[x] + end + return x + end + local function union(a, b) + local ra, rb = find(a), find(b) + if ra ~= rb then parent[ra] = rb end + end + + for _, pair in ipairs(S.gbSimilarPairs) do + union(pair.i, pair.j) + end + + -- 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 + } + local similarSet = {} for _, pair in ipairs(S.gbSimilarPairs) do - similarSet[pair.i] = true - similarSet[pair.j] = true + local root = find(pair.i) + if not groupColors[root] then + colorIdx = colorIdx + 1 + groupColors[root] = GROUP_HUES[((colorIdx - 1) % #GROUP_HUES) + 1] + end + similarSet[pair.i] = groupColors[find(pair.i)] + similarSet[pair.j] = groupColors[find(pair.j)] end + local rw = ts * z local rh = ts * z - for idx, _ in pairs(similarSet) do + for idx, c 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.color = Color(c.red, c.green, c.blue, 40) gc:fillRect(Rectangle(srx, sry, rw, rh)) - gc.color = Color(255, 0, 255, 150) + gc.color = c gc:fillRect(Rectangle(srx, sry, rw, 1)) gc:fillRect(Rectangle(srx, sry + rh - 1, rw, 1)) gc:fillRect(Rectangle(srx, sry, 1, rh)) @@ -1993,7 +2100,9 @@ local function run() end end - local imgCopy = S.gbOptImage:clone() + local imgConverted = Image(S.gbOptImage.width, S.gbOptImage.height, sp.colorMode) + imgConverted:clear() + imgConverted:drawImage(S.gbOptImage, Point(0, 0)) app.transaction("Save Optimized Layer", function() if existingLayer then if S.gbAlwaysOverwrite then @@ -2001,7 +2110,8 @@ local function run() for _, cel in ipairs(existingLayer.cels) do sp:deleteCel(cel) end - sp:newCel(existingLayer, app.frame, imgCopy, Point(0, 0)) + sp:newCel(existingLayer, app.frame, imgConverted, Point(0, 0)) + existingLayer.isEditable = false else local suffix = 2 local newName = layerName .. "-" .. suffix @@ -2019,12 +2129,14 @@ local function run() end local newLayer = sp:newLayer() newLayer.name = newName - sp:newCel(newLayer, app.frame, imgCopy, Point(0, 0)) + sp:newCel(newLayer, app.frame, imgConverted, Point(0, 0)) + newLayer.isEditable = false end else local newLayer = sp:newLayer() newLayer.name = layerName - sp:newCel(newLayer, app.frame, imgCopy, Point(0, 0)) + sp:newCel(newLayer, app.frame, imgConverted, Point(0, 0)) + newLayer.isEditable = false end end) end