Improve animations tab, fix Save as Layer, and enhance GB similarity colors

- Fix Save as Layer: convert image to sprite color mode before creating cel
- Remove "Selected ->" label (brackets on buttons suffice)
- Add Remove button and right-click to remove frames in strip
- Add Move Animation Up/Down buttons for reordering animations
- Add configurable source viewport height and auto-resize with zoom
- Rename GB buttons: Analyze Duplicates, Analyze Similars, Similarity >=X%
- Reorder GB buttons: Analyze -> Similars -> Similarity threshold
- Use distinct colors per group for similar tile highlighting (union-find)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cidwel Highwind 2026-04-03 13:46:31 +02:00
parent 534fc09da3
commit 0c1d5d9c90
1 changed files with 155 additions and 43 deletions

View File

@ -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