diff --git a/README.md b/README.md index e008dca1..9f2e1b6f 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ supported out of the box. | Key | What it does | | --------- | ---------------------------------------------------- | | `-` / `=` | Zoom out / in (overworld; also mouse wheel) | +| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) | | `2` | Cycle COLORS | | `3` | Cycle TILT (free-roam overworld) | | `4` | Cycle ZOOM through every level (free-roam overworld) | @@ -121,8 +122,8 @@ supported out of the box. | `F10` | Open / close the mod manager | -COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu -and persist in `options.lua`. +COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the +Options menu and persist in `options.lua`. ### Low-end devices diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index fe2602ff..afc9fc4c 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -325,9 +325,14 @@ return { table.insert(rows, { "jump_if_false", base + 6 }) table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) + -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then + -- walk out past the player. The fanfare was dropped here (#683) -- the + -- parcel scene above already plays Music_MeetRival on both arrival and + -- departure (lines 144-146), and this exit should match (#596). + table.insert(rows, { "stop_music" }) + table.insert(rows, { "play_music", "Music_MeetRival" }) table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) - -- restore the lab theme once he's walked out, same as the Yellow port table.insert(rows, { "play_music", "Music_OaksLab" }) ow.runner:run(rows, { npc = rival }) return true diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index e3a663a1..8f4ac856 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -287,10 +287,12 @@ return { table.insert(rows, { "label", "lost_lab" }) table.insert(rows, { "set_field", "rivalStarter", 3 }) table.insert(rows, { "label", "exit" }) - -- OaksLabRivalStartsExitScript: parting shot, walk out past the - -- player, restore the lab theme + -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then + -- walk out past the player (#683). table.insert(rows, { "wait", 20 }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) + table.insert(rows, { "stop_music" }) + table.insert(rows, { "play_music", "Music_MeetRival" }) table.insert(rows, { "move_npc_to", RIVAL, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "play_music", "Music_OaksLab" }) diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua index 788e32e0..4d0206cf 100644 --- a/data/scripts/seafoam.lua +++ b/data/scripts/seafoam.lua @@ -56,6 +56,7 @@ for mapId, holes in pairs(HOLE_FALLS) do M[mapId].onStep = function(game, ow, x, y) for _, h in ipairs(holes) do if x == h[1] and y == h[2] then + require("src.core.Sound").play(game.data, "Faint_Fall") ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) return true end diff --git a/data/scripts/story.lua b/data/scripts/story.lua index c7c58186..53be89c5 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -942,6 +942,7 @@ M.VICTORY_ROAD_3F = { -- fall is onStep, not a collision block. onStep = function(game, ow, x, y) if x == 23 and y == 15 then + require("src.core.Sound").play(game.data, "Faint_Fall") ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing) return true end diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua index d847c09b..e28c1230 100644 --- a/data/scripts/story6.lua +++ b/data/scripts/story6.lua @@ -118,6 +118,7 @@ local MANSION_HOLES = { M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y) for _, h in ipairs(MANSION_HOLES) do if x == h[1] and y == h[2] then + require("src.core.Sound").play(game.data, "Faint_Fall") ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) return true end diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index 74a62000..f97e69eb 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -1,13 +1,13 @@ { "name": "gen1recomp App Repo", "identifier": "com.theboisclub.gen1recomp.repo", - "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png", + "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png", "apps": [ { "name": "gen1recomp", "bundleIdentifier": "com.theboisclub.gen1recomp", "developerName": "bryanthaboi", - "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png", + "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png", "localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke", "tintColor": "3b5ca8", "category": "games", diff --git a/src/core/FaithfulRes.lua b/src/core/FaithfulRes.lua index c7fc4946..e1535c77 100644 --- a/src/core/FaithfulRes.lua +++ b/src/core/FaithfulRes.lua @@ -8,8 +8,21 @@ -- letterbox entirely, so the surface is the Game Boy screen and nothing else. -- -- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu --- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs --- that lack love.window. +-- and on boot via Game:applyOptions. No-ops in headless stubs that lack +-- love.window. +-- +-- MOBILE takes the other route to the same place. There is no window to +-- resize -- the window IS the screen, and it rotates -- so the lock caps the +-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N +-- physical pixels per GB pixel and centres it, and the rest of the display +-- stays black. Same promise as the desktop lock (a GB pixel is exactly N +-- screen pixels, no more) reached by moving the picture rather than the +-- window. This used to return false on the first line, so the row sat in +-- OPTIONS on Android and iOS doing nothing at all. +-- +-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs +-- every frame off the live drawable size, so portrait and landscape both get +-- the same locked scale with the bars falling wherever the screen is longer. local FaithfulRes = {} @@ -17,6 +30,10 @@ FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144 FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 } FaithfulRes.DEFAULT = 0 +-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF. +-- Renderer:fitScale reads it through FaithfulRes.scaleCap. +FaithfulRes.mobileScale = 0 + -- conf.lua's floor for the resizable desktop window, restored when the lock -- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum -- as well as set the size or LOVE clamps the window back up. @@ -25,21 +42,52 @@ FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360 -- whether this module currently owns the window size FaithfulRes.locked = false +-- The highest level this display can actually show. +-- +-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the +-- feature shipped with. On mobile there is no window to size, so a fixed +-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the +-- levels the panel could really use are not on the list at all. Derive it +-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top +-- of the ladder is the biggest exact-pixel picture it can draw. +-- +-- OFF (0) is untouched by any of this and keeps doing exactly what it always +-- did: the renderer fits and letterboxes as usual. +function FaithfulRes.maxLevel() + -- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea -- + -- there it names a window size you can see. On a phone the same number + -- means a different fraction of every device, and every level below the top + -- is just a smaller picture for no reason. ON means one thing instead: + -- lock the viewport to the Game Boy's 10:9 and size it to this screen. + if FaithfulRes.isMobile() then return 1 end + return 4 +end + +-- the selectable ladder for this display: OFF, then 1X..maxLevel +function FaithfulRes.levels() + local out = { 0 } + for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end + return out +end + function FaithfulRes.normalize(v) v = math.floor(tonumber(v) or FaithfulRes.DEFAULT) if v < 0 then return 0 end - if v > 4 then return 4 end + local max = FaithfulRes.maxLevel() + if v > max then return max end return v end function FaithfulRes.label(v) v = FaithfulRes.normalize(v) if v == 0 then return "OFF" end + -- mobile has one ON: the level is chosen from the display, not the player + if FaithfulRes.isMobile() then return "ON" end return tostring(v) .. "X" end function FaithfulRes.cycle(v, dir) - local levels = FaithfulRes.LEVELS + local levels = FaithfulRes.levels() local cur = 1 for i, level in ipairs(levels) do if level == FaithfulRes.normalize(v) then cur = i break end @@ -48,6 +96,12 @@ function FaithfulRes.cycle(v, dir) end function FaithfulRes.isMobile() + -- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the + -- scale lock can be seen and driven without a device. The window is still + -- resizable, which is the point -- drag it to a phone aspect, rotate it by + -- dragging the other way, and the lock has to hold through both. Only this + -- module reads isMobile, so the override cannot leak into anything else. + if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end if not love or not love.system or not love.system.getOS then return false end local osName = love.system.getOS() return osName == "Android" or osName == "iOS" @@ -86,13 +140,50 @@ end -- Push the lock into the live window. Returns true when the window is -- locked afterwards. +-- The largest WHOLE multiple of the Game Boy screen this display can hold. +-- Integer, never fractional: a GB pixel has to be the same number of screen +-- pixels in both axes or it is not pixel perfect, it is resampled. +-- +-- The leftover is black bars, and on a tall phone there is a lot of it +-- vertically -- that is simply what a 10:9 screen looks like on a 9:20 +-- display, and it is what an emulator shows too. +function FaithfulRes.deviceScale() + local g = love and love.graphics + if not (g and g.getPixelDimensions) then return 1 end + local pw, ph = g.getPixelDimensions() + if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end + return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH, + ph / FaithfulRes.HEIGHT))) +end + +-- The scale the renderer must lock to, or nil for "fit the window as usual". +-- Only ever set on mobile: on desktop the window itself is the lock, so +-- fitScale already lands on N and this would be a second, redundant one. +-- +-- Always the device maximum. Anything less is a smaller picture for no gain, +-- which is how the first cut ended up showing a postage stamp on a 1080p +-- phone. +function FaithfulRes.scaleCap() + if not FaithfulRes.locked then return nil end + if not FaithfulRes.isMobile() then return nil end + return FaithfulRes.deviceScale() +end + function FaithfulRes.apply(v) - if FaithfulRes.isMobile() then return false end + v = FaithfulRes.normalize(v) + -- Mobile: lock the render scale instead of the window. The scale itself + -- comes from the display (deviceScale), not from v -- v only says whether + -- the lock is on. Nothing to restore on release: the renderer simply goes + -- back to filling the display. + if FaithfulRes.isMobile() then + FaithfulRes.locked = v > 0 + FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0 + return FaithfulRes.locked + end if not love or not love.window or not love.window.setMode or not love.window.getMode then return false end - v = FaithfulRes.normalize(v) local curW, curH, flags = love.window.getMode() flags = flags or {} diff --git a/src/core/Game.lua b/src/core/Game.lua index dcc87fba..d89e5dd4 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -509,6 +509,24 @@ function Game:wheelmoved(_, dy) end end +function Game:_cycleSpeed(dir) + if not (self.save and self.save.options) then return end + local busy + local ow = self.overworld + if ow then + local top = self.stack:top() + busy = ow.transitioning + or (top == ow and ( + (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) + or (ow.scriptMoves and #ow.scriptMoves > 0) + or ow.engaging or ow.emote)) + end + if busy then return end + local GameSpeed = require("src.core.GameSpeed") + self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir) + self:writeOptions() +end + function Game:keypressed(key) if self.stack and self.stack:top() and self.stack:top().onKeyPressed then self.stack:top():onKeyPressed(key) @@ -546,6 +564,11 @@ function Game:keypressed(key) elseif key == "=" then self:zoomStep(1) return + elseif key == "1" then + -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected); + -- R2/L2 on gamepad do the same (see gamepadpressed) + self:_cycleSpeed(1) + return elseif key == "2" then -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so @@ -626,6 +649,15 @@ function Game:gamepadpressed(joystick, button) -- a controller is being used: the touch overlay steps aside until the -- next screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() + -- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster, + -- L2/leftshoulder = slower; same as keyboard hotkey 1) + if button == "rightshoulder" then + self:_cycleSpeed(1) + return + elseif button == "leftshoulder" then + self:_cycleSpeed(-1) + return + end -- BindingsMenu's pad capture rides the same top-state routing as keys local top = self.stack and self.stack:top() if top and top.onGamepadPressed then diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 85b81b3c..0a5ddde3 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -18,7 +18,7 @@ local GameSpeed = {} -- attempt is long enough that the iteration loop, not the engine, is the -- bottleneck. Vsync caps how much a real frame can do, so past 10X the -- multiplier is increasingly a ceiling rather than a rate. -GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 } +GameSpeed.LEVELS = { 1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200 } GameSpeed.DEFAULT = 1 function GameSpeed.levelLabel(v) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index d60e16a3..ad1e2165 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -324,6 +324,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) require("src.core.Sound").play(data, "Heal_HP") -- a revive takes the same .healHP -> .doneHealing route, animating up -- from the fainted mon's 0 HP (#252) + -- re-add to participants so the revived mon gets its share of exp + -- at battle end (onFaint clears the flag; revive must restore it) + if battle and battle.participants then + battle.participants[target] = true + end return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }, { healedFrom = 0 } end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 735a43aa..064b86fe 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -559,16 +559,26 @@ local TILESET_GROUP_EXCEPTIONS = { } -- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top --- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the --- tabletop and the checkerboard floor -- both raw tile $37 -- can take --- different palettes; the vanilla-derived blockset shares the one tile --- id, so the RED++ atlas path re-creates the duplicate: the alias slot --- is baked as a copy of `tile` in `group`'s colors, and the listed --- 0-based block cells draw the alias instead of the shared tile. --- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84). +-- at a duplicate tile ($5a, BROWN) so the tabletop and the checkerboard +-- floor -- both raw tile $37 -- can take different palettes; the +-- vanilla-derived blockset shares the one tile id, so the RED++ atlas +-- path re-creates the duplicate: the alias slot is baked as a copy of +-- `tile` in `group`'s colors, and the listed 0-based block cells draw +-- the alias instead of the shared tile. +-- +-- Three LOBBY blocks share tile $37 on their flat surfaces: +-- block 29: 2x2 table top at cells 5/6/9/10 +-- block 45: 2-tile strip at cells 13/14 +-- block 49: 2-tile strip at cells 1/2 +-- CELADON_DINER uses all three (#84, #85, #86); CELADON_MART_ROOF +-- uses only block 29 (#52/#53). local LOBBY_TABLE_TOP_ALIAS = { { block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true }, tile = 0x37, alias = 0x5a, group = 5 }, + { block = 45, cells = { [13] = true, [14] = true }, + tile = 0x37, alias = 0x5a, group = 5 }, + { block = 49, cells = { [1] = true, [2] = true }, + tile = 0x37, alias = 0x5a, group = 5 }, } PaletteFX.TILE_ALIASES = { CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS, diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 6eb57edf..28618cb2 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +-- leaf module (no renderer dependency), so requiring it here cannot cycle +local FaithfulRes = require("src.core.FaithfulRes") local Renderer = {} @@ -124,7 +126,16 @@ end function Renderer:fitScale() local _, _, pw, ph = displayMetrics() local w, h = self:uiSize() - return math.max(1, math.floor(math.min(pw / w, ph / h))) + local s = math.max(1, math.floor(math.min(pw / w, ph / h))) + -- FAITHFUL RATIO on mobile locks the scale here rather than by resizing the + -- window, which a phone does not have (see src/core/FaithfulRes.lua). The + -- cap is the largest WHOLE multiple the display holds, so the picture is as + -- big as exact pixels allow and the remainder is bars. Computed per frame + -- off the live drawable size, so a rotate re-derives it with nothing to + -- re-apply. + local cap = FaithfulRes.scaleCap() + if cap and cap < s then s = cap end + return s end -- Integer framebuffer pixels per GB pixel for the UI pass. @@ -224,6 +235,21 @@ end -- tilt is inactive). function Renderer:worldViewSize() local _, _, pw, ph = displayMetrics() + -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the + -- WHOLE display, so letterbox voids become more map instead of black bars. + -- That is why the lock appeared to do nothing in the overworld: it shrank + -- the UI blit while the map kept filling the screen -- and showed MORE of + -- the map, because a smaller scale fits more world pixels in. + -- + -- Size the view against the LOCKED VIEWPORT rather than the display. A + -- desktop lock gets this for free by making the window exactly 160N x 144N; + -- this is the same sum with the viewport standing in for the window, so + -- both platforms show the same map area at the same zoom. + local cap = FaithfulRes.scaleCap() + if cap then + local uiw, uih = self:uiSize() + pw, ph = uiw * cap, uih * cap + end local sp = Zoom.scale(self:fitScale()) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp) -- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral), diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 5d066ce6..0cdc7f36 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -354,7 +354,7 @@ local function buildRows(game) -- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE -- because it overrides it: holding an exact size means dropping -- fullscreen. - { id = "faithfulRes", label = Strings("FAITHFUL RES"), + { id = "faithfulRes", label = Strings("FAITHFUL RATIO"), value = function(g) return FaithfulRes.label(g.save.options.faithfulRes) end, diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index c7e7e361..6ff9f8c8 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -203,7 +203,8 @@ function SummaryMenu:draw() local mdef = data.moves[mv.id] Font.draw(mdef.name, 16, y) Font.draw(Strings("PP"), 88, y + 8) - Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8) + local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5) + Font.draw(("%2d/%2d"):format(mv.pp, maxPP), 112, y + 8) else Font.draw("-", 16, y) Font.draw("--", 112, y + 8) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e075605a..a6bf7d50 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2580,8 +2580,13 @@ function OverworldState:openPC(onDone) -- (engine/menus/pokemon_pc.asm gates on EVENT_MET_BILL; we reach that -- when Bill hands over the SS Ticket) local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET + -- keepOpen so B in the sub-PC returns here instead of exiting the + -- PC session (#695); the sub-PC screens (BoxMenu, PlayerPC) already + -- use keepOpen for their own rows, matching the original ROM's flow + -- where the main menu stays underneath. table.insert(items, { label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"), + keepOpen = true, onSelect = function() require("src.core.Sound").play(Game.data, "Enter_PC") Screens.push(Game, "BoxMenu") @@ -2592,6 +2597,7 @@ function OverworldState:openPC(onDone) -- the player's item storage is always available table.insert(items, { label = (Game.save.player.name or "RED") .. "'s PC", + keepOpen = true, onSelect = function() Screens.push(Game, "PlayerPC") done() @@ -2602,6 +2608,7 @@ function OverworldState:openPC(onDone) if flags.EVENT_GOT_POKEDEX then table.insert(items, { label = Strings("PROF.OAK's PC"), + keepOpen = true, onSelect = function() self:openOaksPC(done) end, @@ -3774,7 +3781,9 @@ function OverworldState:takeWarp(warpDef) self:startWarpTo(destMap, x, y, facing) return elseif pad == "hole" then - -- falling through a hole: no door SFX, no walk-out step + -- falling through a hole: Faint_Fall plays while the player drops, + -- matching the boulder-hole Faint_Thud at line 3618 (#694) + require("src.core.Sound").play(Game.data, "Faint_Fall") self:startWarpTo(destMap, x, y, facing) return end diff --git a/tests/engine/faithful_res.lua b/tests/engine/faithful_res.lua index d98b1e26..0f17b044 100644 --- a/tests/engine/faithful_res.lua +++ b/tests/engine/faithful_res.lua @@ -1,4 +1,4 @@ --- FAITHFUL RES: lock the window to an exact 160x144 multiple so the surface +-- FAITHFUL RATIO: lock the window to an exact 160x144 multiple so the surface -- is the Game Boy screen with no letterbox at all. -- -- The interesting parts are the two things a naive setMode gets wrong: the @@ -121,11 +121,22 @@ T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes") T.eq(FaithfulRes.locked, false, "and the module no longer claims the window") --- mobile has no resizable window to lock +-- Mobile has no resizable window to lock, so it locks the RENDER scale +-- instead and never calls setMode. It used to report unlocked and do +-- nothing at all, which is why the OPTIONS row was inert on Android and iOS; +-- tests/engine/faithful_res_mobile.lua covers the scale side. calls = stubWindow(1) love.system = { getOS = function() return "Android" end } -T.eq(FaithfulRes.apply(4), false, "mobile reports unlocked") -T.eq(#calls, 0, "and never touches the window") +T.eq(FaithfulRes.apply(4), true, "mobile locks, by capping the render scale") +-- the level asked for is irrelevant on mobile: ON is ON, and the scale is +-- read off the display so the picture is as big as exact pixels allow +T.eq(FaithfulRes.scaleCap(), FaithfulRes.deviceScale(), + "and the scale comes from the display, not from the level") +T.eq(#calls, 0, "still without ever touching the window") +T.eq(FaithfulRes.apply(0), false, "OFF releases it") +T.eq(FaithfulRes.scaleCap(), nil, "and the cap goes away with it") +T.eq(#calls, 0, "the window is left alone either way") +FaithfulRes.mobileScale = 0 love.window, love.system = savedWindow, savedSystem if love.graphics then diff --git a/tests/engine/faithful_res_mobile.lua b/tests/engine/faithful_res_mobile.lua new file mode 100644 index 00000000..9457e5a5 --- /dev/null +++ b/tests/engine/faithful_res_mobile.lua @@ -0,0 +1,134 @@ +-- FAITHFUL RATIO on Android / iOS. +-- +-- On mobile the setting is ON or OFF, and ON means one thing: lock the +-- viewport to the Game Boy's 10:9 at the largest WHOLE multiple this screen +-- can hold, centred, black around it -- the way an emulator opens a Game Boy +-- game on a phone. +-- +-- Three things had to be true and none of them were: +-- +-- * it had to apply at all. FaithfulRes.apply returned false on its first +-- line for mobile, so the OPTIONS row did nothing on Android and iOS. +-- A phone has no window to resize, so the lock caps the RENDER scale. +-- +-- * it had to be sized for the device. The first cut kept the desktop's +-- absolute 1X-4X ladder, which names a window size you can see on a +-- desktop and means a different fraction of every phone: 4X was a quarter +-- of a 1080p display and 5X/6X were not on the list at all. The scale is +-- read off the display now, not chosen by the player. +-- +-- * it had to work in the OVERWORLD. The world pass deliberately expands +-- to cover the whole display so letterbox voids become more map. So the +-- lock shrank the UI blit while the map kept filling the screen -- and +-- showed MORE map, since a smaller scale fits more world pixels in. +-- +-- Pixel perfect throughout: whole multiples only. The leftover is bars, and +-- on a 9:20 phone there is a lot of it vertically. That is what a 10:9 +-- screen looks like on a tall display; stretching to reach the edges would +-- resample every pixel, which is the one thing this setting exists to refuse. +-- luajit tests/engine/faithful_res_mobile.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local FaithfulRes = require("src.core.FaithfulRes") +local Renderer = require("src.render.Renderer") +local Zoom = require("src.render.Zoom") + +local g = love.graphics +local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions +local realOS = love.system and love.system.getOS +local savedOffset = Zoom.offset +love.system = love.system or {} + +local function pose(w, h, osName) + love.system.getOS = function() return osName or "Android" end + g.getDimensions = function() return w, h end + g.getPixelDimensions = function() return w, h end +end + +Renderer.uiWidth, Renderer.uiHeight = Renderer.WIDTH, Renderer.HEIGHT +Zoom.offset = 0 + +-- ------------------------------------------------------- it applies at all + +pose(1080, 2400) -- a Pixel 7, portrait +T.eq(FaithfulRes.isMobile(), true, "the fixture reads as a phone") +T.eq(FaithfulRes.apply(1), true, "FAITHFUL RATIO applies on mobile at all now") +T.eq(FaithfulRes.locked, true, "and reports itself locked") + +-- ------------------------------------------------- one ON, sized by the device + +T.eq(FaithfulRes.maxLevel(), 1, "mobile offers ON, not a ladder of multiples") +T.eq(#FaithfulRes.levels(), 2, "so the row is exactly OFF and ON") +T.eq(FaithfulRes.label(1), "ON", "and ON is spelled ON, not 1X") +T.eq(FaithfulRes.label(0), "OFF", "with OFF unchanged") + +-- 1080/160 = 6.75 and 2400/144 = 16.6, so the largest WHOLE multiple is 6 +T.eq(FaithfulRes.deviceScale(), 6, "the scale comes off the display: 6x here") +T.eq(FaithfulRes.scaleCap(), 6, "and that is what the renderer is told to lock") +T.eq(Renderer:fitScale(), 6, "so a GB pixel is exactly 6 screen pixels") + +-- the player never picks a smaller one, which is how the first cut managed to +-- draw a postage stamp on a 1080p phone +T.eq(FaithfulRes.normalize(3), 1, "any ON value is just ON") +FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 6, "and always lands on the device maximum") + +-- --------------------------------------------------------- the overworld + +FaithfulRes.apply(0) +local unlockedW = Renderer:worldViewSize() +T.check(unlockedW > 160, "unlocked, the overworld view covers the whole display") + +FaithfulRes.apply(1) +local lockedW, lockedH = Renderer:worldViewSize() +T.eq(lockedW, 160, "locked, the overworld shows exactly a GB screen wide") +T.eq(lockedH, 144, "and exactly a GB screen tall") +T.check(lockedW < unlockedW, + "so the lock SHRINKS the map area instead of growing it") + +-- ------------------------------------------------------ pixel perfect + +-- 6 x 160 = 960 of 1080 wide. Reaching the edges would need 6.75, which +-- resamples every pixel; the bars are the honest answer. +local cap = FaithfulRes.scaleCap() +T.eq(cap, math.floor(cap), "the locked scale is a whole number, never fractional") +T.eq(160 * cap, 960, "which puts the GB screen at 960 of 1080 pixels wide") + +-- ------------------------------------------------------- rotation is free + +-- fitScale reads the live drawable size every frame, so a rotate re-derives +-- the scale with nothing to re-apply +pose(1080, 2400); FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 6, "portrait locks at 6x") +pose(2400, 1080); FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 7, "landscape re-derives to 7x (1080/144), still whole") +T.eq(Renderer:worldViewSize(), 160, "and the overworld stays locked through it") + +-- ---------------------------------------------------------- OFF is OFF + +-- Nothing about OFF changed: it is the behaviour the game already had. +pose(1080, 2400) +FaithfulRes.apply(0) +T.eq(FaithfulRes.locked, false, "OFF releases the lock") +T.eq(FaithfulRes.scaleCap(), nil, "with no cap on the renderer") +T.eq(Renderer:fitScale(), 6, "the UI fits exactly as it did before") +T.check(Renderer:worldViewSize() > 160, "and the overworld fills the screen again") + +-- ------------------------------------------------- desktop is untouched + +pose(1280, 800, "Windows") +T.eq(FaithfulRes.isMobile(), false, "the fixture reads as desktop") +T.eq(FaithfulRes.maxLevel(), 4, "desktop keeps its 1X-4X ladder") +T.eq(FaithfulRes.label(2), "2X", "and its labels") +T.eq(FaithfulRes.scaleCap(), nil, "no cap: the window itself is the lock there") +T.eq(Renderer:fitScale(), 5, "so fitScale is untouched (800/144 = 5)") + +g.getDimensions, g.getPixelDimensions = realDims, realPixelDims +if realOS then love.system.getOS = realOS end +Zoom.offset = savedOffset +FaithfulRes.locked = false +FaithfulRes.mobileScale = 0 + +T.finish("faithful ratio mobile") diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 9e6c3017..8100a907 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -621,7 +621,9 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE, check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON, "RED++ passes zone colors through like GBC") -- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with --- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias +-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias. +-- Issue #689: blocks 45 and 49 also form tables with tile $37 on their +-- flat surfaces; CELADON_DINER uses all three. do local aliases = PaletteFX.TILE_ALIASES local roof = aliases and aliases.CELADON_MART_ROOF @@ -630,11 +632,20 @@ do "CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES") check(diner == roof, "diner reuses the same lobby table-top alias as the mart roof") + check(#diner == 3, "three LOBBY table blocks have the tile alias") local al = diner and diner[1] check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a and al.group == 5 and al.cells[5] and al.cells[6] and al.cells[9] and al.cells[10], "lobby table-top alias remaps block 29 cells 5/6/9/10") + al = diner and diner[2] + check(al and al.block == 45 and al.tile == 0x37 and al.alias == 0x5a + and al.group == 5 and al.cells[13] and al.cells[14], + "lobby table-top alias remaps block 45 cells 13/14") + al = diner and diner[3] + check(al and al.block == 49 and al.tile == 0x37 and al.alias == 0x5a + and al.group == 5 and al.cells[1] and al.cells[2], + "lobby table-top alias remaps block 49 cells 1/2") end -- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1 -- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red