Compare commits

..

6 Commits

Author SHA1 Message Date
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
Andrew Quenehen fccb122c59 Respect iOS/Android safe areas in launcher and touch chrome.
Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:53:26 -03:00
25 changed files with 350 additions and 132 deletions
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here | | # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---| |---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) | | 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) | | 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` | | 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` | | 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes ## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the - PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards 20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room. or blocks the deposit," this port spills into the next box with room.
+2 -1
View File
@@ -380,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there. clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that - **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips. with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object - **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid. toggles, with a real filter field and a two-column paged grid.
+8 -4
View File
@@ -476,10 +476,14 @@ function love.mousepressed(x, y, button, istouch)
-- meant two choose() calls and two stacked SAF picker activities: the -- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still -- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice" -- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch rather than on the OS keeps a real mouse -- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- (DeX, a Chromebook, a USB mouse) working, which an Android-wide return -- USB mouse) working, which an Android-wide return would have broken.
-- would have broken. --
if istouch then return end -- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
if istouch and love.system.getOS() == "Android" then return end
return Importer:mousepressed(x, y, button) return Importer:mousepressed(x, y, button)
end end
if editorMode and EditorApp.mousepressed then if editorMode and EditorApp.mousepressed then
+1 -1
View File
@@ -156,7 +156,7 @@ return function(mod)
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST" caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas()) mod.save:set("caught_areas", caughtAreas())
end end
Bag.add(self.game.save, ball, 1) Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!" self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!") or "You already have\nthis POKéMON family!")
return return
+5 -5
View File
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches. -- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" } local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry -- Vanilla defaults for rules exposed through the constants registry. A
-- deep-merges over these, so a value has to exist before a mod can patch -- value has to exist before a mod can patch it; each one matches the
-- it; each one is the number the engine hard-codes today, so seeding them -- engine's no-mod behavior, so seeding them changes nothing on a vanilla
-- changes nothing on a mod-free boot. -- boot.
local CONSTANT_DEFAULTS = { local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua) bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua) partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua) boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4, moveMax = 4,
+40
View File
@@ -0,0 +1,40 @@
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
-- the engine provides it; otherwise the full graphics window.
--
-- Desktop and headless stubs return the full window, so callers can always
-- layout against this rect without platform branches. Interactive chrome
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
function SafeArea.rect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
if type(x) ~= "number" or type(y) ~= "number"
or type(w) ~= "number" or type(h) ~= "number"
or w <= 0 or h <= 0 then
return 0, 0, ww, wh
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
y = math.max(0, math.min(y, wh))
w = math.max(1, math.min(w, ww - x))
h = math.max(1, math.min(h, wh - y))
return x, y, w, h
end
return SafeArea
+1 -1
View File
@@ -1058,7 +1058,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i) table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table" if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then or not Bag.add(save, entry.id, entry.count or 1, data) then
save.pcItems = save.pcItems or {} save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1) save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end end
+42 -26
View File
@@ -23,6 +23,7 @@
-- and a player rebind can never detach the overlay. -- and a player rebind can never detach the overlay.
local Input = require("src.core.Input") local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchControls = {} local TouchControls = {}
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
return out return out
end end
-- Pure default layout in LOVE units for a given window size. Shared by -- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- layout() and the editor's Reset path so defaults stay in one place. -- origin (ox, oy). Shared by layout() and the editor's Reset path so
function TouchControls.defaultLayout(ww, wh) -- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size.
function TouchControls.defaultLayout(ww, wh, ox, oy)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh) local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34) local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46 local abW = dpadW * 0.46
local ssW = dpadW * 0.30 local ssW = dpadW * 0.30
local margin = dpadW * 0.12 local margin = dpadW * 0.12
return { return {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW }, dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW }, a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW }, b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
} }
end end
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
self.enabled = cfg.enabled self.enabled = cfg.enabled
self.positions = cfg.positions self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then if not self.enabled then
self.controllerHidden = false self.controllerHidden = false
self:reset() self:reset()
@@ -185,30 +190,37 @@ function TouchControls:visible()
and not self.controllerHidden and not self.controllerHidden
end end
local function clampZone(zone, ww, wh) -- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5 local half = zone.w * 0.5
zone.cx = math.max(half, math.min(ww - half, zone.cx)) zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
zone.cy = math.max(half, math.min(wh - half, zone.cy)) zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end end
-- Layout in LOVE units (density-independent on mobile), recomputed when -- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). Default: d-pad bottom-left, -- the window or safe area changes (rotation, resize, notch insets).
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT -- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- flanking the bottom center. Custom positions (normalized 0..1) override -- diagonal), START/SELECT flanking the bottom center -- all inside the
-- centers while sizes stay derived from the short edge. -- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge.
function TouchControls:layout() function TouchControls:layout()
local ww, wh = love.graphics.getDimensions() local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end if self.layoutW == sw and self.layoutH == sh
self.layoutW, self.layoutH = ww, wh and self.layoutOx == ox and self.layoutOy == oy and self.L then
self.L = TouchControls.defaultLayout(ww, wh) return self.L
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then if self.positions then
for _, name in ipairs(CONTROLS) do for _, name in ipairs(CONTROLS) do
local p = self.positions[name] local p = self.positions[name]
local zone = self.L[name] local zone = self.L[name]
if p and zone then if p and zone then
zone.cx = p.x * ww zone.cx = ox + p.x * sw
zone.cy = p.y * wh zone.cy = oy + p.y * sh
clampZone(zone, ww, wh) clampZone(zone, ox, oy, ox + sw, oy + sh)
end end
end end
end end
@@ -222,21 +234,25 @@ function TouchControls:layout()
end end
-- Move one control to a screen-space point and persist its normalized -- Move one control to a screen-space point and persist its normalized
-- position. Used by the layout editor while dragging. -- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy) function TouchControls:setControlCenter(name, cx, cy)
local ww, wh = love.graphics.getDimensions() local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout() local L = self:layout()
local zone = L[name] local zone = L[name]
if not zone then return end if not zone then return end
zone.cx, zone.cy = cx, cy zone.cx, zone.cy = cx, cy
clampZone(zone, ww, wh) clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {} self.positions = self.positions or {}
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh } self.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end end
function TouchControls:clearPositions() function TouchControls:clearPositions()
self.positions = nil self.positions = nil
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end end
local function inCircle(zone, x, y, slop) local function inCircle(zone, x, y, slop)
+1 -1
View File
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end end
elseif game.data.items and game.data.items[id] then elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1 local n = tonumber(count) or 1
if require("src.inventory.Bag").add(save, id, n) then if require("src.inventory.Bag").add(save, id, n, game.data) then
self:print(("%s x%d added"):format(id, n)) self:print(("%s x%d added"):format(id, n))
else else
self:print("bag full") self:print("bag full")
+52 -49
View File
@@ -1,6 +1,7 @@
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 SafeArea = require("src.core.SafeArea")
local RomImporter = {} local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
@@ -1017,7 +1018,6 @@ function RomImporter:chooseMod()
else else
self.pickPending = true self.pickPending = true
self.pickTimer = 0 self.pickTimer = 0
self.pickElapsed = 0
end end
return return
end end
@@ -1082,7 +1082,6 @@ function RomImporter:chooseSaveImport(version)
else else
self.pickPending = true self.pickPending = true
self.pickTimer = 0 self.pickTimer = 0
self.pickElapsed = 0
end end
return return
end end
@@ -1121,7 +1120,6 @@ function RomImporter:exportSave(version)
if love.system.createFile and love.system.createFile(suggested) then if love.system.createFile and love.system.createFile(suggested) then
self.pickPending = true self.pickPending = true
self.pickTimer = 0 self.pickTimer = 0
self.pickElapsed = 0
self.saveNotice[version] = { ok = true, self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." } text = "Pick where to save " .. suggested .. "..." }
else else
@@ -1177,7 +1175,6 @@ function RomImporter:choose(version)
else else
self.pickPending = true self.pickPending = true
self.pickTimer = 0 self.pickTimer = 0
self.pickElapsed = 0
end end
return return
end end
@@ -1225,18 +1222,18 @@ end
-- for the next tap to find, which is what made users import twice and -- for the next tap to find, which is what made users import twice and
-- what made it look random: it depends on memory pressure (#553). -- what made it look random: it depends on memory pressure (#553).
-- --
-- Disarms after PICK_TIMEOUT so a cancelled picker (which delivers nothing, -- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
-- ever) does not leave this scanning the save directory for the whole session. -- cancelled picker would stop scanning, which was wrong on iOS: the picker there
local PICK_TIMEOUT = 120 -- is an in-process modal sheet, so update() keeps running while it is open and
-- the window burned down while the player was still browsing Files. The pick
-- then landed with nothing armed to consume it, and because every path here is
-- silent on success the import just did not happen, with no error shown. A
-- half-second directory listing on a menu screen is far cheaper than an import
-- that vanishes, so the poll stays armed until something is actually consumed.
function RomImporter:_pollPickedFiles(dt) function RomImporter:_pollPickedFiles(dt)
if not self.pickPending then return end if not self.pickPending then return end
if self.workState == "working" then return end if self.workState == "working" then return end
self.pickElapsed = (self.pickElapsed or 0) + dt
if self.pickElapsed > PICK_TIMEOUT then
self.pickPending, self.pickElapsed = nil, nil
return
end
self.pickTimer = (self.pickTimer or 0) + dt self.pickTimer = (self.pickTimer or 0) + dt
if self.pickTimer < 0.5 then return end if self.pickTimer < 0.5 then return end
self.pickTimer = 0 self.pickTimer = 0
@@ -1246,7 +1243,7 @@ function RomImporter:_pollPickedFiles(dt)
local pickError = love.filesystem.read("pick_error.txt") local pickError = love.filesystem.read("pick_error.txt")
if pickError then if pickError then
love.filesystem.remove("pick_error.txt") love.filesystem.remove("pick_error.txt")
self.pickPending, self.pickElapsed = nil, nil self.pickPending = nil
self.modNotice = { ok = false, text = pickError } self.modNotice = { ok = false, text = pickError }
self.notice = { version = self.chooseVersion or "red", self.notice = { version = self.chooseVersion or "red",
status = "File import failed:", detail = pickError } status = "File import failed:", detail = pickError }
@@ -1263,7 +1260,7 @@ function RomImporter:_pollPickedFiles(dt)
end end
end end
if found then if found then
self.pickPending, self.pickElapsed = nil, nil self.pickPending = nil
self:focus(true) self:focus(true)
end end
end end
@@ -1295,10 +1292,10 @@ local PAD_DPAD_SPEED = 420
function RomImporter:_activatePadCursor() function RomImporter:_activatePadCursor()
if self._padCursorActive then return end if self._padCursorActive then return end
local w, h = love.graphics.getDimensions() local ox, oy, w, h = SafeArea.rect()
if not self._padInited then if not self._padInited then
self._padCursor.x = w * 0.5 self._padCursor.x = ox + w * 0.5
self._padCursor.y = h * 0.45 self._padCursor.y = oy + h * 0.45
self._padInited = true self._padInited = true
end end
self._padCursorActive = true self._padCursorActive = true
@@ -1344,11 +1341,11 @@ function RomImporter:_updatePadCursor(dt)
if mag > 1 then dx, dy = dx / mag, dy / mag end if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD) local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED and PAD_SPEED or PAD_DPAD_SPEED
local w, h = love.graphics.getDimensions() local ox, oy, w, h = SafeArea.rect()
local nx = self._padCursor.x + dx * speed * dt local nx = self._padCursor.x + dx * speed * dt
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(0, math.min(w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(0, math.min(h, ny)) self._padCursor.y = math.max(oy, math.min(oy + h, ny))
end end
-- Right stick scrolls the active list (save slots or mods), or the whole page -- Right stick scrolls the active list (save slots or mods), or the whole page
@@ -1726,7 +1723,10 @@ function RomImporter:_resetFrameRects()
end end
function RomImporter:draw() function RomImporter:draw()
local width, height = love.graphics.getDimensions() -- Full window for immersive backdrop; safe rect for interactive chrome so
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, width, height = SafeArea.rect()
local s = clamp(height / 768, 0.7, 1.6) local s = clamp(height / 768, 0.7, 1.6)
local pulse = self.pulse local pulse = self.pulse
self._s = s self._s = s
@@ -1745,8 +1745,9 @@ function RomImporter:draw()
self._anyHover = false self._anyHover = false
self:_resetFrameRects() self:_resetFrameRects()
-- Fonts + size-dependent scenery, rebuilt only when the window size changes. -- Fonts + size-dependent scenery, rebuilt only when the window / safe
local fontKey = ("%dx%d"):format(width, height) -- area changes (rotation, resize, inset changes).
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
if self.fontKey ~= fontKey then if self.fontKey ~= fontKey then
self.fontKey = fontKey self.fontKey = fontKey
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
@@ -1770,10 +1771,10 @@ function RomImporter:draw()
-- Background: a radial gradient (bright navy at top-centre -> near black). -- Background: a radial gradient (bright navy at top-centre -> near black).
-- A triangle fan from the top-centre gives the radial falloff; the screen -- A triangle fan from the top-centre gives the radial falloff; the screen
-- is cleared to the outer colour first so the corners it does not reach -- is cleared to the outer colour first so the corners it does not reach
-- match seamlessly. -- match seamlessly. Sized to the full window so unsafe edges stay filled.
do do
local cx, cy = width / 2, 0 local cx, cy = fullW / 2, 0
local rx, ry = width * 1.3, height * 1.08 local rx, ry = fullW * 1.3, fullH * 1.08
local n = 72 local n = 72
local verts = { { cx, cy, 0, 0, local verts = { { cx, cy, 0, 0,
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } } PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
@@ -1787,8 +1788,8 @@ function RomImporter:draw()
-- CRT vignette: a gentle edge darkening, centred slightly above the middle. -- CRT vignette: a gentle edge darkening, centred slightly above the middle.
do do
local cx, cy = width / 2, height * 0.45 local cx, cy = fullW / 2, fullH * 0.45
local rx, ry = width * 0.78, height * 0.78 local rx, ry = fullW * 0.78, fullH * 0.78
local n = 72 local n = 72
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } } local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
for i = 0, n do for i = 0, n do
@@ -1810,7 +1811,7 @@ 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 = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
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
@@ -1836,21 +1837,23 @@ function RomImporter:draw()
} }
]]) ]])
-- background -- background (full window — unsafe edges stay painted)
col(PAL.bgBot) col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.bgMesh) love.graphics.draw(self.bgMesh)
-- 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.
-- Origin is the safe-area top-left so chrome clears device insets.
local appW = math.min(width, 1440 * s) local appW = math.min(width, 1440 * s)
local appX = (width - appW) / 2 local appX = ox + (width - appW) / 2
local padH = clamp(appW * 0.03, 12 * s, 26 * s) local padH = clamp(appW * 0.03, 12 * s, 26 * s)
local third = appW / 3 local third = appW / 3
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom -- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
local stripH = math.max(4, 6 * s) local stripH = math.max(4, 6 * s)
local stripY = oy
local segs = { local segs = {
{ PAL.red, appX, third }, { PAL.red, appX, third },
{ PAL.blue, appX + third, third }, { PAL.blue, appX + third, third },
@@ -1858,11 +1861,11 @@ function RomImporter:draw()
} }
love.graphics.setBlendMode("add") love.graphics.setBlendMode("add")
for _, seg in ipairs(segs) do for _, seg in ipairs(segs) do
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0) fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
end end
love.graphics.setBlendMode("alpha") love.graphics.setBlendMode("alpha")
for _, seg in ipairs(segs) do for _, seg in ipairs(segs) do
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH) col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
end end
-- Footer (Boi's Club Games logo + trust warning), measured first so the -- Footer (Boi's Club Games logo + trust warning), measured first so the
@@ -1884,7 +1887,7 @@ function RomImporter:draw()
math.min(330 * s, appW - 32 * s)) math.min(330 * s, appW - 32 * s))
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH) local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
local logoDW, logoDH = logoW * logoScale, logoH * logoScale local logoDW, logoDH = logoW * logoScale, logoH * logoScale
local logoY = stripH + 14 * s local logoY = stripY + stripH + 14 * s
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one), -- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
-- with "N of 3 ready" right-aligned. -- with "N of 3 ready" right-aligned.
@@ -1914,7 +1917,7 @@ function RomImporter:draw()
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
local cX = appX + padH local cX = appX + padH
local cW = appW - 2 * padH local cW = appW - 2 * padH
local contentBottom = height - footerH - bannerBand local contentBottom = oy + height - footerH - bannerBand
local cH = math.max(0, contentBottom - contentTop) local cH = math.max(0, contentBottom - contentTop)
-- Page scroll. Everything under the tab bar -- panel, updater banner and -- Page scroll. Everything under the tab bar -- panel, updater banner and
@@ -1925,14 +1928,14 @@ function RomImporter:draw()
-- the previous frame's measurement, the same one-frame settle the slot and -- the previous frame's measurement, the same one-frame settle the slot and
-- mod lists already rely on. While the page fits, `paged` is false and every -- mod lists already rely on. While the page fits, `paged` is false and every
-- measurement below is what it always was. -- measurement below is what it always was.
local viewportH = math.max(0, height - contentTop) local viewportH = math.max(0, oy + height - contentTop)
self._panelNaturalH = self._panelNaturalH or {} self._panelNaturalH = self._panelNaturalH or {}
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
local paged, pageScroll, maxPage = local paged, pageScroll, maxPage =
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll) RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
self.pageScroll, self._pageMax = pageScroll, maxPage self.pageScroll, self._pageMax = pageScroll, maxPage
-- read by the hit tests; a scrolled control is live only inside the viewport -- read by the hit tests; a scrolled control is live only inside the viewport
pageBand = paged and { contentTop, height } or nil pageBand = paged and { contentTop, oy + height } or nil
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation, -- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
-- and it sits above the scrolling viewport. -- and it sits above the scrolling viewport.
@@ -2103,10 +2106,10 @@ function RomImporter:draw()
-- logo, over the split, with a gentle bob + gold glow + sweeping shine -- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
local lx, ly = (width - logoDW) / 2, logoY + bob local lx, ly = ox + (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add") love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6))) love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0, love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
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
@@ -2139,11 +2142,11 @@ function RomImporter:draw()
-- save-slot rename modal (#205), drawn over everything -- save-slot rename modal (#205), drawn over everything
if self._rename then if self._rename then
col(PAL.bgBot, 0.72) col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 420 * s) local dw = math.min(appW - 32 * s, 420 * s)
local dh = 128 * s local dh = 128 * s
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4) neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
@@ -2185,11 +2188,11 @@ function RomImporter:draw()
-- it is typed. -- it is typed.
if self._indexPrompt then if self._indexPrompt then
col(PAL.bgBot, 0.72) col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 520 * s) local dw = math.min(appW - 32 * s, 520 * s)
local dh = 168 * s local dh = 168 * s
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4) neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
@@ -2241,7 +2244,7 @@ function RomImporter:draw()
if self._modConfirm or self._modVersions or self._modReleaseNotes if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then or self._findDetails then
col(PAL.bgBot, 0.72) col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
end end
if self._modConfirm then if self._modConfirm then
local c = self._modConfirm local c = self._modConfirm
@@ -2249,7 +2252,7 @@ function RomImporter:draw()
local lineH = self.hintFont:getHeight() + 4 * s local lineH = self.hintFont:getHeight() + 4 * s
local dh = 36 * s + (#c.lines) * lineH + 56 * s local dh = 36 * s + (#c.lines) * lineH + 56 * s
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2291,7 +2294,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 480 * s) local dw = math.min(appW - 32 * s, 480 * s)
local dh = math.min(height - 48 * s, 360 * s) local dh = math.min(height - 48 * s, 360 * s)
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2336,7 +2339,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 520 * s) local dw = math.min(appW - 32 * s, 520 * s)
local dh = math.min(height - 48 * s, 420 * s) local dh = math.min(height - 48 * s, 420 * s)
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2393,7 +2396,7 @@ function RomImporter:draw()
listH = listN * rowH listH = listN * rowH
dh = headerH + listH + footerH dh = headerH + listH + footerH
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
+23 -7
View File
@@ -1,11 +1,26 @@
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm): -- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
-- a distinct item id occupies one slot regardless of quantity; badges -- constants/menu_constants.asm), but mods may replace that limit through
-- live in the inventory table but are not bag items. save.bagOrder -- Data.constants.bagSize. A distinct item id occupies one slot regardless
-- keeps acquisition order like wBagItems (SELECT can reorder it). -- of quantity; badges live in the inventory table but are not bag items.
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
-- it).
local Bag = {} local Bag = {}
Bag.CAPACITY = 20 local DEFAULT_CAPACITY = 20
-- `data` is injectable for the save editor and headless mod tests. Normal
-- gameplay may omit it because the loader merges mods into the Data
-- singleton before any item can be added. The fallback keeps old/stale
-- generated caches and isolated callers at the vanilla limit.
function Bag.capacity(data)
data = data or require("src.core.Data")
local configured = data and data.constants and data.constants.bagSize
if type(configured) == "number" and configured >= 1 then
return math.floor(configured)
end
return DEFAULT_CAPACITY
end
local function isBadge(id) local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil return id:find("BADGE", 1, true) ~= nil
@@ -55,9 +70,10 @@ end
-- Add qty of an item; returns false (and adds nothing) when a new slot -- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99 -- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap). -- (AddItemToInventory's per-slot quantity cap).
function Bag.add(save, id, qty) function Bag.add(save, id, qty, data)
local inv = save.inventory local inv = save.inventory
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then if not inv[id] and not isBadge(id)
and Bag.slots(save) >= Bag.capacity(data) then
return false return false
end end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
+3 -2
View File
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name); -- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row. -- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText) function Commands.give_item(ctx, itemId, count, gotText)
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt -- the bag can refuse at its configured capacity (20 in vanilla): halt
-- the script, so later set_flag rows don't burn the gift -- make -- the script, so later set_flag rows don't burn the gift -- make
-- room and talk again, like the original (pokered's `jr nc, .bag_full` -- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses) -- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then if not require("src.inventory.Bag").add(
ctx.save, itemId, count or 1, ctx.game.data) then
Commands.show_text(ctx, ctx.game.data.text Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge return math.huge
+1 -1
View File
@@ -76,7 +76,7 @@ local function withdraw(game)
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty) then if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = Strings("You can't carry\nany more items.") list.footer = Strings("You can't carry\nany more items.")
return return
end end
+1 -1
View File
@@ -67,7 +67,7 @@ local function buy(game, stock)
list.footer = notEnough list.footer = notEnough
return return
end end
if not Bag.add(game.save, item.value, qty) then if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = txt(game, "_PokemartItemBagFullText", list.footer = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items.")) Strings("You can't carry\nany more items."))
return return
+13 -11
View File
@@ -109,34 +109,36 @@ function Editor.update(_dt)
end end
function Editor.draw() function Editor.draw()
local ww, wh = love.graphics.getDimensions() local SafeArea = require("src.core.SafeArea")
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768)) local s = math.max(0.75, math.min(1.4, wh / 768))
Editor.rects = {} Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere) -- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot) col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
col(PAL.bgTop, 0.85) col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55) love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s local pad = 18 * s
local barH = 56 * s local barH = 56 * s
local btnH = 40 * s local btnH = 40 * s
local btnW = 100 * s local btnW = 100 * s
-- top bar -- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92) col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, ww, barH + pad) love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35) col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1) love.graphics.setLineWidth(1)
love.graphics.line(0, barH + pad, ww, barH + pad) love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title) love.graphics.setFont(Editor.fonts.title)
col(PAL.white) col(PAL.white)
love.graphics.print("Touch Controls", pad, pad + 4 * s) love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset -- Done / Reset
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2, local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH } w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH } local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset Editor.rects.done, Editor.rects.reset = done, reset
@@ -156,9 +158,9 @@ function Editor.draw()
chromeBtn(done, "Done", PAL.green) chromeBtn(done, "Done", PAL.green)
-- enable toggle card -- enable toggle card
local cardY = barH + pad + 14 * s local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s local cardH = 64 * s
local cardX, cardW = pad, ww - 2 * pad local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88) col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s) roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4) col(PAL.stroke, 0.4)
@@ -190,7 +192,7 @@ function Editor.draw()
local hint = on local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done." and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout." or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left") love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled) -- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw() TouchControls:draw()
+2 -2
View File
@@ -1803,7 +1803,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {} save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end if save.hiddenTaken[key] then return false end
if not require("src.inventory.Bag").add(save, h.item, 1) then if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!"))) Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return true return true
end end
@@ -2423,7 +2423,7 @@ function OverworldState:talkTo(npc)
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats -- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text. -- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then if d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!"))) Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return return
end end
+6 -4
View File
@@ -2414,14 +2414,15 @@ local function sellItem(id)
return sold return sold
end end
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20 -- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
-- slots; a stack of an item already held costs nothing). This is why -- capacity mod; a stack of an item already held costs nothing). This is why
-- every restock had been reporting "HYPER_POTION x0": the buy list -- every restock had been reporting "HYPER_POTION x0": the buy list
-- opened, the quantity was set, the engine said "no room" -- and the run -- opened, the quantity was set, the engine said "no room" -- and the run
-- walked into the Mansion with FULL_HEALs but not one HP restore. -- walked into the Mansion with FULL_HEALs but not one HP restore.
local function freeBagSlots(needed, where) local function freeBagSlots(needed, where)
local used = #(G.save.bagOrder or {}) local used = #(G.save.bagOrder or {})
local free = 20 - used local capacity = require("src.inventory.Bag").capacity(G.data)
local free = capacity - used
for _, id in ipairs(SELLABLE_JUNK) do for _, id in ipairs(SELLABLE_JUNK) do
if free >= needed then break end if free >= needed then break end
if ((G.save.inventory or {})[id] or 0) > 0 then if ((G.save.inventory or {})[id] or 0) > 0 then
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
end end
end end
local used = #(G.save.bagOrder or {}) local used = #(G.save.bagOrder or {})
if newKinds > 0 and 20 - used < newKinds then local capacity = require("src.inventory.Bag").capacity(G.data)
if newKinds > 0 and capacity - used < newKinds then
freeBagSlots(newKinds, where) freeBagSlots(newKinds, where)
end end
end end
+8
View File
@@ -203,4 +203,12 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end } stub.timer = { getTime = function() return 0 end }
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()
local ww, wh = stub.graphics.getDimensions()
return 0, 0, ww, wh
end,
}
return stub return stub
+69
View File
@@ -0,0 +1,69 @@
-- T4: constants.bagSize controls the bag through the public mod API while
-- vanilla and existing-save behavior remain unchanged.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Bag = require("src.inventory.Bag")
local CAPACITY_MOD = {
["mods/fix_small_bag/manifest.json"] = [[{
"id": "fix_small_bag",
"name": "Fixture Small Bag",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_small_bag/main.lua"] = [[
local mod = ...
mod.content.constants:patch("bagSize", 2)
]],
}
-- No-mod parity: the new lookup keeps the cartridge's 20-slot limit.
do
local data = T.fixtures.fresh()
local run = T.sdk.loadNone({ data = data })
T.eq(#run.errors, 0, "the no-mod baseline loads cleanly")
T.eq(Bag.capacity(data), 20, "the vanilla bag still has 20 slots")
T.eq(Bag.capacity({}), 20, "a stale dataset without bagSize falls back to 20")
run.release()
end
-- The public constants registry changes both the reported and enforced cap.
do
local data = T.fixtures.fresh()
local run = T.sdk.loadMods({ "mods/fix_small_bag" },
{ data = data, fs = T.sdk.memfs(CAPACITY_MOD) })
T.eq(#run.errors, 0, "the capacity mod loads cleanly")
T.eq(Bag.capacity(data), 2, "Bag.capacity reads merged constants.bagSize")
local save = { inventory = {} }
T.check(Bag.add(save, "FIX_POTION", 1, data), "the first item fits")
T.check(Bag.add(save, "FIX_BALL", 1, data), "the second item fits")
T.check(not Bag.add(save, "FIX_TM", 1, data),
"a new item is refused at the modded limit")
T.eq(Bag.slots(save), 2, "a refused add does not change the bag")
run.release()
end
-- Saves are dictionaries, not fixed arrays: lowering the active cap never
-- truncates an older or modded save. Existing stacks remain usable while a
-- new item waits until the player makes enough room.
do
local data = T.fixtures.fresh()
data.constants.bagSize = 1
local save = {
inventory = { FIX_POTION = 1, FIX_BALL = 1 },
bagOrder = { "FIX_POTION", "FIX_BALL" },
}
T.eq(Bag.slots(save), 2, "an over-cap save keeps all existing slots")
T.check(Bag.add(save, "FIX_POTION", 1, data),
"an over-cap save can still add to an existing stack")
T.eq(save.inventory.FIX_POTION, 2, "the existing stack is updated")
T.check(not Bag.add(save, "FIX_TM", 1, data),
"an over-cap save cannot add another item kind")
T.eq(Bag.slots(save), 2, "the compatibility path never drops items")
end
T.finish("bag_capacity")
+22 -3
View File
@@ -96,9 +96,13 @@ local fired = false
ri3.focus = function(self, f) if f then fired = true end end ri3.focus = function(self, f) if f then fired = true end end
ri3:_pollPickedFiles(0.6) ri3:_pollPickedFiles(0.6)
check(not fired, "an empty save dir consumes nothing") check(not fired, "an empty save dir consumes nothing")
check(ri3.pickPending, "and stays armed while it is still within the timeout") check(ri3.pickPending, "and stays armed, waiting for the pick to land")
ri3:_pollPickedFiles(200) -- No timeout on purpose: a 120s disarm silently dropped iOS imports, because the
check(not ri3.pickPending, "a cancelled pick disarms instead of polling forever") -- picker there is an in-process sheet and update() keeps running while the
-- player browses Files. Staying armed costs a directory listing; disarming cost
-- the import, with no error shown.
ri3:_pollPickedFiles(600)
check(ri3.pickPending, "and is still armed after a long browse in the picker")
-- 5. A pick that is still importing must not be double-started. -- 5. A pick that is still importing must not be double-started.
saveDir = { ["picked_mod.zip"] = "PK\003\004" } saveDir = { ["picked_mod.zip"] = "PK\003\004" }
@@ -131,6 +135,21 @@ contract:choose("red")
check(picks == 2, check(picks == 2,
"choose() still reopens the picker per call (#420/#442 contract, got " .. picks .. ")") "choose() still reopens the picker per call (#420/#442 contract, got " .. picks .. ")")
-- 7. iOS taps must survive the Android double-fire guard. love.touchpressed in
-- main.lua returns early on iOS and never forwards, so the synthesized
-- love.mousepressed is the ONLY event the launcher gets there. Filtering
-- istouch on both platforms killed every tap on iOS. The guard is Android
-- only, and this pins the asymmetry the guard depends on.
local touchForwardsToImporter = {
Android = true, -- love.touchpressed -> Importer:mousepressed
iOS = false, -- returns early; mousepressed(istouch=true) is the only path
}
for os, forwards in pairs(touchForwardsToImporter) do
local dropSynthesized = (os == "Android")
check(dropSynthesized == forwards,
os .. ": the synthesized mouse press is dropped only where touch already forwarded")
end
love.system.getOS = saved.getOS love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile love.system.pickFile = saved.pickFile
love.filesystem.getDirectoryItems = saved.getDirectoryItems love.filesystem.getDirectoryItems = saved.getDirectoryItems
+29 -1
View File
@@ -1696,6 +1696,7 @@ end
-- ---------------------------------------------------------------- touch controls layout (#327) -- ---------------------------------------------------------------- touch controls layout (#327)
do do
local TC = require("src.core.TouchControls") local TC = require("src.core.TouchControls")
local SafeArea = require("src.core.SafeArea")
local cfg = TC.normalizeConfig(nil) local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled") eq(cfg.enabled, true, "touchControls default enabled")
check(cfg.positions == nil, "touchControls default positions nil") check(cfg.positions == nil, "touchControls default positions nil")
@@ -1721,6 +1722,12 @@ do
check(L.a.cx > 200, "default A on right half") check(L.a.cx > 200, "default A on right half")
check(L.dpad.cy > 400, "default d-pad in bottom half") check(L.dpad.cy > 400, "default d-pad in bottom half")
-- safe-area origin shifts defaults without changing relative layout
local Ls = TC.defaultLayout(400, 800, 20, 30)
eq(Ls.dpad.cx, L.dpad.cx + 20, "defaultLayout ox shifts controls")
eq(Ls.dpad.cy, L.dpad.cy + 30, "defaultLayout oy shifts controls")
eq(Ls.a.cx, L.a.cx + 20, "defaultLayout ox shifts A")
-- applyOptions + visible gate (no real images needed for the gate) -- applyOptions + visible gate (no real images needed for the gate)
TC.enabled = true TC.enabled = true
TC.active = true TC.active = true
@@ -1743,16 +1750,37 @@ do
-- custom position applied through layout() -- custom position applied through layout()
local g = love.graphics local g = love.graphics
local oldDim, oldFont = g.getDimensions, g.newFont local oldDim, oldFont = g.getDimensions, g.newFont
local oldSafe = love.window and love.window.getSafeArea
g.getDimensions = function() return 400, 800 end g.getDimensions = function() return 400, 800 end
g.newFont = function() return { getWidth = function() return 10 end, g.newFont = function() return { getWidth = function() return 10 end,
getHeight = function() return 10 end } end getHeight = function() return 10 end } end
TC.layoutW, TC.layoutH, TC.L = nil, nil, nil love.window = love.window or {}
love.window.getSafeArea = function() return 0, 0, 400, 800 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
local lay = TC:layout() local lay = TC:layout()
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww") eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh") eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
-- inset safe area: custom positions stay inside the usable rect
love.window.getSafeArea = function() return 10, 40, 380, 720 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
lay = TC:layout()
eq(lay.dpad.cx, 10 + 0.25 * 380, "safe-area custom dpad cx")
eq(lay.dpad.cy, 40 + 0.75 * 720, "safe-area custom dpad cy")
check(lay.dpad.cy <= 40 + 720 - lay.dpad.w * 0.5 + 1e-6,
"safe-area dpad clears bottom inset")
local x, y, w, h = SafeArea.rect()
eq(x, 10, "SafeArea.rect x")
eq(y, 40, "SafeArea.rect y")
eq(w, 380, "SafeArea.rect w")
eq(h, 720, "SafeArea.rect h")
TC:clearPositions() TC:clearPositions()
check(TC.positions == nil, "clearPositions wipes overrides") check(TC.positions == nil, "clearPositions wipes overrides")
g.getDimensions, g.newFont = oldDim, oldFont g.getDimensions, g.newFont = oldDim, oldFont
if oldSafe then love.window.getSafeArea = oldSafe
else love.window.getSafeArea = nil end
end end
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest) -- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
+3 -2
View File
@@ -181,12 +181,13 @@ end
do do
-- the bag has a hard slot cap; the picker must refuse past it -- the bag has a hard slot cap; the picker must refuse past it
local S = newState() local S = newState()
local capacity = Bag.capacity(S.data)
local added = 0 local added = 0
for _, id in ipairs(S.cat.items) do for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end
if added >= Bag.CAPACITY then break end if added >= capacity then break end
end end
eq(Bag.slots(S.save), Bag.CAPACITY, "the bag filled to its cap") eq(Bag.slots(S.save), capacity, "the bag filled to its cap")
S.dirty = false S.dirty = false
local spare local spare
for _, id in ipairs(S.cat.items) do for _, id in ipairs(S.cat.items) do
+1 -1
View File
@@ -427,7 +427,7 @@ local function tabCount(id)
return tostring(n) return tostring(n)
elseif id == "items" then elseif id == "items" then
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
return ("%d/%d"):format(Bag.slots(S.save), Bag.CAPACITY) return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
elseif id == "events" then elseif id == "events" then
local n = 0 local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end for _ in pairs(S.save.flags or {}) do n = n + 1 end
+8 -5
View File
@@ -8,7 +8,8 @@
-- --
-- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6 -- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6
-- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999, -- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999,
-- item stack 99 and 20 bag slots (src/inventory/Bag). -- item stack 99 and the configured bag capacity (20 by default;
-- src/inventory/Bag).
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local PartyMod = require("src.pokemon.Party") local PartyMod = require("src.pokemon.Party")
@@ -338,11 +339,13 @@ end
function Ops.addToBag(S, id) function Ops.addToBag(S, id)
if not id then return Ops.say(S, "Pick an item first") end if not id then return Ops.say(S, "Pick an item first") end
if Bag.add(S.save, id, 1) then local capacity = Bag.capacity(S.data)
if Bag.add(S.save, id, 1, S.data) then
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)") return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
:format(id, Bag.slots(S.save), Bag.CAPACITY)) :format(id, Bag.slots(S.save), capacity))
end end
return Ops.say(S, ("Bag is full (%d/%d slots)"):format(Bag.slots(S.save), Bag.CAPACITY)) return Ops.say(S, ("Bag is full (%d/%d slots)")
:format(Bag.slots(S.save), capacity))
end end
function Ops.bagAdjust(S, id, delta) function Ops.bagAdjust(S, id, delta)
@@ -352,7 +355,7 @@ function Ops.bagAdjust(S, id, delta)
if have >= Ops.STACK_MAX then if have >= Ops.STACK_MAX then
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
end end
Bag.add(S.save, id, delta) Bag.add(S.save, id, delta, S.data)
else else
Bag.remove(S.save, id, -delta) Bag.remove(S.save, id, -delta)
if not S.save.inventory[id] then if not S.save.inventory[id] then
+4 -3
View File
@@ -1,4 +1,4 @@
-- Items panel: money, the shared item picker, badges, the 20-slot bag -- Items panel: money, the shared item picker, badges, the configurable bag
-- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain -- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain
-- S.save.pcItems dict with no slot cap). -- S.save.pcItems dict with no slot cap).
-- --
@@ -178,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------------------- bag -- --------------------------------------------------------------- bag
local order = Bag.order(S.save) local order = Bag.order(S.save)
local capacity = Bag.capacity(S.data)
Kit.card(bagX, y, listW, h) Kit.card(bagX, y, listW, h)
Kit.caption(bagX + pad, y + pad, "BAG") Kit.caption(bagX + pad, y + pad, "BAG")
Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), Bag.CAPACITY), Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), capacity),
bagX + listW - pad, y + pad, PAL.caption) bagX + listW - pad, y + pad, PAL.caption)
local barY = y + pad + Kit.textHeight("caption") + 8 * s local barY = y + pad + Kit.textHeight("caption") + 8 * s
local slotFrac = Bag.slots(S.save) / Bag.CAPACITY local slotFrac = Bag.slots(S.save) / capacity
Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100, Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100,
slotFrac >= 1 and PAL.yellow or PAL.blue) slotFrac >= 1 and PAL.yellow or PAL.blue)