mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
shamona
This commit is contained in:
@@ -11,7 +11,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Persistent custom options** stored separately from game saves
|
* **Persistent custom options** stored separately from game saves
|
||||||
* **Optional widescreen battle layout**
|
* **Optional widescreen battle layout**
|
||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||||
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|||||||
+14
-9
@@ -95,18 +95,22 @@ auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
|
|||||||
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
|
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
|
||||||
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
|
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
|
||||||
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||||
`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the
|
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||||
window the way Delta does rather than letterboxing. Host functions map to
|
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||||
|
window, and puts the Game Boy picture in the leftover space above -- the
|
||||||
|
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
||||||
|
stretch to the window the way Delta does. Host functions map to
|
||||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||||
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
||||||
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
||||||
|
|
||||||
PDF artwork is the one thing that does not come across: Delta's own templates
|
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
|
||||||
are all-PDF and this engine has no rasterizer, so such a skin is refused with
|
Image-to-PDF skins, Preview exports, and the like). Import extracts that
|
||||||
the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are
|
JPEG and draws it; a true vector PDF with no embedded image is still refused,
|
||||||
an older, incompatible schema and are refused by name.
|
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
|
||||||
|
are an older, incompatible schema and are refused by name.
|
||||||
|
|
||||||
## Bindable actions
|
## Bindable actions
|
||||||
|
|
||||||
@@ -268,6 +272,7 @@ exported file** opens that folder.
|
|||||||
|
|
||||||
## Not implemented
|
## Not implemented
|
||||||
|
|
||||||
Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this
|
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
|
||||||
engine does not carry, so they are refused with a message rather than imported
|
a PDF renderer this engine does not carry, so they are refused with a message
|
||||||
half-drawn.
|
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
|
||||||
|
skin case, extract on import.
|
||||||
|
|||||||
+21
-6
@@ -116,11 +116,13 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
|||||||
if type(assets) ~= "table" then return nil end
|
if type(assets) ~= "table" then return nil end
|
||||||
pdfFiles = pdfFiles or {}
|
pdfFiles = pdfFiles or {}
|
||||||
local raster = {}
|
local raster = {}
|
||||||
|
local pdfName
|
||||||
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
|
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
|
||||||
local name = pick(assets, key)
|
local name = pick(assets, key)
|
||||||
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
|
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
|
||||||
if type(name) == "string" and name ~= "" then
|
if type(name) == "string" and name ~= "" then
|
||||||
if name:lower():match("%.pdf$") then
|
if name:lower():match("%.pdf$") then
|
||||||
|
pdfName = name
|
||||||
pdfFiles[#pdfFiles + 1] = name
|
pdfFiles[#pdfFiles + 1] = name
|
||||||
else
|
else
|
||||||
raster[#raster + 1] = { key = key, name = name }
|
raster[#raster + 1] = { key = key, name = name }
|
||||||
@@ -130,12 +132,14 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
|||||||
local resizable = pick(assets, "resizable")
|
local resizable = pick(assets, "resizable")
|
||||||
if type(resizable) == "string" and resizable ~= "" then
|
if type(resizable) == "string" and resizable ~= "" then
|
||||||
if resizable:lower():match("%.pdf$") then
|
if resizable:lower():match("%.pdf$") then
|
||||||
|
pdfName = resizable
|
||||||
pdfFiles[#pdfFiles + 1] = resizable
|
pdfFiles[#pdfFiles + 1] = resizable
|
||||||
else
|
else
|
||||||
raster[#raster + 1] = { key = "large", name = resizable }
|
raster[#raster + 1] = { key = "large", name = resizable }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #raster == 0 then return nil end
|
local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
|
||||||
|
if #raster == 0 then return nil, pdfPath end
|
||||||
|
|
||||||
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
|
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
|
||||||
local chosen
|
local chosen
|
||||||
@@ -145,7 +149,7 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not chosen then chosen = raster[#raster].name end
|
if not chosen then chosen = raster[#raster].name end
|
||||||
return DeltaSkin.resolveName(chosen, opts)
|
return DeltaSkin.resolveName(chosen, opts), nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function DeltaSkin.mergeEdges(base, item)
|
function DeltaSkin.mergeEdges(base, item)
|
||||||
@@ -288,10 +292,12 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
|||||||
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
|
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
|
||||||
local page = {
|
local page = {
|
||||||
name = orient,
|
name = orient,
|
||||||
orient = orient,
|
orient = orient,
|
||||||
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
|
imagePath = imagePath,
|
||||||
|
pdfPath = pdfPath,
|
||||||
fullScreen = true,
|
fullScreen = true,
|
||||||
normalized = true,
|
normalized = true,
|
||||||
pixelCoords = false,
|
pixelCoords = false,
|
||||||
@@ -309,6 +315,14 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
|||||||
if screen then
|
if screen then
|
||||||
page.viewport = screen
|
page.viewport = screen
|
||||||
page.viewportFill = false
|
page.viewportFill = false
|
||||||
|
else
|
||||||
|
-- mappingSize is the overlay, not the device. Portrait controller
|
||||||
|
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
|
||||||
|
-- that aspect, sit at the bottom, and leave the leftover for the
|
||||||
|
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
|
||||||
|
page.aspectFromCfg = true
|
||||||
|
page.screenFit = "remainder"
|
||||||
|
if orient == "portrait" then page.anchor = "bottom" end
|
||||||
end
|
end
|
||||||
|
|
||||||
local baseEdges = pick(obj, "extendedEdges")
|
local baseEdges = pick(obj, "extendedEdges")
|
||||||
@@ -368,9 +382,6 @@ function DeltaSkin.parse(text, opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #pages == 0 then return nil, "info.json has no usable representation" end
|
if #pages == 0 then return nil, "info.json has no usable representation" end
|
||||||
if #pdfFiles > 0 then
|
|
||||||
addWarning(warnings, "PDF artwork cannot be imported yet")
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pages = pages,
|
pages = pages,
|
||||||
@@ -390,6 +401,10 @@ function DeltaSkin.needsConversion(skin)
|
|||||||
local files = skin.pdfFiles
|
local files = skin.pdfFiles
|
||||||
if type(files) ~= "table" or #files == 0 then return nil end
|
if type(files) ~= "table" or #files == 0 then return nil end
|
||||||
for _, page in ipairs(skin.pages or {}) do
|
for _, page in ipairs(skin.pages or {}) do
|
||||||
|
-- A raster asset, or a JPEG recovered from the PDF at load, means the
|
||||||
|
-- skin can draw. Parse-only callers still see pdfOnly because they
|
||||||
|
-- have not run extract yet.
|
||||||
|
if page.rasterData then return nil end
|
||||||
if page.imagePath then return nil end
|
if page.imagePath then return nil end
|
||||||
end
|
end
|
||||||
return { pdfOnly = true, files = files }
|
return { pdfOnly = true, files = files }
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
|
||||||
|
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
|
||||||
|
-- import pulls the embedded image out instead of refusing the skin. True
|
||||||
|
-- vector PDFs (no Image XObject, no JPEG) still fail.
|
||||||
|
|
||||||
|
local PdfImage = {}
|
||||||
|
|
||||||
|
local function isPdf(bytes)
|
||||||
|
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
|
||||||
|
-- `endstream` also contains the letters "stream", so skip that match.
|
||||||
|
local function streamDataStart(bytes, from)
|
||||||
|
local s, e = bytes:find("stream", from, true)
|
||||||
|
while s do
|
||||||
|
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
|
||||||
|
local p = e + 1
|
||||||
|
if bytes:sub(p, p) == "\r" then p = p + 1 end
|
||||||
|
if bytes:sub(p, p) == "\n" then p = p + 1 end
|
||||||
|
return p, s
|
||||||
|
end
|
||||||
|
s, e = bytes:find("stream", e + 1, true)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dictWindow(bytes, imageAt)
|
||||||
|
local from = imageAt > 400 and (imageAt - 400) or 1
|
||||||
|
local to = math.min(#bytes, imageAt + 800)
|
||||||
|
return bytes:sub(from, to)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dictNumber(window, key)
|
||||||
|
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
|
||||||
|
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
|
||||||
|
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dictFilter(window)
|
||||||
|
local named = window:match("/Filter%s*/(%w+)")
|
||||||
|
if named then return named end
|
||||||
|
return window:match("/Filter%s*%[%s*/(%w+)")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function jpegIn(bytes, from, to)
|
||||||
|
if from < 1 then from = 1 end
|
||||||
|
if not to or to > #bytes then to = #bytes end
|
||||||
|
if to < from then return nil end
|
||||||
|
local region = bytes:sub(from, to)
|
||||||
|
local soi = region:find("\255\216\255", 1, true)
|
||||||
|
if not soi then return nil end
|
||||||
|
local eoi = region:find("\255\217", soi + 3, true)
|
||||||
|
if not eoi then return nil end
|
||||||
|
return region:sub(soi, eoi + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function candidate(data, width, height, ext)
|
||||||
|
if not data or data == "" then return nil end
|
||||||
|
return {
|
||||||
|
data = data,
|
||||||
|
ext = ext or "jpg",
|
||||||
|
width = width or 0,
|
||||||
|
height = height or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bigger(a, b)
|
||||||
|
if not a then return b end
|
||||||
|
if not b then return a end
|
||||||
|
local as = (a.width or 0) * (a.height or 0)
|
||||||
|
local bs = (b.width or 0) * (b.height or 0)
|
||||||
|
if bs ~= as then return bs > as and b or a end
|
||||||
|
return #b.data > #a.data and b or a
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
|
||||||
|
local function fromImageXObjects(bytes)
|
||||||
|
local best
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local s, e = bytes:find("/Subtype%s*/Image", i)
|
||||||
|
if not s then break end
|
||||||
|
local window = dictWindow(bytes, s)
|
||||||
|
local filter = dictFilter(window)
|
||||||
|
local width = dictNumber(window, "Width")
|
||||||
|
local height = dictNumber(window, "Height")
|
||||||
|
local dataStart = streamDataStart(bytes, e)
|
||||||
|
i = e + 1
|
||||||
|
if dataStart and filter == "DCTDecode" then
|
||||||
|
local es = bytes:find("endstream", dataStart, true)
|
||||||
|
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
|
||||||
|
best = bigger(best, candidate(jpeg, width, height, "jpg"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
|
||||||
|
-- body even when /Length is an indirect object we do not resolve.
|
||||||
|
local function fromBareJpeg(bytes)
|
||||||
|
local jpeg = jpegIn(bytes, 1, #bytes)
|
||||||
|
if not jpeg then return nil end
|
||||||
|
return candidate(jpeg, 0, 0, "jpg")
|
||||||
|
end
|
||||||
|
|
||||||
|
function PdfImage.extract(bytes)
|
||||||
|
if not isPdf(bytes) then return nil, "not a pdf" end
|
||||||
|
local best = fromImageXObjects(bytes)
|
||||||
|
if not best then best = fromBareJpeg(bytes) end
|
||||||
|
if not best then return nil, "no extractable image" end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
return PdfImage
|
||||||
+105
-11
@@ -432,6 +432,10 @@ function TouchSkin.parseNative(text)
|
|||||||
aspectFromCfg = raw.fitAspect == true,
|
aspectFromCfg = raw.fitAspect == true,
|
||||||
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
||||||
or raw.orient == "any") and raw.orient or nil,
|
or raw.orient == "any") and raw.orient or nil,
|
||||||
|
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
|
||||||
|
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
|
||||||
|
or raw.anchor == "left" or raw.anchor == "right")
|
||||||
|
and raw.anchor or nil,
|
||||||
rect = { x = 0, y = 0, w = 1, h = 1 },
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
@@ -507,6 +511,8 @@ function TouchSkin.toNative(skin)
|
|||||||
alphaMod = page.alphaMod,
|
alphaMod = page.alphaMod,
|
||||||
aspect = page.aspect,
|
aspect = page.aspect,
|
||||||
fitAspect = page.aspectFromCfg or nil,
|
fitAspect = page.aspectFromCfg or nil,
|
||||||
|
screenFit = page.screenFit == "remainder" and "remainder" or nil,
|
||||||
|
anchor = page.anchor,
|
||||||
orient = (page.orient == "portrait" or page.orient == "landscape"
|
orient = (page.orient == "portrait" or page.orient == "landscape"
|
||||||
or page.orient == "any") and page.orient or nil,
|
or page.orient == "any") and page.orient or nil,
|
||||||
controls = {},
|
controls = {},
|
||||||
@@ -570,6 +576,40 @@ local function loadImage(path)
|
|||||||
return img
|
return img
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
|
||||||
|
local function loadImageFromBytes(bytes, name)
|
||||||
|
if not bytes or bytes == "" then return nil end
|
||||||
|
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||||
|
if not (love.filesystem and love.filesystem.newFileData) then return nil end
|
||||||
|
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
|
||||||
|
local cached = imageCache[key]
|
||||||
|
if cached then return cached end
|
||||||
|
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
|
||||||
|
if not okFd or not fd then return nil end
|
||||||
|
local ok, img = pcall(love.graphics.newImage, fd)
|
||||||
|
if not ok or not img then
|
||||||
|
if love.image and love.image.newImageData then
|
||||||
|
local okData, data = pcall(love.image.newImageData, fd)
|
||||||
|
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not ok or not img then return nil end
|
||||||
|
if img.setFilter then img:setFilter("linear", "linear") end
|
||||||
|
imageCache[key] = img
|
||||||
|
return img
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rasterizePdfPage(page, root)
|
||||||
|
if not page or page.image or not page.pdfPath then return end
|
||||||
|
local pdf = readFile(joinPath(root, page.pdfPath))
|
||||||
|
local raster = require("src.core.PdfImage").extract(pdf)
|
||||||
|
if not raster then return end
|
||||||
|
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
|
||||||
|
page.rasterData = raster.data
|
||||||
|
page.rasterName = name:match("([^/]+)$") or name
|
||||||
|
page.image = loadImageFromBytes(raster.data, page.rasterName)
|
||||||
|
end
|
||||||
|
|
||||||
local function pixelScalePending(page)
|
local function pixelScalePending(page)
|
||||||
if page.pixelCoords then return true end
|
if page.pixelCoords then return true end
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -622,6 +662,8 @@ function TouchSkin.load(root, id)
|
|||||||
for _, page in ipairs(skin.pages) do
|
for _, page in ipairs(skin.pages) do
|
||||||
if page.imagePath then
|
if page.imagePath then
|
||||||
page.image = loadImage(joinPath(root, page.imagePath))
|
page.image = loadImage(joinPath(root, page.imagePath))
|
||||||
|
elseif page.pdfPath then
|
||||||
|
rasterizePdfPage(page, root)
|
||||||
end
|
end
|
||||||
if not applyPixelScale(page) then
|
if not applyPixelScale(page) then
|
||||||
return nil, "could not read " .. tostring(page.imagePath)
|
return nil, "could not read " .. tostring(page.imagePath)
|
||||||
@@ -646,7 +688,7 @@ end
|
|||||||
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
||||||
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||||
TouchSkin.PDF_ONLY_MESSAGE =
|
TouchSkin.PDF_ONLY_MESSAGE =
|
||||||
"This skin uses PDF artwork, which cannot be imported yet. "
|
"This skin uses PDF artwork with no extractable image. "
|
||||||
.. "Ask the author for a PNG version."
|
.. "Ask the author for a PNG version."
|
||||||
|
|
||||||
function TouchSkin.archiveId(name)
|
function TouchSkin.archiveId(name)
|
||||||
@@ -1246,12 +1288,27 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
and page.aspect and page.aspect > 0 and h > 0
|
and page.aspect and page.aspect > 0 and h > 0
|
||||||
if fit then
|
if fit then
|
||||||
local displayAspect = w / h
|
local displayAspect = w / h
|
||||||
|
local anchor = page.anchor
|
||||||
if displayAspect > page.aspect then
|
if displayAspect > page.aspect then
|
||||||
bw = h * page.aspect
|
bw = h * page.aspect
|
||||||
bx = ox + (w - bw) * 0.5
|
local extra = w - bw
|
||||||
|
if anchor == "right" then
|
||||||
|
bx = ox + extra
|
||||||
|
elseif anchor == "left" then
|
||||||
|
bx = ox
|
||||||
|
else
|
||||||
|
bx = ox + extra * 0.5
|
||||||
|
end
|
||||||
else
|
else
|
||||||
bh = w / page.aspect
|
bh = w / page.aspect
|
||||||
by = oy + (h - bh) * 0.5
|
local extra = h - bh
|
||||||
|
if anchor == "bottom" then
|
||||||
|
by = oy + extra
|
||||||
|
elseif anchor == "top" then
|
||||||
|
by = oy
|
||||||
|
else
|
||||||
|
by = oy + extra * 0.5
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local r = page.rect
|
local r = page.rect
|
||||||
@@ -1308,18 +1365,55 @@ end
|
|||||||
|
|
||||||
function TouchSkin.hasViewport()
|
function TouchSkin.hasViewport()
|
||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
|
if not page or not TouchSkin.drawable() then return false end
|
||||||
|
return page.viewport ~= nil or page.screenFit == "remainder"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
|
||||||
|
local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||||
|
local right, bottom = ox + w, oy + h
|
||||||
|
local cand = {
|
||||||
|
{ ox, oy, w, by - oy },
|
||||||
|
{ ox, by + bh, w, bottom - (by + bh) },
|
||||||
|
{ ox, oy, bx - ox, h },
|
||||||
|
{ bx + bw, oy, right - (bx + bw), h },
|
||||||
|
}
|
||||||
|
local best, bestArea
|
||||||
|
for _, r in ipairs(cand) do
|
||||||
|
if r[3] > 1 and r[4] > 1 then
|
||||||
|
local area = r[3] * r[4]
|
||||||
|
if not best or area > bestArea then
|
||||||
|
best, bestArea = r, area
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not best then return nil end
|
||||||
|
return best[1], best[2], best[3], best[4]
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
|
if not page then return nil end
|
||||||
|
ox, oy = ox or 0, oy or 0
|
||||||
|
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||||
|
if page.viewport then
|
||||||
|
local v = page.viewport
|
||||||
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
|
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||||
|
end
|
||||||
|
if page.screenFit == "remainder" then
|
||||||
|
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||||
|
if not x then return nil end
|
||||||
|
return x, y, vw, vh, false, false
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.viewport(w, h, ox, oy)
|
function TouchSkin.viewport(w, h, ox, oy)
|
||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
if not page or not TouchSkin.drawable() then return nil end
|
||||||
local v = page.viewport
|
return TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
|
||||||
local x, y = bx + v.x * bw, by + v.y * bh
|
|
||||||
local vw, vh = v.w * bw, v.h * bh
|
|
||||||
if vw <= 0 or vh <= 0 then return nil end
|
|
||||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return TouchSkin
|
return TouchSkin
|
||||||
|
|||||||
@@ -3119,7 +3119,8 @@ function RomImporter:_ensureSkins(force)
|
|||||||
format = skin and skin.format or nil,
|
format = skin and skin.format or nil,
|
||||||
pages = skin and #skin.pages or 0,
|
pages = skin and #skin.pages or 0,
|
||||||
controls = controls,
|
controls = controls,
|
||||||
screen = page ~= nil and page.viewport ~= nil,
|
screen = page ~= nil
|
||||||
|
and (page.viewport ~= nil or page.screenFit == "remainder"),
|
||||||
ok = skin ~= nil,
|
ok = skin ~= nil,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
+13
-11
@@ -851,6 +851,7 @@ function Studio.detectViewport()
|
|||||||
end
|
end
|
||||||
Studio.pushUndo()
|
Studio.pushUndo()
|
||||||
page.viewport = rect
|
page.viewport = rect
|
||||||
|
page.screenFit = nil
|
||||||
setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph))
|
setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph))
|
||||||
Studio.dirty = true
|
Studio.dirty = true
|
||||||
end
|
end
|
||||||
@@ -860,8 +861,9 @@ function Studio.toggleViewport()
|
|||||||
if not page then return end
|
if not page then return end
|
||||||
if Studio.canvas().lockViewport then return end
|
if Studio.canvas().lockViewport then return end
|
||||||
Studio.pushUndo()
|
Studio.pushUndo()
|
||||||
if page.viewport then
|
if page.viewport or page.screenFit == "remainder" then
|
||||||
page.viewport = nil
|
page.viewport = nil
|
||||||
|
page.screenFit = nil
|
||||||
else
|
else
|
||||||
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
|
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
|
||||||
end
|
end
|
||||||
@@ -892,7 +894,7 @@ function Studio.pageLabel(index)
|
|||||||
local orient = TouchSkin.pageOrient(page)
|
local orient = TouchSkin.pageOrient(page)
|
||||||
local bits = { #(page.controls or {}) .. " controls" }
|
local bits = { #(page.controls or {}) .. " controls" }
|
||||||
if orient then bits[#bits + 1] = orient end
|
if orient then bits[#bits + 1] = orient end
|
||||||
if page.viewport then bits[#bits + 1] = "screen" end
|
if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end
|
||||||
return name, table.concat(bits, " \194\183 ")
|
return name, table.concat(bits, " \194\183 ")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1092,10 +1094,9 @@ function Studio.snapLines(page, r, skipIndex)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function viewportRect(page, r)
|
local function viewportRect(page, r)
|
||||||
local v = page.viewport
|
local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
|
||||||
if not v then return nil end
|
if not x then return nil end
|
||||||
local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
|
return x, y, w, h
|
||||||
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function handleRects(bx, by, bw, bh)
|
local function handleRects(bx, by, bw, bh)
|
||||||
@@ -1138,7 +1139,7 @@ function Studio.beginCanvasDrag(mx, my, r)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local vx, vy, vw, vh = viewportRect(page, r)
|
local vx, vy, vw, vh = viewportRect(page, r)
|
||||||
if vx and not Studio.canvas().lockViewport then
|
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
|
||||||
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
|
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
|
||||||
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
|
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
|
||||||
Studio.pushUndo()
|
Studio.pushUndo()
|
||||||
@@ -1162,7 +1163,7 @@ function Studio.beginCanvasDrag(mx, my, r)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if vx and not Studio.canvas().lockViewport
|
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder"
|
||||||
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
|
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
|
||||||
Studio.selected = nil
|
Studio.selected = nil
|
||||||
Studio.pushUndo()
|
Studio.pushUndo()
|
||||||
@@ -1270,7 +1271,7 @@ local function drawCanvas(x, y, w, h)
|
|||||||
if vx then
|
if vx then
|
||||||
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
|
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
|
||||||
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
|
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
|
||||||
if not Studio.canvas().lockViewport then
|
if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
|
||||||
local a = Studio.selectedControl() and 0.45 or 1
|
local a = Studio.selectedControl() and 0.45 or 1
|
||||||
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
|
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
|
||||||
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
|
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
|
||||||
@@ -1374,7 +1375,7 @@ local function inspectorBody(x, y, w)
|
|||||||
cy = cy + rowH + gap
|
cy = cy + rowH + gap
|
||||||
|
|
||||||
if page then
|
if page then
|
||||||
local bezel = page.imagePath or "(none)"
|
local bezel = page.imagePath or page.rasterName or "(none)"
|
||||||
local pickW = 82 * Kit.scale
|
local pickW = 82 * Kit.scale
|
||||||
local cycleW = w - pickW - gap
|
local cycleW = w - pickW - gap
|
||||||
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
|
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
|
||||||
@@ -1385,7 +1386,8 @@ local function inspectorBody(x, y, w)
|
|||||||
Studio.importImageFile("bezel")
|
Studio.importImageFile("bezel")
|
||||||
end
|
end
|
||||||
cy = cy + rowH + gap
|
cy = cy + rowH + gap
|
||||||
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
|
local vpLabel = (page.viewport or page.screenFit == "remainder")
|
||||||
|
and "Screen cutout: ON" or "Screen cutout: OFF"
|
||||||
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
|
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
|
||||||
enabled = not Studio.canvas().lockViewport }) then
|
enabled = not Studio.canvas().lockViewport }) then
|
||||||
Studio.toggleViewport()
|
Studio.toggleViewport()
|
||||||
|
|||||||
@@ -364,6 +364,31 @@ eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y")
|
|||||||
eq(bw, 1000, "delta page box fills the width")
|
eq(bw, 1000, "delta page box fills the width")
|
||||||
eq(bh, 500, "delta page box fills the height")
|
eq(bh, 500, "delta page box fills the height")
|
||||||
|
|
||||||
|
local DECK_JSON = [[
|
||||||
|
{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
|
||||||
|
"representations": { "iphone": { "standard": { "portrait": {
|
||||||
|
"assets": { "large": "deck.png" },
|
||||||
|
"mappingSize": {"width":320,"height":240},
|
||||||
|
"items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ]
|
||||||
|
} } } } }
|
||||||
|
]]
|
||||||
|
local deck = assert(DeltaSkin.parse(DECK_JSON))
|
||||||
|
local deckPage = deck.pages[1]
|
||||||
|
check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect")
|
||||||
|
eq(deckPage.anchor, "bottom", "and sits at the bottom of the window")
|
||||||
|
eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture")
|
||||||
|
eq(deckPage.viewport, nil, "no screens[] means no baked cutout")
|
||||||
|
local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920)
|
||||||
|
eq(dbx, 0, "deck overlay is full width")
|
||||||
|
eq(dbw, 1080, "deck overlay width")
|
||||||
|
near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect")
|
||||||
|
near(dby, 1920 - dbh, "pinned to the bottom, not stretched")
|
||||||
|
local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920)
|
||||||
|
eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y")
|
||||||
|
eq(vw, 1080, "screen leftover is full width")
|
||||||
|
near(vh, dby, "and fills everything above the overlay")
|
||||||
|
check(vh > dbh, "there is more room for the picture than for the pad")
|
||||||
|
|
||||||
local LEGACY_SCREEN = [[
|
local LEGACY_SCREEN = [[
|
||||||
{ "gameTypeIdentifier": "public.aoshuang.game.gbc",
|
{ "gameTypeIdentifier": "public.aoshuang.game.gbc",
|
||||||
"representations": { "iphone": { "standard": { "landscape": {
|
"representations": { "iphone": { "standard": { "landscape": {
|
||||||
@@ -419,6 +444,8 @@ local PDF_JSON = [[
|
|||||||
]]
|
]]
|
||||||
local pdf = assert(DeltaSkin.parse(PDF_JSON))
|
local pdf = assert(DeltaSkin.parse(PDF_JSON))
|
||||||
eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
|
eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
|
||||||
|
eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf",
|
||||||
|
"but the PDF path is kept so load can extract a JPEG from it")
|
||||||
local convert = DeltaSkin.needsConversion(pdf)
|
local convert = DeltaSkin.needsConversion(pdf)
|
||||||
check(convert ~= nil, "PDF-only skins report that they need conversion")
|
check(convert ~= nil, "PDF-only skins report that they need conversion")
|
||||||
if convert then
|
if convert then
|
||||||
@@ -471,6 +498,55 @@ check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil,
|
|||||||
eq(love.filesystem.read("skins/vector.deltaskin"), nil,
|
eq(love.filesystem.read("skins/vector.deltaskin"), nil,
|
||||||
"and the refused archive is not left behind")
|
"and the refused archive is not left behind")
|
||||||
|
|
||||||
|
local PdfImage = require("src.core.PdfImage")
|
||||||
|
local function unhex(s)
|
||||||
|
return (s:gsub("..", function(cc)
|
||||||
|
return string.char(tonumber(cc, 16))
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
-- 1x1 JFIF JPEG, so extract tests do not need a file on disk.
|
||||||
|
local TINY_JPEG = unhex(
|
||||||
|
"ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" ..
|
||||||
|
"09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" ..
|
||||||
|
"2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" ..
|
||||||
|
"0000000000000000000000000000000008ffc40014100100000000000000000000000000" ..
|
||||||
|
"00000000ffda0008010100003f007f3fffd9")
|
||||||
|
local function jpegPdf(jpeg, w, h)
|
||||||
|
return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width "
|
||||||
|
.. tostring(w) .. " /Height " .. tostring(h)
|
||||||
|
.. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length "
|
||||||
|
.. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n"
|
||||||
|
end
|
||||||
|
local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1)))
|
||||||
|
eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg")
|
||||||
|
eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte")
|
||||||
|
eq(extracted.width, 1, "width comes from the Image XObject")
|
||||||
|
eq(extracted.height, 1, "and so does height")
|
||||||
|
|
||||||
|
local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1"
|
||||||
|
.. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG
|
||||||
|
.. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n"
|
||||||
|
local fromRef = assert(PdfImage.extract(indirect))
|
||||||
|
eq(fromRef.data, TINY_JPEG,
|
||||||
|
"an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts")
|
||||||
|
|
||||||
|
eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")),
|
||||||
|
nil, "a vector PDF with no image is not pretended to be art")
|
||||||
|
eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage")
|
||||||
|
|
||||||
|
love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON)
|
||||||
|
love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf",
|
||||||
|
jpegPdf(TINY_JPEG, 1, 1))
|
||||||
|
local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub")
|
||||||
|
eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: "
|
||||||
|
.. tostring(pikaErr))
|
||||||
|
local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf"))
|
||||||
|
check(pika.pages[1].rasterData == TINY_JPEG,
|
||||||
|
"load recovers the JPEG from the PDF")
|
||||||
|
check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes")
|
||||||
|
eq(DeltaSkin.needsConversion(pika), nil,
|
||||||
|
"so the skin no longer reports that it needs conversion")
|
||||||
|
|
||||||
eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
|
eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
|
||||||
"a GBA4iOS .gbcskin is refused at the door")
|
"a GBA4iOS .gbcskin is refused at the door")
|
||||||
local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")
|
local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")
|
||||||
|
|||||||
Reference in New Issue
Block a user