mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8e137b42 |
@@ -49,6 +49,7 @@ function love.conf(t)
|
|||||||
t.modules.joystick = true
|
t.modules.joystick = true
|
||||||
t.modules.physics = false
|
t.modules.physics = false
|
||||||
|
|
||||||
|
|
||||||
-- love.system is not loaded during love.conf; love._os is set by the
|
-- love.system is not loaded during love.conf; love._os is set by the
|
||||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||||
local osName = love._os
|
local osName = love._os
|
||||||
@@ -92,4 +93,28 @@ function love.conf(t)
|
|||||||
else
|
else
|
||||||
t.window.resizable = true
|
t.window.resizable = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Consoles, last: LOVE Potion (3DS / Switch / Wii U) publishes love._console,
|
||||||
|
-- set when the love module initializes and so available here exactly like
|
||||||
|
-- love._os above. This runs after the branch above because that branch's
|
||||||
|
-- desktop `else` would otherwise re-enable resizing underneath it.
|
||||||
|
if love._console then
|
||||||
|
-- LOVE Potion implements the 12.0 API, so the 11.5 declared above trips its
|
||||||
|
-- version-mismatch notice. Ask the running engine for its own version
|
||||||
|
-- string rather than hardcoding a second number that would need keeping in
|
||||||
|
-- sync with whichever LOVE Potion release the player installed.
|
||||||
|
t.version = love._version or t.version
|
||||||
|
-- It builds no love.mouse module at all (its source/modules ships touch,
|
||||||
|
-- joystick and keyboard, and nothing that points), so do not ask for one.
|
||||||
|
t.modules.mouse = false
|
||||||
|
-- Consoles own their resolution: the 3DS screens are fixed at 400x240 top
|
||||||
|
-- and 320x240 bottom (800x240 in wide mode), while the Switch and Wii U
|
||||||
|
-- backends set their size at runtime from the dock / TV state. The desktop
|
||||||
|
-- sizing above is not merely ignored there but actively wrong -- a 480x360
|
||||||
|
-- minimum is larger than the whole 3DS screen -- so drop the fields that
|
||||||
|
-- only ever described a resizable desktop window.
|
||||||
|
t.window.minwidth = nil
|
||||||
|
t.window.minheight = nil
|
||||||
|
t.window.resizable = false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,38 @@
|
|||||||
|
|
||||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||||
|
|
||||||
|
-- Crash log, for the console ports.
|
||||||
|
--
|
||||||
|
-- LÖVE's error screen assumes someone can read it. On the Nintendo builds
|
||||||
|
-- nobody can: the Wii U reports only "terminated by calling coreinit.exit(1)"
|
||||||
|
-- to the host, the 3DS closes back to the menu, and neither surfaces the Lua
|
||||||
|
-- message or traceback anywhere. That turns any boot-time error into a silent
|
||||||
|
-- exit, which is exactly the failure this port kept hitting.
|
||||||
|
--
|
||||||
|
-- So persist it. The save directory is a real folder on the SD card (and on
|
||||||
|
-- the emulator's mlc), so the file survives the exit and can be read back
|
||||||
|
-- afterwards. Desktop is unchanged: this only prepends the write, then hands
|
||||||
|
-- off to LÖVE's normal handler and its error screen.
|
||||||
|
do
|
||||||
|
local previous = love.errorhandler or love.errhand
|
||||||
|
local function handler(msg)
|
||||||
|
pcall(function()
|
||||||
|
local body = table.concat({
|
||||||
|
"gen1recomp crash",
|
||||||
|
"console: " .. tostring(love._console),
|
||||||
|
"os: " .. tostring(love._os),
|
||||||
|
"version: " .. tostring(love._version),
|
||||||
|
"",
|
||||||
|
debug.traceback(tostring(msg), 2),
|
||||||
|
}, "\n")
|
||||||
|
love.filesystem.write("crash.txt", body)
|
||||||
|
end)
|
||||||
|
if previous then return previous(msg) end
|
||||||
|
end
|
||||||
|
love.errorhandler = handler
|
||||||
|
love.errhand = handler -- 11.x name, still read by some builds
|
||||||
|
end
|
||||||
|
|
||||||
local Game, EditorApp, Importer, TouchEditor
|
local Game, EditorApp, Importer, TouchEditor
|
||||||
|
|
||||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||||
|
|||||||
+201
-5
@@ -4,9 +4,12 @@
|
|||||||
# the Windows and Linux builds reuse LÖVE's prebuilt win64 / AppImage
|
# the Windows and Linux builds reuse LÖVE's prebuilt win64 / AppImage
|
||||||
# binaries, fusing our game.love onto them the same way love.exe does).
|
# binaries, fusing our game.love onto them the same way love.exe does).
|
||||||
#
|
#
|
||||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
# Usage: scripts/build.sh [mac|win|linux|android|ios|all|3ds|switch|wiiu|console]
|
||||||
|
# [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||||
# [--notary-profile NAME] [--no-notarize]
|
# [--notary-profile NAME] [--no-notarize]
|
||||||
# [--release] # ios only: release config instead of debug
|
# [--release] # ios only: release config instead of debug
|
||||||
|
# [--unpackaged] # console only: emit loose game files
|
||||||
|
# # instead of fusing into the binary
|
||||||
#
|
#
|
||||||
# Output: dist/mac/gen1recomp-macos.zip
|
# Output: dist/mac/gen1recomp-macos.zip
|
||||||
# dist/win/gen1recomp-win64.zip
|
# dist/win/gen1recomp-win64.zip
|
||||||
@@ -15,6 +18,8 @@
|
|||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||||
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
|
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
|
||||||
# under mobile/ios/build/Build/Products/)
|
# under mobile/ios/build/Build/Products/)
|
||||||
|
# dist/console/sdcard/ (LÖVE Potion game folder, runs as-is)
|
||||||
|
# dist/console/gen1recomp-<ver>-console-bundle.zip (for lovebrew.org)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -35,6 +40,21 @@ TARGET="all"
|
|||||||
NOTARY_PROFILE="notary-profile"
|
NOTARY_PROFILE="notary-profile"
|
||||||
NOTARIZE=true
|
NOTARIZE=true
|
||||||
IOS_RELEASE=false
|
IOS_RELEASE=false
|
||||||
|
# Console bundles ask for a single executable by default. With packaged=false
|
||||||
|
# the bundler has nothing to build for a non-3DS target -- no executable, and no
|
||||||
|
# asset conversion outside the 3DS -- so it hands back the source unchanged,
|
||||||
|
# which is what dist/console/sdcard already is.
|
||||||
|
CONSOLE_PACKAGED=true
|
||||||
|
|
||||||
|
# Console targets requested, in bundler names (ctr / hac / cafe), deduped and
|
||||||
|
# kept in the order the user asked for them.
|
||||||
|
CONSOLE_LIST=""
|
||||||
|
add_console() {
|
||||||
|
case " $CONSOLE_LIST " in
|
||||||
|
*" $1 "*) ;; # already requested
|
||||||
|
*) CONSOLE_LIST="${CONSOLE_LIST:+$CONSOLE_LIST }$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -43,11 +63,21 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
||||||
|
# Console targets accumulate instead of overwriting: one bundle can build
|
||||||
|
# for several consoles at once, and each build_console run clears
|
||||||
|
# dist/console, so `build.sh 3ds wiiu` as two separate runs would leave
|
||||||
|
# only the Wii U output behind.
|
||||||
|
3ds) TARGET="console"; add_console ctr ;;
|
||||||
|
switch) TARGET="console"; add_console hac ;;
|
||||||
|
wiiu) TARGET="console"; add_console cafe ;;
|
||||||
|
console) TARGET="console"; add_console ctr; add_console hac; add_console cafe ;;
|
||||||
--version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
|
--version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
|
||||||
--identity) IDENTITY="$2"; shift ;;
|
--identity) IDENTITY="$2"; shift ;;
|
||||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||||
--no-notarize) NOTARIZE=false ;;
|
--no-notarize) NOTARIZE=false ;;
|
||||||
--release) IOS_RELEASE=true ;;
|
--release) IOS_RELEASE=true ;;
|
||||||
|
--packaged) CONSOLE_PACKAGED=true ;;
|
||||||
|
--unpackaged) CONSOLE_PACKAGED=false ;;
|
||||||
*) fail "unknown argument: $1" ;;
|
*) fail "unknown argument: $1" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
@@ -68,8 +98,17 @@ rm -f "$LOVE_FILE"
|
|||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json \
|
tools/rom_manifest_yellow.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
# List the archive once into a file, and grep that file rather than a pipe.
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
# `unzip -Z1 ... | grep -q ...` looks harmless but is a race under the
|
||||||
|
# `set -o pipefail` above: grep -q exits the instant it matches, unzip takes
|
||||||
|
# SIGPIPE on the next write, and pipefail then reports the whole pipeline as
|
||||||
|
# failed even though the match succeeded. Every check below matches something
|
||||||
|
# in the last 20 of ~300 entries, so unzip is virtually always still writing --
|
||||||
|
# which turned every one of these guards into an unconditional "missing file"
|
||||||
|
# abort, on every target.
|
||||||
|
LOVE_LIST="$WORK/game.love.list"
|
||||||
|
unzip -Z1 "$LOVE_FILE" > "$LOVE_LIST"
|
||||||
|
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LOVE_LIST"; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
# The editor is only reachable if its entry point and both module directories
|
# The editor is only reachable if its entry point and both module directories
|
||||||
@@ -81,7 +120,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/save-editor/panels/Party.lua \
|
tools/save-editor/panels/Party.lua \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json; do
|
tools/rom_manifest_yellow.json; do
|
||||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
grep -qx "$required" "$LOVE_LIST" \
|
||||||
|| fail "game.love is missing $required"
|
|| fail "game.love is missing $required"
|
||||||
done
|
done
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||||
@@ -103,7 +142,10 @@ if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|||||||
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||||
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
# Captured, not piped into grep -q, for the same SIGPIPE-under-pipefail
|
||||||
|
# reason as the listing checks above.
|
||||||
|
stamped="$(unzip -p "$LOVE_FILE" src/core/Version.lua)"
|
||||||
|
printf '%s' "$stamped" \
|
||||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||||
say "stamped engine version: $VERSION"
|
say "stamped engine version: $VERSION"
|
||||||
@@ -291,6 +333,139 @@ build_linux() {
|
|||||||
say "Linux build: $zip_out"
|
say "Linux build: $zip_out"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------- Nintendo (LÖVE Potion)
|
||||||
|
# 3DS / Switch / Wii U, via LÖVE Potion (https://lovebrew.org).
|
||||||
|
#
|
||||||
|
# Nothing is compiled here, and nothing can be: LÖVE Potion ships prebuilt
|
||||||
|
# console binaries, and turning a game into a .3dsx/.nro/.wuhb is done by the
|
||||||
|
# lovebrew bundler, which is a hosted service (it shells out to devkitPro tools
|
||||||
|
# server-side). There is no working local CLI for it. The old `lovebrew`
|
||||||
|
# client still on the releases page posts to www.bundle.lovebrew.org/data:
|
||||||
|
# that hostname no longer resolves, and the /data route is gone from the host
|
||||||
|
# that does (404 on GET, 405 on POST), so the client cannot be repaired by
|
||||||
|
# repointing it. So this target produces the two things that do not depend on
|
||||||
|
# any service being up:
|
||||||
|
#
|
||||||
|
# 1. an SD-card tree, which needs no bundling at all. With `packaged = false`
|
||||||
|
# LÖVE Potion runs a plain `game/` folder sitting next to its own binary,
|
||||||
|
# so this is the route that actually boots today, and the one where a
|
||||||
|
# player can drop their .gb right next to main.lua for the auto-import.
|
||||||
|
# 2. a bundle zip in the layout the bundler expects, for whenever the service
|
||||||
|
# is reachable, so producing the single-file executables is a drag and
|
||||||
|
# drop rather than a re-derivation of this layout by hand.
|
||||||
|
#
|
||||||
|
# The game payload is unpacked from game.love rather than re-copied from the
|
||||||
|
# source tree, so the console files are byte-identical to every other platform's
|
||||||
|
# -- including the version stamp above, which a fresh copy would miss.
|
||||||
|
build_console() {
|
||||||
|
local targets="$1" # toml array body, e.g. '"ctr", "hac"'
|
||||||
|
local label="$2" # human name for the log line
|
||||||
|
say "building for Nintendo consoles ($label) via LÖVE Potion"
|
||||||
|
|
||||||
|
local out="$DIST/console"
|
||||||
|
local stage="$WORK/console"
|
||||||
|
rm -rf "$stage" "$out"
|
||||||
|
mkdir -p "$stage/game" "$out"
|
||||||
|
|
||||||
|
# 1. game payload, straight out of the archive the desktop builds ship
|
||||||
|
unzip -q "$LOVE_FILE" -d "$stage/game"
|
||||||
|
[ -f "$stage/game/main.lua" ] \
|
||||||
|
|| fail "console payload has no main.lua at its root"
|
||||||
|
|
||||||
|
# 2. icons, in each console's required size and container
|
||||||
|
say "generating console icons"
|
||||||
|
python3 "$ROOT/tools/make_console_icons.py" --out "$stage/icons" \
|
||||||
|
|| fail "icon generation failed (needs Pillow: python3 -m pip install Pillow)"
|
||||||
|
for required in icon-ctr.png icon-hac.jpg icon-cafe.png; do
|
||||||
|
[ -f "$stage/icons/$required" ] || fail "icon generation did not write $required"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. bundler config. Two files on purpose, and they are not redundant: the
|
||||||
|
# published example and docs use lovebrew.toml with a per-target `icons`
|
||||||
|
# table, while the current bundler source reads bundle.toml with a single
|
||||||
|
# `icon`. The two schemas are mutually exclusive, the service picks one
|
||||||
|
# filename and ignores the other, and writing both costs nothing while
|
||||||
|
# removing a coin flip over which deployment is live.
|
||||||
|
local semver="$VERSION"
|
||||||
|
printf '%s' "$semver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || semver="0.1.0"
|
||||||
|
|
||||||
|
cat > "$stage/lovebrew.toml" <<TOML
|
||||||
|
[metadata]
|
||||||
|
title = "$APP_NAME"
|
||||||
|
author = "bryanthaboi"
|
||||||
|
description = "Pokemon Red/Blue recompilation"
|
||||||
|
version = "$semver"
|
||||||
|
icons = { ctr = "icons/icon-ctr.png", hac = "icons/icon-hac.jpg", cafe = "icons/icon-cafe.png" }
|
||||||
|
|
||||||
|
[build]
|
||||||
|
targets = [$targets]
|
||||||
|
source = "game"
|
||||||
|
packaged = $CONSOLE_PACKAGED
|
||||||
|
TOML
|
||||||
|
|
||||||
|
cat > "$stage/bundle.toml" <<TOML
|
||||||
|
[metadata]
|
||||||
|
title = "$APP_NAME"
|
||||||
|
author = "bryanthaboi"
|
||||||
|
description = "Pokemon Red/Blue recompilation"
|
||||||
|
version = "$semver"
|
||||||
|
icon = "icons/icon-ctr.png"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
targets = [$targets]
|
||||||
|
source = "game"
|
||||||
|
TOML
|
||||||
|
|
||||||
|
# 4. bundle zip, for the hosted bundler
|
||||||
|
local bundle="$out/$APP_NAME-$VERSION-console-bundle.zip"
|
||||||
|
(cd "$stage" && zip -q -9 -r "$bundle" lovebrew.toml bundle.toml game icons \
|
||||||
|
-x '*.DS_Store')
|
||||||
|
|
||||||
|
# 5. SD-card tree. The LÖVE Potion binary is deliberately not vendored: it
|
||||||
|
# is a 20-35MB per-console download with its own release cadence, and pinning
|
||||||
|
# a stale copy inside our dist is how a player ends up debugging a runtime
|
||||||
|
# mismatch that we caused.
|
||||||
|
local sd="$out/sdcard"
|
||||||
|
mkdir -p "$sd"
|
||||||
|
cp -R "$stage/game" "$sd/game"
|
||||||
|
cp -R "$stage/icons" "$sd/icons"
|
||||||
|
cat > "$sd/README.txt" <<'TXT'
|
||||||
|
Pokemon Gen 1 recompilation, Nintendo homebrew (3DS / Switch / Wii U)
|
||||||
|
====================================================================
|
||||||
|
|
||||||
|
This folder is the game, not the runtime. It runs on LÖVE Potion, which is a
|
||||||
|
separate download:
|
||||||
|
|
||||||
|
https://github.com/lovebrew/lovepotion/releases
|
||||||
|
|
||||||
|
1. Copy LÖVE Potion's binary for your console (.3dsx, .nro or .wuhb) onto the
|
||||||
|
SD card, in the usual homebrew location.
|
||||||
|
2. Copy this `game` folder in next to that binary.
|
||||||
|
3. Copy your own Pokemon Red / Blue / Yellow ROM (.gb or .gbc) into the `game`
|
||||||
|
folder, right beside main.lua.
|
||||||
|
4. Launch it from the homebrew menu.
|
||||||
|
|
||||||
|
No ROM ships with this, and none can: the game builds everything it needs from
|
||||||
|
your own cartridge dump on first launch. That import runs once, takes a while
|
||||||
|
on a 3DS, and writes into the save directory afterwards.
|
||||||
|
|
||||||
|
The ROM is found by its SHA-1, so the filename does not matter, and Red, Blue
|
||||||
|
and Yellow can all sit there together.
|
||||||
|
|
||||||
|
If instead you built a single fused executable with the bundler (packaged =
|
||||||
|
true), there is no `game` folder to drop the ROM beside -- the source lives
|
||||||
|
inside the binary and is read-only. Put the ROM in the game's save directory
|
||||||
|
instead; the launcher prints the exact path when it cannot find one.
|
||||||
|
TXT
|
||||||
|
|
||||||
|
say "console bundle: $bundle"
|
||||||
|
say "console SD tree: $sd"
|
||||||
|
warn "not built into .3dsx/.nro/.wuhb here: drag the bundle zip above into"
|
||||||
|
warn "https://bundle.lovebrew.org, which returns the executables. (Ignore"
|
||||||
|
warn "api.lovebrew.org -- that is the unreleased rewrite's endpoint, it 502s,"
|
||||||
|
warn "and it is not what the live bundler uses.) The SD tree needs no bundler."
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- Android
|
# --------------------------------------------------------------- Android
|
||||||
build_android() {
|
build_android() {
|
||||||
say "building Android (delegating to scripts/build_android.sh)"
|
say "building Android (delegating to scripts/build_android.sh)"
|
||||||
@@ -320,11 +495,32 @@ case "$TARGET" in
|
|||||||
linux) build_linux ;;
|
linux) build_linux ;;
|
||||||
android) build_android ;;
|
android) build_android ;;
|
||||||
ios) build_ios ;;
|
ios) build_ios ;;
|
||||||
|
# ctr / hac / cafe are the bundler's own names for the three consoles.
|
||||||
|
console)
|
||||||
|
# "ctr cafe" -> '"ctr", "cafe"' for the toml array, and "3DS, Wii U" for
|
||||||
|
# the log line.
|
||||||
|
console_toml=""
|
||||||
|
console_label=""
|
||||||
|
for c in $CONSOLE_LIST; do
|
||||||
|
case "$c" in
|
||||||
|
ctr) name="3DS" ;;
|
||||||
|
hac) name="Switch" ;;
|
||||||
|
cafe) name="Wii U" ;;
|
||||||
|
esac
|
||||||
|
console_toml="${console_toml:+$console_toml, }\"$c\""
|
||||||
|
console_label="${console_label:+$console_label, }$name"
|
||||||
|
done
|
||||||
|
build_console "$console_toml" "$console_label"
|
||||||
|
;;
|
||||||
|
# `all` stays the desktop trio: the console output is not a finished
|
||||||
|
# executable, so folding it in would make every desktop build print bundler
|
||||||
|
# instructions nobody asked for.
|
||||||
all) build_mac; build_win; build_linux ;;
|
all) build_mac; build_win; build_linux ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
android) say "done. See $DIST/android/" ;;
|
android) say "done. See $DIST/android/" ;;
|
||||||
ios) say "done. See $DIST/ios/" ;;
|
ios) say "done. See $DIST/ios/" ;;
|
||||||
|
3ds|switch|wiiu|console) say "done. See $DIST/console/" ;;
|
||||||
*) say "done. Artifacts in $DIST" ;;
|
*) say "done. Artifacts in $DIST" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Which Nintendo console this is running on, or nil everywhere else.
|
||||||
|
--
|
||||||
|
-- love.system.getOS() cannot answer this. The LOVE Potion build defines
|
||||||
|
-- __OS__ as "Horizon" for BOTH the 3DS and the Switch, and "Cafe" for the
|
||||||
|
-- Wii U (lovebrew/lovepotion CMakeLists.txt), so an OS test meaning "3DS"
|
||||||
|
-- would equally catch a Switch -- two machines separated by roughly two orders
|
||||||
|
-- of magnitude of CPU and an order of magnitude of pixels. Anything scaled to
|
||||||
|
-- the hardware has to distinguish them.
|
||||||
|
--
|
||||||
|
-- The console name is published separately, as the love._console string
|
||||||
|
-- ("3DS" / "Switch" / "Wii U", set in source/modules/love/love.cpp). It is
|
||||||
|
-- absent on desktop LOVE, on Android and on iOS, which is what makes its mere
|
||||||
|
-- presence the "is this a console" probe.
|
||||||
|
--
|
||||||
|
-- Nothing here is cached: the two table lookups are free next to a frame, and
|
||||||
|
-- caching would freeze whatever the first caller saw, which tests override.
|
||||||
|
|
||||||
|
local Console = {}
|
||||||
|
|
||||||
|
-- The console names LOVE Potion publishes. An unrecognized string is treated
|
||||||
|
-- as "not a console we know", not as a console -- a future port would need its
|
||||||
|
-- own branches here anyway, and guessing would silently apply 3DS-shaped
|
||||||
|
-- compromises to hardware that does not need them.
|
||||||
|
local NAMES = { ["3DS"] = true, ["Switch"] = true, ["Wii U"] = true }
|
||||||
|
|
||||||
|
-- "3DS" / "Switch" / "Wii U", or nil.
|
||||||
|
function Console.name()
|
||||||
|
local name = love and rawget(love, "_console")
|
||||||
|
if type(name) == "string" and NAMES[name] then return name end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Running on a LOVE Potion console at all. Falls back to the OS name so a
|
||||||
|
-- build that stops publishing love._console still takes the console paths
|
||||||
|
-- (both strings are unique to LOVE Potion), even though it cannot then say
|
||||||
|
-- which console it is.
|
||||||
|
function Console.isConsole()
|
||||||
|
if Console.name() then return true end
|
||||||
|
local os = love and love.system and love.system.getOS and love.system.getOS()
|
||||||
|
return os == "Horizon" or os == "Cafe"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The 3DS specifically: a 400x240 top screen on a 268MHz ARM11, which is the
|
||||||
|
-- one target where effects the other two render for free have to be dropped.
|
||||||
|
function Console.is3DS()
|
||||||
|
return Console.name() == "3DS"
|
||||||
|
end
|
||||||
|
|
||||||
|
return Console
|
||||||
@@ -112,7 +112,11 @@ end
|
|||||||
local function loadImages()
|
local function loadImages()
|
||||||
local img = {}
|
local img = {}
|
||||||
for name, path in pairs(IMAGES) do
|
for name, path in pairs(IMAGES) do
|
||||||
local ok, im = pcall(love.graphics.newImage, path)
|
-- Resolved, not raw: the 3DS build ships these as .t3x (the bundler
|
||||||
|
-- converts and drops the .png). Without this the pcall below quietly
|
||||||
|
-- fails and the console loses its on-screen controls entirely.
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
|
local ok, im = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||||
if not ok then return nil end
|
if not ok then return nil end
|
||||||
im:setFilter("linear", "linear")
|
im:setFilter("linear", "linear")
|
||||||
img[name] = im
|
img[name] = im
|
||||||
|
|||||||
+207
-49
@@ -1,6 +1,10 @@
|
|||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
local HostShell = require("src.core.HostShell")
|
local HostShell = require("src.core.HostShell")
|
||||||
|
local Console = require("src.core.Console")
|
||||||
|
-- for Assets.resolve only: the launcher's own art has to survive the 3DS
|
||||||
|
-- bundler rewriting every shipped .png to .t3x
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
|
|
||||||
local RomImporter = {}
|
local RomImporter = {}
|
||||||
RomImporter.__index = RomImporter
|
RomImporter.__index = RomImporter
|
||||||
@@ -281,13 +285,42 @@ end
|
|||||||
-- moment and never the launcher. pump() only drains OS events into LOVE's
|
-- moment and never the launcher. pump() only drains OS events into LOVE's
|
||||||
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
|
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
|
||||||
-- and the release is still delivered normally on the next frame.
|
-- and the release is still delivered normally on the next frame.
|
||||||
|
-- Pointer reads, for platforms that may have no pointer module at all.
|
||||||
|
--
|
||||||
|
-- LOVE Potion builds no love.mouse: its source/modules directory ships touch,
|
||||||
|
-- joystick and keyboard, and nothing else that points. The launcher polls the
|
||||||
|
-- pointer every frame (no move events reach it), so on a console every one of
|
||||||
|
-- those reads would index a nil module. Off-screen coordinates are the honest
|
||||||
|
-- answer for a machine with no cursor: they hover nothing, which is what the
|
||||||
|
-- hit-testing below should conclude.
|
||||||
|
-- Whether there is a cursor to poll and hover with: Android and iOS emulate a
|
||||||
|
-- mouse from touch, and the LOVE Potion consoles have no love.mouse at all.
|
||||||
|
--
|
||||||
|
-- Derived on each read rather than cached in a field at construction, because
|
||||||
|
-- importers are also built by hand (setmetatable, a couple of fields) in tests
|
||||||
|
-- and would silently lose a derived flag while still setting `android` -- the
|
||||||
|
-- inputs are what callers set, so the inputs are what this reads.
|
||||||
|
local function pointerless(self)
|
||||||
|
return self.android or self.console ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mousePosition()
|
||||||
|
if not (love.mouse and love.mouse.getPosition) then return -1, -1 end
|
||||||
|
return love.mouse.getPosition()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mouseDown(...)
|
||||||
|
if not (love.mouse and love.mouse.isDown) then return false end
|
||||||
|
return love.mouse.isDown(...)
|
||||||
|
end
|
||||||
|
|
||||||
local function releasePointerGrab()
|
local function releasePointerGrab()
|
||||||
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
|
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
|
||||||
and love.timer) then
|
and love.timer) then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local deadline = love.timer.getTime() + 1
|
local deadline = love.timer.getTime() + 1
|
||||||
while love.mouse.isDown(1, 2, 3) do
|
while mouseDown(1, 2, 3) do
|
||||||
love.event.pump()
|
love.event.pump()
|
||||||
if love.timer.getTime() > deadline then break end
|
if love.timer.getTime() > deadline then break end
|
||||||
love.timer.sleep(0.005)
|
love.timer.sleep(0.005)
|
||||||
@@ -544,6 +577,11 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- keeps its historical name so every Android call site stays untouched.
|
-- keeps its historical name so every Android call site stays untouched.
|
||||||
local mobileOS = love.system.getOS()
|
local mobileOS = love.system.getOS()
|
||||||
local android = mobileOS == "Android" or mobileOS == "iOS"
|
local android = mobileOS == "Android" or mobileOS == "iOS"
|
||||||
|
-- The LOVE Potion consoles ("3DS" / "Switch" / "Wii U", or nil elsewhere).
|
||||||
|
-- They are NOT folded into `android`: that flag also means SAF pickers and
|
||||||
|
-- Files-app export, none of which exist here. What they share is having no
|
||||||
|
-- pointer, which is what `pointerless` names.
|
||||||
|
local console = Console.name()
|
||||||
local CacheFs = require("src.import.CacheFs")
|
local CacheFs = require("src.import.CacheFs")
|
||||||
local self = setmetatable({
|
local self = setmetatable({
|
||||||
onComplete = onComplete,
|
onComplete = onComplete,
|
||||||
@@ -552,6 +590,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
onEditSave = opts.onEditSave,
|
onEditSave = opts.onEditSave,
|
||||||
onEditTouchControls = opts.onEditTouchControls,
|
onEditTouchControls = opts.onEditTouchControls,
|
||||||
android = android,
|
android = android,
|
||||||
|
console = console,
|
||||||
ios = mobileOS == "iOS",
|
ios = mobileOS == "iOS",
|
||||||
-- One startup poll pass: files dropped through the Files app are swept
|
-- One startup poll pass: files dropped through the Files app are swept
|
||||||
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
|
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
|
||||||
@@ -563,11 +602,17 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- love.touch IS pollable, so where it exists a touch drag can be resolved
|
-- love.touch IS pollable, so where it exists a touch drag can be resolved
|
||||||
-- inside draw the same way the desktop mouse is. Where it does not, every
|
-- inside draw the same way the desktop mouse is. Where it does not, every
|
||||||
-- Android path stays exactly as it was: act on press, never arm.
|
-- Android path stays exactly as it was: act on press, never arm.
|
||||||
touchPollable = android and love.touch ~= nil
|
-- The consoles have a touchscreen too (the 3DS bottom screen, the Wii U
|
||||||
|
-- gamepad, the Switch in handheld mode) and LOVE Potion does build a touch
|
||||||
|
-- module, so they resolve a drag the same pollable way.
|
||||||
|
touchPollable = (android or console ~= nil) and love.touch ~= nil
|
||||||
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
|
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
|
||||||
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
||||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
-- Through Assets.resolve: on the 3DS these two are .t3x inside the build,
|
||||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
-- and these are the very first images the app loads, so an unresolved .png
|
||||||
|
-- here is the whole launcher failing before it draws a single frame.
|
||||||
|
logo = love.graphics.newImage(Assets.resolve("assets/logo/logo.png")),
|
||||||
|
bcg = love.graphics.newImage(Assets.resolve("assets/logo/bcg.png")),
|
||||||
ready = {}, returning = {}, romName = {},
|
ready = {}, returning = {}, romName = {},
|
||||||
importing = nil, -- the version currently extracting, or nil
|
importing = nil, -- the version currently extracting, or nil
|
||||||
workState = nil, -- "working" / "complete" / "error" for that import
|
workState = nil, -- "working" / "complete" / "error" for that import
|
||||||
@@ -639,11 +684,17 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
|
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
|
||||||
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
|
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
|
||||||
-- so a stale picked_rom.gb cannot block another version.
|
-- so a stale picked_rom.gb cannot block another version.
|
||||||
|
--
|
||||||
|
-- The consoles ride the same scan, and it is their ONLY way in: LOVE Potion
|
||||||
|
-- has no file picker, no drag-and-drop and no shell to fall back on, so a
|
||||||
|
-- 3DS/Switch/Wii U player copies the cart onto the SD card and launches. The
|
||||||
|
-- scan reads the physfs root, which covers both the save directory and the
|
||||||
|
-- game folder itself, so a ROM sitting next to main.lua is found too.
|
||||||
local needRom = false
|
local needRom = false
|
||||||
for _, version in ipairs(GameVersion.ORDER) do
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
if not self.ready[version] then needRom = true; break end
|
if not self.ready[version] then needRom = true; break end
|
||||||
end
|
end
|
||||||
if android and needRom then
|
if (android or self.console) and needRom then
|
||||||
local name, data = findPendingRom(self.ready)
|
local name, data = findPendingRom(self.ready)
|
||||||
if name then
|
if name then
|
||||||
self:startData(data, name)
|
self:startData(data, name)
|
||||||
@@ -786,8 +837,9 @@ end
|
|||||||
-- Choose control. Once the importer is torn down that draw path stops
|
-- Choose control. Once the importer is torn down that draw path stops
|
||||||
-- running, so restore the arrow before handing off to boot (issue #114).
|
-- running, so restore the arrow before handing off to boot (issue #114).
|
||||||
local function resetPointerCursor(self)
|
local function resetPointerCursor(self)
|
||||||
if self.android then return end
|
if pointerless(self) then return end
|
||||||
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
|
if not (love.mouse and love.mouse.isCursorSupported
|
||||||
|
and love.mouse.isCursorSupported()) then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
|
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
|
||||||
@@ -1126,6 +1178,30 @@ end
|
|||||||
function RomImporter:choose(version)
|
function RomImporter:choose(version)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
self.chooseVersion = version or "red"
|
self.chooseVersion = version or "red"
|
||||||
|
if self.console then
|
||||||
|
-- LOVE Potion has no picker of any kind (no love.system.pickFile, no
|
||||||
|
-- drag-and-drop, no shell to fall back on), so the only way in is a file
|
||||||
|
-- already on the SD card. Rescan, and failing that say exactly where the
|
||||||
|
-- cart has to go rather than opening a dialog that cannot exist.
|
||||||
|
local name, data = findPendingRom(self.ready)
|
||||||
|
if name then
|
||||||
|
self:startData(data, name)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Report the SAVE directory, not the source. Both are on the scan's read
|
||||||
|
-- path, but the source is inside the executable for a fused (packaged)
|
||||||
|
-- build, so naming it would send the player somewhere they cannot write.
|
||||||
|
-- The save directory is a real SD folder in either layout.
|
||||||
|
self.notice = {
|
||||||
|
version = self.chooseVersion,
|
||||||
|
status = "Copy your .gb/.gbc onto the SD card, into:",
|
||||||
|
detail = (love.filesystem.getSaveDirectory
|
||||||
|
and love.filesystem.getSaveDirectory())
|
||||||
|
or (love.filesystem.getSource and love.filesystem.getSource())
|
||||||
|
or "the game folder",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.android then
|
if self.android then
|
||||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||||
@@ -1272,7 +1348,7 @@ end
|
|||||||
function RomImporter:_updatePadCursor(dt)
|
function RomImporter:_updatePadCursor(dt)
|
||||||
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
||||||
-- pointer after bumping a stick once.
|
-- pointer after bumping a stick once.
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = mousePosition()
|
||||||
if self._lastMouseX and self._padCursorActive then
|
if self._lastMouseX and self._padCursorActive then
|
||||||
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
||||||
self._padCursorActive = false
|
self._padCursorActive = false
|
||||||
@@ -1467,23 +1543,54 @@ end
|
|||||||
-- One reusable unit quad, recoloured per call, for every vertical gradient
|
-- One reusable unit quad, recoloured per call, for every vertical gradient
|
||||||
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
|
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
|
||||||
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
|
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
|
||||||
local gradMesh
|
local gradMesh -- false once resolved unavailable, so the attempt is made once
|
||||||
local function setGrad(cTop, cBot, aTop, aBot)
|
local function setGrad(cTop, cBot, aTop, aBot)
|
||||||
if not gradMesh then gradMesh = love.graphics.newMesh(4, "fan", "dynamic") end
|
if gradMesh == nil then
|
||||||
|
local ok, mesh = pcall(love.graphics.newMesh, 4, "fan", "dynamic")
|
||||||
|
gradMesh = ok and mesh or false
|
||||||
|
end
|
||||||
|
if not gradMesh then return false end
|
||||||
gradMesh:setVertices({
|
gradMesh:setVertices({
|
||||||
{ 0, 0, 0, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop },
|
{ 0, 0, 0, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop },
|
||||||
{ 1, 0, 1, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop },
|
{ 1, 0, 1, 0, cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop },
|
||||||
{ 1, 1, 1, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot },
|
{ 1, 1, 1, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot },
|
||||||
{ 0, 1, 0, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot },
|
{ 0, 1, 0, 1, cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot },
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
local function fillGrad(x, y, w, h, cTop, cBot, aTop, aBot)
|
local function fillGrad(x, y, w, h, cTop, cBot, aTop, aBot)
|
||||||
setGrad(cTop, cBot, aTop, aBot)
|
if not setGrad(cTop, cBot, aTop, aBot) then return end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(gradMesh, x, y, 0, w, h)
|
love.graphics.draw(gradMesh, x, y, 0, w, h)
|
||||||
end
|
end
|
||||||
|
-- Whether the rounded-rect clip below can be drawn at all. love.graphics.stencil
|
||||||
|
-- is an 11.x entry point that LOVE 12 replaced, and the LOVE Potion console
|
||||||
|
-- backends (3DS/Switch/Wii U) expose no stencil path either, so resolve it once
|
||||||
|
-- and let each caller degrade rather than crash the launcher -- which on those
|
||||||
|
-- platforms is also what runs the ROM auto-import.
|
||||||
|
local stencilOk = nil
|
||||||
|
local function hasStencil()
|
||||||
|
if stencilOk == nil then
|
||||||
|
stencilOk = type(love.graphics.stencil) == "function"
|
||||||
|
and type(love.graphics.setStencilTest) == "function"
|
||||||
|
end
|
||||||
|
return stencilOk
|
||||||
|
end
|
||||||
|
|
||||||
-- vertical gradient clipped to a rounded rectangle (via the stencil buffer)
|
-- vertical gradient clipped to a rounded rectangle (via the stencil buffer)
|
||||||
local function fillGradRounded(x, y, w, h, r, cTop, cBot, aTop, aBot)
|
local function fillGradRounded(x, y, w, h, r, cTop, cBot, aTop, aBot)
|
||||||
|
if not hasStencil() then
|
||||||
|
-- Without a stencil the gradient mesh cannot be clipped to the rounded
|
||||||
|
-- shape, and an unclipped mesh would square off every panel corner in the
|
||||||
|
-- launcher. Fill the rounded rect with the gradient's vertical midpoint
|
||||||
|
-- instead: the falloff is lost, the silhouette every card and button is
|
||||||
|
-- built from is not.
|
||||||
|
love.graphics.setColor((cTop[1] + cBot[1]) / 510, (cTop[2] + cBot[2]) / 510,
|
||||||
|
(cTop[3] + cBot[3]) / 510, (aTop + aBot) / 2)
|
||||||
|
love.graphics.rectangle("fill", x, y, w, h, r, r)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
return
|
||||||
|
end
|
||||||
love.graphics.stencil(function()
|
love.graphics.stencil(function()
|
||||||
love.graphics.rectangle("fill", x, y, w, h, r, r)
|
love.graphics.rectangle("fill", x, y, w, h, r, r)
|
||||||
end, "replace", 1)
|
end, "replace", 1)
|
||||||
@@ -1512,6 +1619,10 @@ end
|
|||||||
-- rounded shape. phase is 0..1 (left of the button -> right of it).
|
-- rounded shape. phase is 0..1 (left of the button -> right of it).
|
||||||
local shineMesh
|
local shineMesh
|
||||||
local function buttonShine(x, y, w, h, r, phase)
|
local function buttonShine(x, y, w, h, r, phase)
|
||||||
|
-- Pure decoration, and it is the stencil that keeps the band inside the
|
||||||
|
-- button: with no stencil, skip it rather than paint a rectangle across the
|
||||||
|
-- panel behind.
|
||||||
|
if not hasStencil() then return end
|
||||||
if not shineMesh then
|
if not shineMesh then
|
||||||
-- triangle strip: three columns (transparent, white, transparent)
|
-- triangle strip: three columns (transparent, white, transparent)
|
||||||
shineMesh = love.graphics.newMesh({
|
shineMesh = love.graphics.newMesh({
|
||||||
@@ -1691,9 +1802,9 @@ function RomImporter:draw()
|
|||||||
if self._padCursorActive then
|
if self._padCursorActive then
|
||||||
self._mx, self._my = self._padCursor.x, self._padCursor.y
|
self._mx, self._my = self._padCursor.x, self._padCursor.y
|
||||||
else
|
else
|
||||||
self._mx, self._my = love.mouse.getPosition()
|
self._mx, self._my = mousePosition()
|
||||||
end
|
end
|
||||||
self._hoverEnabled = self._padCursorActive or not self.android
|
self._hoverEnabled = self._padCursorActive or not pointerless(self)
|
||||||
self._anyHover = false
|
self._anyHover = false
|
||||||
self:_resetFrameRects()
|
self:_resetFrameRects()
|
||||||
|
|
||||||
@@ -1734,7 +1845,10 @@ function RomImporter:draw()
|
|||||||
verts[#verts + 1] = { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0,
|
verts[#verts + 1] = { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0,
|
||||||
PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1 }
|
PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1 }
|
||||||
end
|
end
|
||||||
self.bgMesh = love.graphics.newMesh(verts, "fan", "static")
|
-- pcall'd like every other optional GPU object here: a backend without
|
||||||
|
-- meshes still gets the flat PAL.bgBot clear the fan draws over.
|
||||||
|
local ok, mesh = pcall(love.graphics.newMesh, verts, "fan", "static")
|
||||||
|
self.bgMesh = ok and mesh or false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
|
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
|
||||||
@@ -1748,12 +1862,18 @@ function RomImporter:draw()
|
|||||||
verts[#verts + 1] =
|
verts[#verts + 1] =
|
||||||
{ cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, 0, 0, 0, 0.32 }
|
{ cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0, 0, 0, 0, 0.32 }
|
||||||
end
|
end
|
||||||
self.vignetteMesh = love.graphics.newMesh(verts, "fan", "static")
|
local ok, mesh = pcall(love.graphics.newMesh, verts, "fan", "static")
|
||||||
|
self.vignetteMesh = ok and mesh or false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- CRT scanlines: a 1px dark line every 3px, baked into a tiny tile and
|
-- CRT scanlines: a 1px dark line every 3px, baked into a tiny tile and
|
||||||
-- drawn once with a repeat-wrapped quad (one draw call, correct alpha).
|
-- drawn once with a repeat-wrapped quad (one draw call, correct alpha).
|
||||||
if not self.scanlineImage then
|
--
|
||||||
|
-- Off on the 3DS. The tile is 1x3, and non-power-of-two textures cannot be
|
||||||
|
-- "repeat"-wrapped on that GPU, so the one draw call this effect is built
|
||||||
|
-- around is exactly the call it cannot make there. A 240px-tall screen has
|
||||||
|
-- little room for a 3px CRT pitch to read as anything but noise anyway.
|
||||||
|
if not self.scanlineImage and not Console.is3DS() then
|
||||||
local id = love.image.newImageData(1, 3)
|
local id = love.image.newImageData(1, 3)
|
||||||
id:setPixel(0, 0, 0, 0, 0, 0.08)
|
id:setPixel(0, 0, 0, 0, 0, 0.08)
|
||||||
id:setPixel(0, 1, 0, 0, 0, 0)
|
id:setPixel(0, 1, 0, 0, 0, 0)
|
||||||
@@ -1762,37 +1882,63 @@ function RomImporter:draw()
|
|||||||
self.scanlineImage:setWrap("repeat", "repeat")
|
self.scanlineImage:setWrap("repeat", "repeat")
|
||||||
self.scanlineImage:setFilter("nearest", "nearest")
|
self.scanlineImage:setFilter("nearest", "nearest")
|
||||||
end
|
end
|
||||||
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
|
self.scanlineQuad = self.scanlineImage
|
||||||
|
and love.graphics.newQuad(0, 0, width, height, 1, 3) or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
|
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
|
||||||
-- is rendered white (the design's filter:invert(1)). Built lazily so a
|
-- is rendered white (the design's filter:invert(1)). Built lazily so a
|
||||||
-- headless require never needs a GL context.
|
-- headless require never needs a GL context, and pcall'd because LOVE Potion
|
||||||
self.invertShader = self.invertShader or love.graphics.newShader([[
|
-- (3DS/Switch/Wii U) ships no shader compiler at all. `false` is the
|
||||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
-- resolved-unavailable sentinel, so the attempt happens once, not per frame.
|
||||||
vec4 p = Texel(tex, tc);
|
if self.invertShader == nil then
|
||||||
return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a);
|
local ok, sh = pcall(love.graphics.newShader, [[
|
||||||
}
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||||
]])
|
vec4 p = Texel(tex, tc);
|
||||||
|
return vec4((vec3(1.0) - p.rgb) * color.rgb, p.a * color.a);
|
||||||
|
}
|
||||||
|
]])
|
||||||
|
self.invertShader = ok and sh or false
|
||||||
|
if not self.invertShader then
|
||||||
|
-- No shader to invert with, and the un-inverted mark is dark ink on a
|
||||||
|
-- near-black panel, i.e. invisible. Invert it once on the CPU instead;
|
||||||
|
-- it is one small image, and the result draws identically.
|
||||||
|
-- love.image is itself optional (a headless stub omits it), and indexing
|
||||||
|
-- the field is what would raise -- pcall never gets to run on a nil
|
||||||
|
-- module -- so probe the module before the call.
|
||||||
|
local newImageData = love.image and love.image.newImageData
|
||||||
|
local okd, id = false, nil
|
||||||
|
if newImageData then okd, id = pcall(newImageData, "assets/logo/bcg.png") end
|
||||||
|
if okd and id and id.mapPixel then
|
||||||
|
id:mapPixel(function(_, _, r, g, b, a) return 1 - r, 1 - g, 1 - b, a end)
|
||||||
|
local oki, img = pcall(love.graphics.newImage, id)
|
||||||
|
self.bcgInverted = oki and img or nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Shine shader: the same white sweep the active buttons get, but clipped to
|
-- Shine shader: the same white sweep the active buttons get, but clipped to
|
||||||
-- the logo's own shape (a soft band brightens the pixels it crosses; fully
|
-- the logo's own shape (a soft band brightens the pixels it crosses; fully
|
||||||
-- transparent pixels stay transparent).
|
-- transparent pixels stay transparent). Decoration only -- where shaders are
|
||||||
self.shineShader = self.shineShader or love.graphics.newShader([[
|
-- unavailable the logo simply draws without the sweep.
|
||||||
extern number shinePos;
|
if self.shineShader == nil then
|
||||||
extern number shineW;
|
local ok, sh = pcall(love.graphics.newShader, [[
|
||||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
extern number shinePos;
|
||||||
vec4 p = Texel(tex, tc);
|
extern number shineW;
|
||||||
float band = smoothstep(shineW, 0.0, abs(tc.x - shinePos));
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||||
return vec4(p.rgb + band * 0.55, p.a) * color;
|
vec4 p = Texel(tex, tc);
|
||||||
}
|
float band = smoothstep(shineW, 0.0, abs(tc.x - shinePos));
|
||||||
]])
|
return vec4(p.rgb + band * 0.55, p.a) * color;
|
||||||
|
}
|
||||||
|
]])
|
||||||
|
self.shineShader = ok and sh or false
|
||||||
|
end
|
||||||
|
|
||||||
-- background
|
-- background
|
||||||
col(PAL.bgBot)
|
col(PAL.bgBot)
|
||||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(self.bgMesh)
|
if self.bgMesh then love.graphics.draw(self.bgMesh) end
|
||||||
|
|
||||||
-- Centered content container (max ~1440 scaled units on very wide windows)
|
-- Centered content container (max ~1440 scaled units on very wide windows)
|
||||||
-- with a responsive side gutter; every column below derives from these.
|
-- with a responsive side gutter; every column below derives from these.
|
||||||
@@ -2012,15 +2158,18 @@ function RomImporter:draw()
|
|||||||
self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH }
|
self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH }
|
||||||
|
|
||||||
local bcgHot = self:_hover(self.bcgButton)
|
local bcgHot = self:_hover(self.bcgButton)
|
||||||
love.graphics.setShader(self.invertShader)
|
-- Either the shader inverts self.bcg on the GPU, or self.bcgInverted already
|
||||||
|
-- holds the CPU-inverted copy; both draw through the same two passes.
|
||||||
|
local bcgImg = self.invertShader and self.bcg or (self.bcgInverted or self.bcg)
|
||||||
|
if self.invertShader then love.graphics.setShader(self.invertShader) end
|
||||||
love.graphics.setBlendMode("add")
|
love.graphics.setBlendMode("add")
|
||||||
love.graphics.setColor(1, 1, 1, bcgHot and 0.5 or 0.22)
|
love.graphics.setColor(1, 1, 1, bcgHot and 0.5 or 0.22)
|
||||||
love.graphics.draw(self.bcg, bcgX - bcgDW * 0.02, bcgY - bcgDH * 0.02, 0,
|
love.graphics.draw(bcgImg, bcgX - bcgDW * 0.02, bcgY - bcgDH * 0.02, 0,
|
||||||
bcgScale * 1.04, bcgScale * 1.04)
|
bcgScale * 1.04, bcgScale * 1.04)
|
||||||
love.graphics.setBlendMode("alpha")
|
love.graphics.setBlendMode("alpha")
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(self.bcg, bcgX, bcgY, 0, bcgScale, bcgScale)
|
love.graphics.draw(bcgImg, bcgX, bcgY, 0, bcgScale, bcgScale)
|
||||||
love.graphics.setShader()
|
if self.invertShader then love.graphics.setShader() end
|
||||||
|
|
||||||
love.graphics.setFont(self.warningFont)
|
love.graphics.setFont(self.warningFont)
|
||||||
col(PAL.warning)
|
col(PAL.warning)
|
||||||
@@ -2062,12 +2211,14 @@ function RomImporter:draw()
|
|||||||
logoScale * 1.05, logoScale * 1.05)
|
logoScale * 1.05, logoScale * 1.05)
|
||||||
love.graphics.setBlendMode("alpha")
|
love.graphics.setBlendMode("alpha")
|
||||||
local shineW = 0.16
|
local shineW = 0.16
|
||||||
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
|
if self.shineShader then
|
||||||
self.shineShader:send("shineW", shineW)
|
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
|
||||||
love.graphics.setShader(self.shineShader)
|
self.shineShader:send("shineW", shineW)
|
||||||
|
love.graphics.setShader(self.shineShader)
|
||||||
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale)
|
love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale)
|
||||||
love.graphics.setShader()
|
if self.shineShader then love.graphics.setShader() end
|
||||||
|
|
||||||
-- page scrollbar: the same thin thumb the lists use, against the app edge
|
-- page scrollbar: the same thin thumb the lists use, against the app edge
|
||||||
if paged then
|
if paged then
|
||||||
@@ -2080,8 +2231,10 @@ function RomImporter:draw()
|
|||||||
|
|
||||||
-- CRT scanlines + vignette, over everything
|
-- CRT scanlines + vignette, over everything
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0)
|
if self.scanlineImage then
|
||||||
love.graphics.draw(self.vignetteMesh)
|
love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0)
|
||||||
|
end
|
||||||
|
if self.vignetteMesh then love.graphics.draw(self.vignetteMesh) end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|
||||||
-- drag-to-scroll the save-slot list (polls the pointer; no move/release
|
-- drag-to-scroll the save-slot list (polls the pointer; no move/release
|
||||||
@@ -2459,7 +2612,8 @@ function RomImporter:draw()
|
|||||||
|
|
||||||
-- pointer cursor over any interactive element (desktop only)
|
-- pointer cursor over any interactive element (desktop only)
|
||||||
if self._hoverEnabled and not self._padCursorActive
|
if self._hoverEnabled and not self._padCursorActive
|
||||||
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
and love.mouse and love.mouse.isCursorSupported
|
||||||
|
and love.mouse.isCursorSupported() then
|
||||||
if self._anyHover then
|
if self._anyHover then
|
||||||
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
||||||
love.mouse.setCursor(self.handCursor)
|
love.mouse.setCursor(self.handCursor)
|
||||||
@@ -2583,11 +2737,11 @@ function RomImporter:mousepressed(x, y, button)
|
|||||||
end
|
end
|
||||||
-- Whether a press can be ARMED and resolved on release, which needs a
|
-- Whether a press can be ARMED and resolved on release, which needs a
|
||||||
-- pollable pointer: always on desktop, on Android only where love.touch is.
|
-- pollable pointer: always on desktop, on Android only where love.touch is.
|
||||||
local armDrag = (not self.android) or self.touchPollable
|
local armDrag = (not pointerless(self)) or self.touchPollable
|
||||||
-- right-click a save-slot row to rename it (#205); desktop only (touch
|
-- right-click a save-slot row to rename it (#205); desktop only (touch
|
||||||
-- has no secondary button)
|
-- has no secondary button)
|
||||||
if button == 2 then
|
if button == 2 then
|
||||||
if not self.android and self.workState ~= "working" then
|
if not pointerless(self) and self.workState ~= "working" then
|
||||||
for _, r in ipairs(self.slotRects or {}) do
|
for _, r in ipairs(self.slotRects or {}) do
|
||||||
if inside(r, x, y) then
|
if inside(r, x, y) then
|
||||||
self:_beginRename(self.panelVersion, r.id)
|
self:_beginRename(self.panelVersion, r.id)
|
||||||
@@ -3211,6 +3365,10 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
|
|||||||
sfHintText, sfHintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red)
|
sfHintText, sfHintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red)
|
||||||
elseif locked then
|
elseif locked then
|
||||||
sfHintText, sfHintCol = "Not available yet.", PAL.warning
|
sfHintText, sfHintCol = "Not available yet.", PAL.warning
|
||||||
|
elseif self.console then
|
||||||
|
-- No picker here either, so the .sav moves the same way the ROM did.
|
||||||
|
sfHintText, sfHintCol =
|
||||||
|
"Copy a .sav onto the SD card, next to the game.", PAL.warning
|
||||||
elseif self.android then
|
elseif self.android then
|
||||||
sfHintText, sfHintCol =
|
sfHintText, sfHintCol =
|
||||||
"Import or export a .sav with the system file picker.", PAL.warning
|
"Import or export a .sav with the system file picker.", PAL.warning
|
||||||
@@ -3445,7 +3603,7 @@ end
|
|||||||
-- first active touch on Android. A nil y means "nothing to read" -- the
|
-- first active touch on Android. A nil y means "nothing to read" -- the
|
||||||
-- release branches below do not need one.
|
-- release branches below do not need one.
|
||||||
function RomImporter:_pointerHold()
|
function RomImporter:_pointerHold()
|
||||||
if not self.android then return love.mouse.isDown(1), self._my end
|
if not pointerless(self) then return mouseDown(1), self._my end
|
||||||
if not self.touchPollable then return false, nil end
|
if not self.touchPollable then return false, nil end
|
||||||
local ok, list = pcall(love.touch.getTouches)
|
local ok, list = pcall(love.touch.getTouches)
|
||||||
if not ok or type(list) ~= "table" or list[1] == nil then return false, nil end
|
if not ok or type(list) ~= "table" or list[1] == nil then return false, nil end
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
-- keeps a mod-free boot (and every headless test) loading exactly the
|
-- keeps a mod-free boot (and every headless test) loading exactly the
|
||||||
-- paths it always did.
|
-- paths it always did.
|
||||||
|
|
||||||
|
-- Console has no requires of its own, so pulling it in here cannot cycle.
|
||||||
|
local Console = require("src.core.Console")
|
||||||
|
|
||||||
local Assets = {}
|
local Assets = {}
|
||||||
|
|
||||||
-- resolved path -> love Image
|
-- resolved path -> love Image
|
||||||
@@ -29,10 +32,38 @@ local function exists(path)
|
|||||||
end
|
end
|
||||||
Assets.exists = exists
|
Assets.exists = exists
|
||||||
|
|
||||||
|
-- 3DS: the lovebrew bundler converts every shipped PNG in the bundle to .t3x
|
||||||
|
-- (the console's own texture format) and does NOT keep the .png, so a .png
|
||||||
|
-- path written in the source resolves to a file that is not in the build --
|
||||||
|
-- which is a hard error at load, not a missing-texture placeholder. Swap in
|
||||||
|
-- the sibling .t3x when one exists.
|
||||||
|
--
|
||||||
|
-- Existence-driven rather than blanket, because only the shipped art is
|
||||||
|
-- converted: everything under assets/generated/ is written as PNG at runtime
|
||||||
|
-- by the ROM import, long after the bundler has run, and must stay PNG.
|
||||||
|
--
|
||||||
|
-- Memoized, because resolve() is on the per-draw path and this would
|
||||||
|
-- otherwise cost a filesystem stat per image request per frame.
|
||||||
|
local t3xFor = {}
|
||||||
|
local function consoleTexture(path)
|
||||||
|
local hit = t3xFor[path]
|
||||||
|
if hit ~= nil then return hit end
|
||||||
|
local swapped = path
|
||||||
|
if path:sub(-4) == ".png" then
|
||||||
|
local candidate = path:sub(1, -5) .. ".t3x"
|
||||||
|
if exists(candidate) then swapped = candidate end
|
||||||
|
end
|
||||||
|
t3xFor[path] = swapped
|
||||||
|
return swapped
|
||||||
|
end
|
||||||
|
|
||||||
-- an override dir shadows the generated cache; a transform's derived
|
-- an override dir shadows the generated cache; a transform's derived
|
||||||
-- output is the fallback under it, so hand-authored art beats generated
|
-- output is the fallback under it, so hand-authored art beats generated
|
||||||
function Assets.resolve(path)
|
function Assets.resolve(path)
|
||||||
local loader = Assets.loader
|
local loader = Assets.loader
|
||||||
|
if type(path) == "string" and Console.is3DS() then
|
||||||
|
path = consoleTexture(path)
|
||||||
|
end
|
||||||
if not loader or type(path) ~= "string" then return path end
|
if not loader or type(path) ~= "string" then return path end
|
||||||
if path:sub(1, #GENERATED) ~= GENERATED then return path end
|
if path:sub(1, #GENERATED) ~= GENERATED then return path end
|
||||||
local rel = path:sub(#GENERATED + 1)
|
local rel = path:sub(#GENERATED + 1)
|
||||||
|
|||||||
@@ -830,6 +830,15 @@ end
|
|||||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
||||||
-- applying the active COLORS display mode
|
-- applying the active COLORS display mode
|
||||||
function PaletteFX.sendColors(shader, c)
|
function PaletteFX.sendColors(shader, c)
|
||||||
|
-- No shader is a real state, not a caller bug: shader() and keyedShader()
|
||||||
|
-- both resolve to nil where the backend compiles none (the headless stub, and
|
||||||
|
-- every LOVE Potion console -- that port ships no shader compiler at all).
|
||||||
|
-- Most callers test for it and skip the whole colorized path, but the battle
|
||||||
|
-- zone pass sets the shader once and then feeds this per zone inside its
|
||||||
|
-- loop, so the nil has to stop here or it becomes a nil:send() mid-battle.
|
||||||
|
-- Falling through with no uniforms sent is the right degradation: the zones
|
||||||
|
-- draw uncolorized, which is the DMG picture the fallback path renders.
|
||||||
|
if not shader then return end
|
||||||
c = PaletteFX.effectiveColors(c)
|
c = PaletteFX.effectiveColors(c)
|
||||||
if not c then return end
|
if not c then return end
|
||||||
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
|||||||
|
|
||||||
local function tryImage(path)
|
local function tryImage(path)
|
||||||
if not path then return nil end
|
if not path then return nil end
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
-- Assets.resolve so the shipped title art still loads on the 3DS, where the
|
||||||
|
-- bundler has rewritten it to .t3x. Generated title art is unaffected: it
|
||||||
|
-- is written as PNG at runtime and has no .t3x sibling to swap to.
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
|
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||||
return ok and img or nil
|
return ok and img or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- 3DS texture paths: the bundler rewrites shipped PNGs to .t3x.
|
||||||
|
--
|
||||||
|
-- The lovebrew bundler converts every image in the bundle to the 3DS texture
|
||||||
|
-- format and does NOT keep the original, so `assets/logo/logo.png` simply does
|
||||||
|
-- not exist inside a built .3dsx -- `assets/logo/logo.t3x` does. Loading the
|
||||||
|
-- .png path there is a hard error, and it fired in RomImporter.new, which is
|
||||||
|
-- the first thing the app builds: the launcher died before drawing a frame,
|
||||||
|
-- with nothing in the log after service registration.
|
||||||
|
--
|
||||||
|
-- Only shipped art is converted. Everything under assets/generated/ is
|
||||||
|
-- written as PNG by the ROM import at runtime, long after the bundler ran, so
|
||||||
|
-- the swap must be driven by whether a .t3x actually exists rather than
|
||||||
|
-- applied to every .png path.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
|
|
||||||
|
local love = _G.love
|
||||||
|
|
||||||
|
-- Model a built .3dsx: the shipped logo exists only as .t3x, while a
|
||||||
|
-- runtime-generated texture exists only as .png.
|
||||||
|
local present = {
|
||||||
|
["assets/logo/logo.t3x"] = true,
|
||||||
|
["assets/generated/tilesets/ship.png"] = true,
|
||||||
|
}
|
||||||
|
local realGetInfo = love.filesystem.getInfo
|
||||||
|
love.filesystem.getInfo = function(path, ...)
|
||||||
|
if present[path] then return { type = "file" } end
|
||||||
|
if path:match("^assets/") then return nil end
|
||||||
|
return realGetInfo and realGetInfo(path, ...) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local realConsole = love._console
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------- desktop
|
||||||
|
love._console = nil
|
||||||
|
T.eq(Assets.resolve("assets/logo/logo.png"), "assets/logo/logo.png",
|
||||||
|
"off-console, a .png path is left exactly as written")
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------- 3DS
|
||||||
|
love._console = "3DS"
|
||||||
|
T.eq(Assets.resolve("assets/logo/logo.png"), "assets/logo/logo.t3x",
|
||||||
|
"on a 3DS, shipped art resolves to the .t3x the bundler produced")
|
||||||
|
T.eq(Assets.resolve("assets/generated/tilesets/ship.png"),
|
||||||
|
"assets/generated/tilesets/ship.png",
|
||||||
|
"but a runtime-generated PNG stays PNG: it has no .t3x, having been written "
|
||||||
|
.. "after the bundler ran")
|
||||||
|
T.eq(Assets.resolve("assets/logo/missing.png"), "assets/logo/missing.png",
|
||||||
|
"and a path with no .t3x sibling is left alone rather than pointed at a "
|
||||||
|
.. "file that does not exist either")
|
||||||
|
|
||||||
|
-- Non-strings pass through untouched: resolve() is called with whatever a
|
||||||
|
-- caller had, and must not start indexing nil.
|
||||||
|
T.eq(Assets.resolve(nil), nil, "a nil path survives resolve")
|
||||||
|
|
||||||
|
-- The Switch and Wii U keep their PNGs -- only the 3DS gets converted art, so
|
||||||
|
-- swapping there would point at a file the build does not contain.
|
||||||
|
love._console = "Switch"
|
||||||
|
T.eq(Assets.resolve("assets/logo/logo.png"), "assets/logo/logo.png",
|
||||||
|
"the Switch keeps the .png: only the 3DS build converts textures")
|
||||||
|
|
||||||
|
love._console = realConsole
|
||||||
|
love.filesystem.getInfo = realGetInfo
|
||||||
|
|
||||||
|
T.finish("assets t3x 3ds")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- conf.lua on the LOVE Potion consoles (3DS / Switch / Wii U).
|
||||||
|
--
|
||||||
|
-- conf runs before love.load and before any module is available, so it has only
|
||||||
|
-- the love._* strings the engine sets during initialization to go on. Three
|
||||||
|
-- things have to come out right there, and none of them are observable later:
|
||||||
|
-- the declared API version (LOVE Potion is 12.0, this game declares 11.5), the
|
||||||
|
-- mouse module (that port builds none), and the window constraints (a 480x360
|
||||||
|
-- minimum is larger than the entire 3DS screen).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
|
||||||
|
-- A conf table shaped like the one LOVE hands love.conf: defaults already
|
||||||
|
-- filled in, for the callback to overwrite.
|
||||||
|
local function newConf()
|
||||||
|
return {
|
||||||
|
identity = nil, version = "0.0.0", window = {}, modules = {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runConf()
|
||||||
|
local t = newConf()
|
||||||
|
-- conf.lua defines a global love.conf; load it fresh each time so an earlier
|
||||||
|
-- run cannot leak state into the next.
|
||||||
|
assert(loadfile("conf.lua"))()
|
||||||
|
love.conf(t)
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
local realConsole, realVersion, realOs = love._console, love._version, love._os
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ desktop
|
||||||
|
love._console, love._version, love._os = nil, nil, "OS X"
|
||||||
|
local desktop = runConf()
|
||||||
|
T.eq(desktop.version, "11.5", "desktop still declares the LOVE it targets")
|
||||||
|
T.eq(desktop.window.resizable, true, "and keeps its resizable window")
|
||||||
|
T.eq(desktop.window.minwidth, 480, "with the drag floor the launcher needs")
|
||||||
|
T.eq(desktop.modules.mouse, nil, "and says nothing about the mouse module")
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ console
|
||||||
|
love._console, love._version, love._os = "3DS", "12.0", "Horizon"
|
||||||
|
local ctr = runConf()
|
||||||
|
T.eq(ctr.version, "12.0", "a console declares the API the running engine has, "
|
||||||
|
.. "read from love._version rather than hardcoded a second time")
|
||||||
|
T.eq(ctr.modules.mouse, false, "and asks for no mouse module, which that port "
|
||||||
|
.. "does not build")
|
||||||
|
T.eq(ctr.window.resizable, false, "a console window does not resize")
|
||||||
|
T.eq(ctr.window.minwidth, nil, "and carries no desktop drag floor: 480x360 is "
|
||||||
|
.. "larger than the whole 400x240 3DS screen")
|
||||||
|
T.eq(ctr.window.minheight, nil, "neither dimension of it")
|
||||||
|
|
||||||
|
-- The console branch has to run after the mobile/desktop branch, which ends in
|
||||||
|
-- an `else` that re-enables resizing. Ordering is the whole bug here, so pin
|
||||||
|
-- it on a second console rather than trusting one case.
|
||||||
|
love._console, love._version, love._os = "Switch", "12.0", "Horizon"
|
||||||
|
T.eq(runConf().window.resizable, false,
|
||||||
|
"the console branch wins over the desktop else that follows it")
|
||||||
|
|
||||||
|
-- A LOVE Potion build that stopped publishing love._version must not blank the
|
||||||
|
-- declared version, which would read as "no version" to the engine.
|
||||||
|
love._console, love._version, love._os = "Wii U", nil, "Cafe"
|
||||||
|
T.eq(runConf().version, "11.5", "a missing love._version falls back rather "
|
||||||
|
.. "than clearing the declaration")
|
||||||
|
|
||||||
|
love._console, love._version, love._os = realConsole, realVersion, realOs
|
||||||
|
|
||||||
|
T.finish("conf console")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
-- Launcher draw on a backend with no shaders, meshes or stencil.
|
||||||
|
--
|
||||||
|
-- LOVE Potion (the 3DS/Switch/Wii U port) ships no shader compiler at all, and
|
||||||
|
-- the console backends expose no stencil path, so every optional GPU object the
|
||||||
|
-- launcher builds has to resolve to a fallback instead of raising. This is not
|
||||||
|
-- cosmetic there: the launcher is also what runs the ROM auto-import on those
|
||||||
|
-- platforms (findPendingRom scans the save/source dir), so a crash in draw is a
|
||||||
|
-- crash before the player can import anything at all.
|
||||||
|
--
|
||||||
|
-- tests/love_stub deliberately omits newShader / newMesh / stencil, which makes
|
||||||
|
-- it exactly that backend. Drawing a full frame through it is the assertion.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
|
||||||
|
-- love_stub covers what the engine's headless tests need; the launcher is the
|
||||||
|
-- one screen that also asks for the desktop shell (OS name, pointer, clock).
|
||||||
|
-- Fill only the gaps, and only here, so the shared stub keeps modelling a
|
||||||
|
-- console-shaped graphics backend for everyone else.
|
||||||
|
local love = _G.love
|
||||||
|
-- Both the 3DS and the Switch report getOS() == "Horizon" (the LOVE Potion
|
||||||
|
-- build defines __OS__ that way for both), which is exactly why the console is
|
||||||
|
-- identified by love._console instead. Set here for the Switch pass; the 3DS
|
||||||
|
-- pass at the bottom swaps it.
|
||||||
|
love.system = love.system or { getOS = function() return "Horizon" end }
|
||||||
|
love._console = "Switch"
|
||||||
|
love.timer = love.timer or { getTime = function() return 0 end }
|
||||||
|
-- No love.mouse is stubbed in on purpose: LOVE Potion does not build that
|
||||||
|
-- module (its source/modules ships touch, joystick and keyboard, no mouse), so
|
||||||
|
-- its absence here is the platform under test, not a gap.
|
||||||
|
love.window = love.window or { getMode = function() return 640, 576, {} end }
|
||||||
|
|
||||||
|
-- love.image is genuinely present on LOVE Potion, so its absence from the
|
||||||
|
-- shared stub is a stub gap, not the platform under test. It stays local:
|
||||||
|
-- adding it to love_stub would flip the `love.image and ...` fallback branches
|
||||||
|
-- that TileRenderer, PartyMenu, Credits and Evolution are tested through.
|
||||||
|
if not love.image then
|
||||||
|
local ImageData = {}
|
||||||
|
ImageData.__index = ImageData
|
||||||
|
function ImageData:setPixel() end
|
||||||
|
function ImageData:mapPixel() end
|
||||||
|
function ImageData:getWidth() return self.w end
|
||||||
|
function ImageData:getHeight() return self.h end
|
||||||
|
love.image = {
|
||||||
|
newImageData = function(w, h)
|
||||||
|
-- the path form (a PNG on disk) is not modelled; callers that pass one
|
||||||
|
-- are expected to pcall, which is the behaviour being tested
|
||||||
|
if type(w) ~= "number" then error("no image decoder in this stub", 0) end
|
||||||
|
return setmetatable({ w = w, h = h }, ImageData)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local newImage = love.graphics.newImage
|
||||||
|
love.graphics.newImage = function(src)
|
||||||
|
if type(src) == "table" then
|
||||||
|
return { w = src.w, h = src.h, setWrap = function() end,
|
||||||
|
setFilter = function() end, getWidth = function() return src.w end,
|
||||||
|
getHeight = function() return src.h end }
|
||||||
|
end
|
||||||
|
return newImage(src)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The stub's fonts measure but do not wrap; the launcher's footer asks for the
|
||||||
|
-- wrapped lines so it can hit-test the community URL inside them.
|
||||||
|
do
|
||||||
|
local newFont = love.graphics.newFont
|
||||||
|
love.graphics.newFont = function(size)
|
||||||
|
local font = newFont(size)
|
||||||
|
font.getWrap = font.getWrap or function(self, text, width)
|
||||||
|
return math.min(width, self:getWidth(text)), { tostring(text) }
|
||||||
|
end
|
||||||
|
return font
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
|
||||||
|
local importer = RomImporter.new(function() end, { launcher = true })
|
||||||
|
|
||||||
|
-- Two frames: the first resolves every lazy object (shaders, meshes, the
|
||||||
|
-- CPU-inverted mark), the second proves the `false` sentinels are read back as
|
||||||
|
-- "unavailable" rather than retried and re-failed.
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
importer:draw()
|
||||||
|
importer:draw()
|
||||||
|
end)
|
||||||
|
T.eq(ok, true, "the launcher draws with no shaders, meshes or stencil: "
|
||||||
|
.. tostring(err))
|
||||||
|
|
||||||
|
-- The sentinels must be false, not nil: nil would mean "not resolved yet" and
|
||||||
|
-- would re-attempt the failing call on every frame of a 60Hz launcher.
|
||||||
|
T.eq(importer.invertShader, false, "the invert shader resolves to unavailable")
|
||||||
|
T.eq(importer.shineShader, false, "the shine shader resolves to unavailable")
|
||||||
|
T.eq(importer.bgMesh, false, "the background fan resolves to unavailable")
|
||||||
|
|
||||||
|
-- The console profile: no pointer to poll (there is no love.mouse to poll it
|
||||||
|
-- with), and the ROM scan is the only way in, so it must not be gated behind
|
||||||
|
-- the Android-only picker flags.
|
||||||
|
T.eq(importer.console, "Switch", "the console is identified by love._console")
|
||||||
|
T.eq(importer.android, false, "and is not misfiled as Android, which would "
|
||||||
|
.. "reach for SAF pickers that do not exist here")
|
||||||
|
-- Hovering is the observable half of "there is no pointer here": the draw above
|
||||||
|
-- set it from the same derivation the cursor and drag paths read.
|
||||||
|
T.eq(importer._hoverEnabled, false, "a console hovers nothing, having no "
|
||||||
|
.. "pointer to hover with")
|
||||||
|
|
||||||
|
-- choose() must not open a dialog that cannot exist: with no ROM on the card it
|
||||||
|
-- reports where to put one instead of calling love.system.pickFile (absent).
|
||||||
|
local okChoose, chooseErr = pcall(function() importer:choose("red") end)
|
||||||
|
T.eq(okChoose, true, "choosing a ROM on a console does not reach for a picker: "
|
||||||
|
.. tostring(chooseErr))
|
||||||
|
T.eq(type(importer.notice), "table", "it leaves a notice saying where to copy "
|
||||||
|
.. "the cart")
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- 3DS
|
||||||
|
-- The 3DS is the one console that has to drop the scanline overlay: the tile is
|
||||||
|
-- 1x3 and that GPU cannot "repeat"-wrap a non-power-of-two texture, so the
|
||||||
|
-- single draw call the effect exists for is the call it cannot make.
|
||||||
|
local Console = require("src.core.Console")
|
||||||
|
love._console = "3DS"
|
||||||
|
T.eq(Console.is3DS(), true, "love._console identifies the 3DS")
|
||||||
|
T.eq(Console.isConsole(), true, "and it counts as a console")
|
||||||
|
|
||||||
|
local threeDS = RomImporter.new(function() end, { launcher = true })
|
||||||
|
local ok3, err3 = pcall(function() threeDS:draw() end)
|
||||||
|
T.eq(ok3, true, "the launcher draws on a 3DS: " .. tostring(err3))
|
||||||
|
T.eq(threeDS.scanlineImage, nil, "the 3DS builds no scanline tile")
|
||||||
|
T.eq(threeDS.scanlineQuad, nil, "and no quad to draw it with")
|
||||||
|
|
||||||
|
-- The Switch keeps it: this is a 3DS-specific compromise, not a console-wide
|
||||||
|
-- one, which is the distinction love._console exists to make.
|
||||||
|
love._console = "Switch"
|
||||||
|
local switch = RomImporter.new(function() end, { launcher = true })
|
||||||
|
switch:draw()
|
||||||
|
T.eq(switch.scanlineImage ~= nil, true, "the Switch still gets scanlines")
|
||||||
|
|
||||||
|
love._console = nil
|
||||||
|
|
||||||
|
T.finish("launcher console degrade")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- PaletteFX with no shader compiler behind it.
|
||||||
|
--
|
||||||
|
-- PaletteFX.shader() / keyedShader() already resolve to nil where newShader
|
||||||
|
-- fails, and most callers test for that and skip the colorized path entirely.
|
||||||
|
-- sendColors is the exception: BattleState:drawZonePass sets the shader once
|
||||||
|
-- and then calls sendColors per zone from inside its loop, so a nil arriving
|
||||||
|
-- there used to become nil:send() in the middle of a battle draw.
|
||||||
|
--
|
||||||
|
-- This is not hypothetical on the LOVE Potion consoles: that port compiles no
|
||||||
|
-- shaders at all, so shader() is nil on every frame of every battle.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
|
||||||
|
-- The stub compiles no shaders, which is the platform being modelled.
|
||||||
|
T.eq(PaletteFX.shader(), nil, "no shader resolves where none can be compiled")
|
||||||
|
T.eq(PaletteFX.keyedShader(), nil, "and neither does the keyed variant")
|
||||||
|
|
||||||
|
local COLORS = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 },
|
||||||
|
{ 0, 0, 0 } }
|
||||||
|
|
||||||
|
local ok, err = pcall(PaletteFX.sendColors, nil, COLORS)
|
||||||
|
T.eq(ok, true, "sending colors to a nil shader is a no-op, not a crash: "
|
||||||
|
.. tostring(err))
|
||||||
|
|
||||||
|
-- The guard must not swallow the real path: a shader that IS present still
|
||||||
|
-- receives all four uniforms, or every colorized platform silently goes gray.
|
||||||
|
local sent = {}
|
||||||
|
local fake = { send = function(_, name, value) sent[name] = value end }
|
||||||
|
PaletteFX.sendColors(fake, COLORS)
|
||||||
|
T.eq(sent.c0 ~= nil and sent.c1 ~= nil and sent.c2 ~= nil and sent.c3 ~= nil,
|
||||||
|
true, "a real shader still gets all four shade uniforms")
|
||||||
|
T.eq(sent.c3[1], 0, "and they carry the mapped colors, normalized to 0..1")
|
||||||
|
|
||||||
|
T.finish("palettefx no shader")
|
||||||
Executable
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the Nintendo homebrew menu icons for the LÖVE Potion builds.
|
||||||
|
|
||||||
|
Each console wants a different size AND a different container, and the sizes
|
||||||
|
are not advisory: the homebrew menu reads a fixed-size record out of the
|
||||||
|
metadata section, so an icon of the wrong dimensions is a corrupt entry rather
|
||||||
|
than a scaled one. From lovebrew/bundler-api (crates/asset/src/icon.rs), which
|
||||||
|
is what the bundler service applies server-side:
|
||||||
|
|
||||||
|
ctr (3DS) 48x48 PNG
|
||||||
|
hac (Switch) 256x256 JPEG
|
||||||
|
cafe (Wii U) 128x128 PNG
|
||||||
|
|
||||||
|
The service resizes with `thumbnail`, which preserves aspect ratio. That is a
|
||||||
|
trap for a non-square source: a 822x241 logo thumbnailed into a 48x48 box comes
|
||||||
|
back 48x14, and that is what gets embedded. So this script always emits an
|
||||||
|
exactly square icon, letterboxed rather than stretched, and the service's
|
||||||
|
resize then becomes a no-op on an already-correct file.
|
||||||
|
|
||||||
|
JPEG cannot carry alpha, so the Switch icon is flattened onto a background
|
||||||
|
first; left to Pillow, an RGBA->JPEG save either raises or produces black
|
||||||
|
fringing wherever the source was transparent.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 tools/make_console_icons.py [--source PATH] [--out DIR]
|
||||||
|
[--background '#RRGGBB']
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Pillow is required: python3 -m pip install Pillow")
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# The square cover art, not logo.png: the logo is 822x241, so every console
|
||||||
|
# icon cut from it would be a thin band floating in a mostly empty square.
|
||||||
|
DEFAULT_SOURCE = ROOT / "assets" / "logo" / "gen1recomp_cover.png"
|
||||||
|
|
||||||
|
# (filename, pixel size, Pillow format). Names are referenced by the
|
||||||
|
# [metadata].icons table that scripts/build.sh writes into lovebrew.toml.
|
||||||
|
TARGETS = (
|
||||||
|
("icon-ctr.png", 48, "PNG"),
|
||||||
|
("icon-hac.jpg", 256, "JPEG"),
|
||||||
|
("icon-cafe.png", 128, "PNG"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def square(image, background):
|
||||||
|
"""Center `image` on a square canvas without distorting it.
|
||||||
|
|
||||||
|
Returns the image unchanged when it is already square, so a square source
|
||||||
|
(the cover art) never takes a needless resample.
|
||||||
|
"""
|
||||||
|
w, h = image.size
|
||||||
|
if w == h:
|
||||||
|
return image
|
||||||
|
side = max(w, h)
|
||||||
|
canvas = Image.new("RGBA", (side, side), background + (0,))
|
||||||
|
canvas.paste(image, ((side - w) // 2, (side - h) // 2))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def render(source, out_dir, background):
|
||||||
|
src = Image.open(source)
|
||||||
|
# Normalize up front: palette and grayscale sources both reach the resize
|
||||||
|
# with a usable alpha channel this way, and RGBA is what square() pastes.
|
||||||
|
src = src.convert("RGBA")
|
||||||
|
src = square(src, background)
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
written = []
|
||||||
|
for name, size, fmt in TARGETS:
|
||||||
|
# LANCZOS: these are large downscales (1024 -> 48 is 21x), where a
|
||||||
|
# cheaper filter aliases the sprite work into noise.
|
||||||
|
icon = src.resize((size, size), Image.LANCZOS)
|
||||||
|
if fmt == "JPEG":
|
||||||
|
# Flatten: JPEG has no alpha, and compositing explicitly is what
|
||||||
|
# keeps transparent edges from turning into black fringes.
|
||||||
|
flat = Image.new("RGB", icon.size, background)
|
||||||
|
flat.paste(icon, mask=icon.split()[3])
|
||||||
|
icon = flat
|
||||||
|
path = out_dir / name
|
||||||
|
icon.save(path, fmt, **({"quality": 95} if fmt == "JPEG" else {}))
|
||||||
|
written.append((path, size, fmt))
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def parse_color(text):
|
||||||
|
text = text.lstrip("#")
|
||||||
|
if len(text) != 6:
|
||||||
|
raise argparse.ArgumentTypeError("expected #RRGGBB")
|
||||||
|
return tuple(int(text[i:i + 2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--source", type=Path, default=DEFAULT_SOURCE,
|
||||||
|
help="source image (default: assets/logo/gen1recomp_cover.png)")
|
||||||
|
ap.add_argument("--out", type=Path, default=ROOT / "dist" / "console" / "icons",
|
||||||
|
help="output directory")
|
||||||
|
ap.add_argument("--background", type=parse_color, default="#000000",
|
||||||
|
help="fill behind transparency, for JPEG and letterboxing")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.source.is_file():
|
||||||
|
sys.exit(f"source image not found: {args.source}")
|
||||||
|
|
||||||
|
background = args.background
|
||||||
|
if isinstance(background, str):
|
||||||
|
background = parse_color(background)
|
||||||
|
|
||||||
|
for path, size, fmt in render(args.source, args.out, background):
|
||||||
|
print(f"{path.relative_to(ROOT) if ROOT in path.parents else path}"
|
||||||
|
f" {size}x{size} {fmt}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user