diff --git a/aniphallow.lua b/aniphallow.lua index cbab7aa..0b7dd0a 100644 --- a/aniphallow.lua +++ b/aniphallow.lua @@ -23,7 +23,7 @@ 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 TABS = { "Animations", "Preview", "GB" } local GB_TILE = 8 -- Game Boy tile size (fixed 8x8) local GB_COLS = 16 -- tiles per row in optimized image (128px = GB standard) @@ -40,7 +40,7 @@ local S = { sourceZoom = DEFAULT_SOURCE_ZOOM, animFrame = 0, currentAnim = 1, -- index into animNames - currentTab = "Setup", + currentTab = "Animations", sourceImage = nil, scrollX = 0, scrollY = 0, @@ -80,9 +80,18 @@ 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) - -- Dynamic animations: each frame is {x, y, flipped} + -- Dynamic animations: each frame is {x, y, flipped, flippedV} animNames = {}, -- ordered list of animation names - anims = {}, -- name -> list of {x, y, flipped} + anims = {}, -- name -> list of {x, y, flipped, flippedV} + -- Frame selection & drag + selectedFrame = 0, + frameDragging = false, + frameDragFrom = 0, + frameDragTo = 0, + -- Source canvas click vs drag + srcClickX = 0, + srcClickY = 0, + srcIsDrag = false, } ---------------------------------------------------------------------- @@ -90,11 +99,12 @@ local S = { ---------------------------------------------------------------------- -- Setup tab elements (static IDs, dynamic ones added at runtime) local SETUP_IDS = { - "btnConfig", - "sepAnims", "lblCurrentAnim", "btnNewAnim", "btnDelAnim", + "sepAnims", "btnNewAnim", "btnDelAnim", + "lblSelectedAnim", "sepSource", "canvasSource", "sepFrames", "canvasStrips", - "sepActions", "btnClearAnim", "btnClearAll", + "sepActions", "btnFlipX", "btnFlipY", "btnMoveLeft", "btnMoveRight", "btnClearAnim", + "sepPreviewAnim", "canvasPreviewAnim", } -- Render tab elements (static, dynamic anim canvases handled separately) @@ -121,7 +131,7 @@ local PREFS_FILE = app.fs.joinPath(app.fs.userConfigPath, "aniphallow_prefs.ini" 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")) + table.insert(parts, f.x .. "," .. f.y .. "," .. (f.flipped and "1" or "0") .. "," .. (f.flippedV and "1" or "0")) end return table.concat(parts, ";") end @@ -131,11 +141,12 @@ 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?)") + local x, y, fh, fv = string.match(part, "([%d-]+),([%d-]+),?(%d?),?(%d?)") if x and y then table.insert(frames, { x = tonumber(x), y = tonumber(y), - flipped = (fl == "1") + flipped = (fh == "1"), + flippedV = (fv == "1") }) end end @@ -153,7 +164,12 @@ local function loadPrefs() 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 == "currentTab" and v ~= "" then + -- Migrate old tab names + if v == "Setup" then v = "Animations" + elseif v == "Render" then v = "Preview" end + S.currentTab = v + end if k == "animNames" and v ~= "" then S.animNames = {} for name in string.gmatch(v, "([^|]+)") do @@ -261,6 +277,17 @@ local function flipImageH(src) return flipped end +local function flipImageV(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(x, src.height - 1 - 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 @@ -869,6 +896,7 @@ local function run() 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 + if f.flippedV then img = flipImageV(img) end return img end @@ -882,7 +910,13 @@ local function run() local function updateAnimLabel() local name = currentAnimName() or "(none)" - dlg:modify{ id = "lblCurrentAnim", text = "-> " .. name } + 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] + dlg:modify{ id = "btnAnim" .. i, text = label } + end) + end end local function startAnimTimer() @@ -916,8 +950,8 @@ local function run() -- Switch visible tab local function switchTab(tab) S.currentTab = tab - local isSetup = (tab == "Setup") - local isRender = (tab == "Render") + local isSetup = (tab == "Animations") + local isRender = (tab == "Preview") local isGB = (tab == "GB") for _, id in ipairs(SETUP_IDS) do dlg:modify{ id = id, visible = isSetup } @@ -1012,14 +1046,9 @@ local function run() ---------------------------------------------------------------- dlg:separator{ id = "sepAnims", text = "Animations" } - dlg:label{ - id = "lblCurrentAnim", - text = "-> " .. (currentAnimName() or "(none)"), - } - dlg:button{ id = "btnNewAnim", - text = "+", + text = "Add Animation", onclick = function() local d = Dialog{ title = "New Animation" } d:entry{ id = "name", label = "Name:", text = "" } @@ -1042,13 +1071,20 @@ local function run() dlg:button{ id = "btnDelAnim", - text = "-", + text = "Remove Animation", onclick = function() local name = currentAnimName() if not name then return end + local result = app.alert{ + title = "Delete Animation", + text = "Delete animation '" .. name .. "'?", + 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() @@ -1057,13 +1093,22 @@ local function run() dlg:newrow() - -- Create buttons only for existing animations + dlg:label{ + id = "lblSelectedAnim", + text = "Selected -> " .. (currentAnimName() or "(none)"), + } + + 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 = S.animNames[i], + text = label, onclick = function() S.currentAnim = i + S.selectedFrame = 0 updateAnimLabel() dlg:repaint() end @@ -1073,7 +1118,7 @@ local function run() ---------------------------------------------------------------- -- Source canvas (L-click=add, R-click=add flipped, M-click=scroll) ---------------------------------------------------------------- - dlg:separator{ id = "sepSource", text = "Source (L=add, R=add flipped)" } + dlg:separator{ id = "sepSource", text = "Source (left-click=add frame, right-click=add flipped)" } dlg:canvas{ id = "canvasSource", @@ -1125,7 +1170,6 @@ local function run() 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) @@ -1140,37 +1184,55 @@ local function run() 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) + S.srcClickX = ev.x + S.srcClickY = ev.y + S.srcIsDrag = false + S.dragging = true + S.dragLastX = ev.x + S.dragLastY = ev.y 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() + local dx = math.abs(ev.x - S.srcClickX) + local dy = math.abs(ev.y - S.srcClickY) + if dx > 3 or dy > 3 then + S.srcIsDrag = true + end + if S.srcIsDrag then + S.scrollX = S.scrollX + (S.dragLastX - ev.x) + S.scrollY = S.scrollY + (S.dragLastY - ev.y) + clampScroll() + end S.dragLastX = ev.x S.dragLastY = ev.y end end, onmouseup = function(ev) + if S.dragging and not S.srcIsDrag then + local pixelX = math.floor((S.srcClickX + S.scrollX) / S.sourceZoom) + local pixelY = math.floor((S.srcClickY + S.scrollY) / S.sourceZoom) + captureCell(pixelX, pixelY, false) + end S.dragging = false + S.srcIsDrag = false + end, + onwheel = function(ev) + local dz = ev.deltaY < 0 and 1 or -1 + S.sourceZoom = math.max(1, math.min(10, S.sourceZoom + dz)) + clampScroll() + dlg:repaint() end } ---------------------------------------------------------------- -- Frame strip for current animation ---------------------------------------------------------------- - dlg:separator{ id = "sepFrames", text = "Frames (right-click to remove)" } + dlg:separator{ id = "sepFrames", text = "Frames (click to select, drag to reorder)" } local STRIP_TOTAL_W = SOURCE_VIEWPORT_W local STRIP_CELL = THUMB_SIZE + 1 @@ -1228,11 +1290,37 @@ local function run() gc:fillRect(Rectangle(tx + THUMB_SIZE - 4, ty, 4, 4)) end - -- Current frame indicator + -- Flip V indicator (green dot) + if frames[i].flippedV then + gc.color = Color(0, 200, 0, 220) + gc:fillRect(Rectangle(tx, ty, 4, 4)) + end + + -- Current playback 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 + + -- Selected frame highlight (yellow border) + if S.selectedFrame == i then + gc.color = Color(255, 255, 0, 220) + gc:fillRect(Rectangle(tx, ty, THUMB_SIZE, 2)) + gc:fillRect(Rectangle(tx, ty + THUMB_SIZE - 2, THUMB_SIZE, 2)) + gc:fillRect(Rectangle(tx, ty, 2, THUMB_SIZE)) + gc:fillRect(Rectangle(tx + THUMB_SIZE - 2, ty, 2, THUMB_SIZE)) + end + end + + -- Drag insertion indicator + if S.frameDragging and S.frameDragTo >= 1 and S.frameDragFrom ~= S.frameDragTo then + local insertIdx = S.frameDragTo + local col = (insertIdx - 1) % thumbsPerRow + local row = math.floor((insertIdx - 1) / thumbsPerRow) + local lx = col * STRIP_CELL + local ly = 2 + row * STRIP_CELL + gc.color = Color(255, 100, 100, 220) + gc:fillRect(Rectangle(lx - 1, ly, 2, THUMB_SIZE)) end end, onmousedown = function(ev) @@ -1245,36 +1333,174 @@ local function run() 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) + if ev.button == MouseButton.LEFT then + S.selectedFrame = idx + S.frameDragging = true + S.frameDragFrom = idx + S.frameDragTo = idx dlg:repaint() end end + end, + onmousemove = function(ev) + if S.frameDragging then + local name = currentAnimName() + local frames = name and S.anims[name] or {} + 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 + S.frameDragTo = clamp(idx, 1, #frames) + dlg:repaint() + end + end, + onmouseup = function(ev) + if S.frameDragging then + local from = S.frameDragFrom + local to = S.frameDragTo + if from ~= to then + local name = currentAnimName() + if name and S.anims[name] then + local frames = S.anims[name] + local frame = table.remove(frames, from) + table.insert(frames, to, frame) + S.selectedFrame = to + end + end + S.frameDragging = false + dlg:repaint() + end end } ---------------------------------------------------------------- - -- Clear buttons + -- Frame action buttons ---------------------------------------------------------------- dlg:separator{ id = "sepActions" } + dlg:button{ + id = "btnFlipX", + text = "Flip X", + 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 + frames[S.selectedFrame].flipped = not frames[S.selectedFrame].flipped + dlg:repaint() + end + } + + dlg:button{ + id = "btnFlipY", + text = "Flip Y", + 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 + frames[S.selectedFrame].flippedV = not frames[S.selectedFrame].flippedV + dlg:repaint() + end + } + + dlg:button{ + id = "btnMoveLeft", + text = "Move Left", + onclick = function() + local name = currentAnimName() + if not name or S.selectedFrame < 2 then return end + local frames = S.anims[name] + if not frames then return end + local idx = S.selectedFrame + frames[idx], frames[idx - 1] = frames[idx - 1], frames[idx] + S.selectedFrame = idx - 1 + dlg:repaint() + end + } + + dlg:button{ + id = "btnMoveRight", + text = "Move Right", + 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 + local idx = S.selectedFrame + frames[idx], frames[idx + 1] = frames[idx + 1], frames[idx] + S.selectedFrame = idx + 1 + dlg:repaint() + end + } + dlg:button{ id = "btnClearAnim", text = "Clear Anim", onclick = function() local name = currentAnimName() - if name then S.anims[name] = {} end + 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 } - dlg:button{ - id = "btnClearAll", - text = "Clear All", - onclick = function() - for _, name in ipairs(S.animNames) do - S.anims[name] = {} + ---------------------------------------------------------------- + -- 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 } @@ -1282,7 +1508,7 @@ local function run() ---------------------------------------------------------------- -- RENDER TAB (single canvas with all animations in 2 columns) ---------------------------------------------------------------- - dlg:separator{ id = "sepRender", text = "Animations", visible = false } + dlg:separator{ id = "sepRender", text = "All Animations Preview", visible = false } local RENDER_MARGIN = 2 @@ -1767,18 +1993,16 @@ local function run() end end + local imgCopy = S.gbOptImage:clone() app.transaction("Save Optimized Layer", function() if existingLayer then if S.gbAlwaysOverwrite then - -- Overwrite: delete old cels, add new cel with image + existingLayer.isEditable = true 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 + sp:newCel(existingLayer, app.frame, imgCopy, Point(0, 0)) else - -- Create new layer with incremented name local suffix = 2 local newName = layerName .. "-" .. suffix local nameExists = true @@ -1795,21 +2019,14 @@ local function run() 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 + sp:newCel(newLayer, app.frame, imgCopy, Point(0, 0)) 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 + sp:newCel(newLayer, app.frame, imgCopy, Point(0, 0)) end end) - app.alert("Layer saved: " .. layerName) end } @@ -1823,15 +2040,15 @@ local function run() app.alert("Run Analyze first.") return end + local origSprite = app.sprite 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() + cel.image = S.gbOptImage:clone() + app.command.MaskAll() app.command.CopyMerged() tmpSprite:close() - app.alert("Copied!") end }