mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| e949c78639 | |||
| 9bcfdb1f0d | |||
| fccb122c59 |
@@ -12,7 +12,7 @@ this port.
|
||||
|
||||
| # | 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`) |
|
||||
| 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` |
|
||||
@@ -38,6 +38,10 @@ this port.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
clickable and create a mon there.
|
||||
- **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.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
|
||||
@@ -461,14 +461,31 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then return Importer:mousepressed(x, y, button) end
|
||||
if Importer then
|
||||
-- The same double-fire TouchEditor guards against, which the launcher was
|
||||
-- missing: love.touchpressed above forwards the primary touch to the
|
||||
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
|
||||
-- same touch, so one tap ran every launcher button twice. On Import that
|
||||
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||
-- 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"
|
||||
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||
-- USB mouse) working, which an Android-wide return would have broken.
|
||||
--
|
||||
-- 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)
|
||||
end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
@@ -156,7 +156,7 @@ return function(mod)
|
||||
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||
mod.save:set("caught_areas", caughtAreas())
|
||||
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!"
|
||||
or "You already have\nthis POKéMON family!")
|
||||
return
|
||||
|
||||
+71
-5
@@ -65,6 +65,10 @@ DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
# Last resort for an incomplete source export, mirroring build_android.sh.
|
||||
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
|
||||
MANIFESTS=""
|
||||
|
||||
VERSION=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
@@ -229,6 +233,68 @@ apply_ios_branding() {
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# Every version's import manifest has to ship or that game's ROM import fails in
|
||||
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
|
||||
# one is absent, and dev reads them off the source tree, so the miss only ever
|
||||
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
|
||||
# for exactly that reason.
|
||||
#
|
||||
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
|
||||
# a fourth version cannot silently ship without its manifest, and a missing file
|
||||
# is recovered from Git or the project repo the same way build_android.sh already
|
||||
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
|
||||
# a manifest carries extraction metadata only, never a ROM or game data.
|
||||
manifest_paths() {
|
||||
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
|
||||
import re, sys
|
||||
src = open(sys.argv[1]).read()
|
||||
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
|
||||
PY
|
||||
}
|
||||
|
||||
manifest_is_valid() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json, pathlib, sys
|
||||
try:
|
||||
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
sha = m.get("romSha1")
|
||||
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_manifests() {
|
||||
MANIFESTS="$(manifest_paths)"
|
||||
[ -n "$MANIFESTS" ] \
|
||||
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
|
||||
local rel staged
|
||||
for rel in $MANIFESTS; do
|
||||
if manifest_is_valid "$ROOT/$rel"; then continue; fi
|
||||
warn "$rel is missing or invalid; recovering it before packaging"
|
||||
staged="$(mktemp)"
|
||||
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
|
||||
&& manifest_is_valid "$staged"; then
|
||||
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||
mv "$staged" "$ROOT/$rel"
|
||||
say "restored $rel from this checkout's Git data"
|
||||
continue
|
||||
fi
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
|
||||
&& manifest_is_valid "$staged"; then
|
||||
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||
mv "$staged" "$ROOT/$rel"
|
||||
say "downloaded $rel from the project repository"
|
||||
continue
|
||||
fi
|
||||
rm -f "$staged"
|
||||
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
|
||||
done
|
||||
say "import manifests: $MANIFESTS"
|
||||
}
|
||||
|
||||
pack_game_love() {
|
||||
say "packing game.love for love-ios resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
@@ -240,10 +306,10 @@ pack_game_love() {
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
$MANIFESTS \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||
@@ -259,10 +325,9 @@ pack_game_love() {
|
||||
# outright when a version's manifest is absent, so Import ROM on Yellow died
|
||||
# in the built app while dev, which reads the source tree, stayed green.
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; do
|
||||
tools/save-editor/panels/Party.lua $MANIFESTS; do
|
||||
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
@@ -577,6 +642,7 @@ install_to_device() {
|
||||
apply_ios_branding
|
||||
say "applying iOS native bridge patches (picker/Files support)"
|
||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
ensure_manifests
|
||||
pack_game_love
|
||||
ensure_game_love_in_xcode
|
||||
|
||||
|
||||
+5
-5
@@ -14,12 +14,12 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- The rules the engine still carries as literals. The constants registry
|
||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
||||
-- changes nothing on a mod-free boot.
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
-- boot.
|
||||
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)
|
||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||
moveMax = 4,
|
||||
|
||||
@@ -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
|
||||
@@ -1058,7 +1058,7 @@ local function reclaim(save, data, report)
|
||||
if type(entry) == "table" and known(data.items, entry.id) then
|
||||
table.remove(orphaned.items, i)
|
||||
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[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||
end
|
||||
|
||||
+42
-26
@@ -23,6 +23,7 @@
|
||||
-- and a player rebind can never detach the overlay.
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local TouchControls = {}
|
||||
|
||||
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Pure default layout in LOVE units for a given window size. Shared by
|
||||
-- layout() and the editor's Reset path so defaults stay in one place.
|
||||
function TouchControls.defaultLayout(ww, wh)
|
||||
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||
-- 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 dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
return {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + 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
|
||||
|
||||
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
|
||||
self.enabled = cfg.enabled
|
||||
self.positions = cfg.positions
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
if not self.enabled then
|
||||
self.controllerHidden = false
|
||||
self:reset()
|
||||
@@ -185,30 +190,37 @@ function TouchControls:visible()
|
||||
and not self.controllerHidden
|
||||
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
|
||||
zone.cx = math.max(half, math.min(ww - half, zone.cx))
|
||||
zone.cy = math.max(half, math.min(wh - half, zone.cy))
|
||||
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
|
||||
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
|
||||
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center. Custom positions (normalized 0..1) override
|
||||
-- centers while sizes stay derived from the short edge.
|
||||
-- the window or safe area changes (rotation, resize, notch insets).
|
||||
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||
-- 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()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
self.L = TouchControls.defaultLayout(ww, wh)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||
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
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = p.x * ww
|
||||
zone.cy = p.y * wh
|
||||
clampZone(zone, ww, wh)
|
||||
zone.cx = ox + p.x * sw
|
||||
zone.cy = oy + p.y * sh
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -222,21 +234,25 @@ function TouchControls:layout()
|
||||
end
|
||||
|
||||
-- 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)
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
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[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
|
||||
|
||||
function TouchControls:clearPositions()
|
||||
self.positions = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
|
||||
end
|
||||
elseif game.data.items and game.data.items[id] then
|
||||
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))
|
||||
else
|
||||
self:print("bag full")
|
||||
|
||||
+52
-49
@@ -1,6 +1,7 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
@@ -1017,7 +1018,6 @@ function RomImporter:chooseMod()
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1082,7 +1082,6 @@ function RomImporter:chooseSaveImport(version)
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1121,7 +1120,6 @@ function RomImporter:exportSave(version)
|
||||
if love.system.createFile and love.system.createFile(suggested) then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Pick where to save " .. suggested .. "..." }
|
||||
else
|
||||
@@ -1177,7 +1175,6 @@ function RomImporter:choose(version)
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1225,18 +1222,18 @@ end
|
||||
-- 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).
|
||||
--
|
||||
-- Disarms after PICK_TIMEOUT so a cancelled picker (which delivers nothing,
|
||||
-- ever) does not leave this scanning the save directory for the whole session.
|
||||
local PICK_TIMEOUT = 120
|
||||
-- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
|
||||
-- cancelled picker would stop scanning, which was wrong on iOS: the picker there
|
||||
-- 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)
|
||||
if not self.pickPending 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
|
||||
if self.pickTimer < 0.5 then return end
|
||||
self.pickTimer = 0
|
||||
@@ -1246,7 +1243,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
local pickError = love.filesystem.read("pick_error.txt")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.txt")
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self.pickPending = nil
|
||||
self.modNotice = { ok = false, text = pickError }
|
||||
self.notice = { version = self.chooseVersion or "red",
|
||||
status = "File import failed:", detail = pickError }
|
||||
@@ -1263,7 +1260,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
end
|
||||
end
|
||||
if found then
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self.pickPending = nil
|
||||
self:focus(true)
|
||||
end
|
||||
end
|
||||
@@ -1295,10 +1292,10 @@ local PAD_DPAD_SPEED = 420
|
||||
|
||||
function RomImporter:_activatePadCursor()
|
||||
if self._padCursorActive then return end
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
if not self._padInited then
|
||||
self._padCursor.x = w * 0.5
|
||||
self._padCursor.y = h * 0.45
|
||||
self._padCursor.x = ox + w * 0.5
|
||||
self._padCursor.y = oy + h * 0.45
|
||||
self._padInited = true
|
||||
end
|
||||
self._padCursorActive = true
|
||||
@@ -1344,11 +1341,11 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if mag > 1 then dx, dy = dx / mag, dy / mag end
|
||||
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
|
||||
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 ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(0, math.min(w, nx))
|
||||
self._padCursor.y = math.max(0, math.min(h, ny))
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
end
|
||||
|
||||
-- Right stick scrolls the active list (save slots or mods), or the whole page
|
||||
@@ -1726,7 +1723,10 @@ function RomImporter:_resetFrameRects()
|
||||
end
|
||||
|
||||
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 pulse = self.pulse
|
||||
self._s = s
|
||||
@@ -1745,8 +1745,9 @@ function RomImporter:draw()
|
||||
self._anyHover = false
|
||||
self:_resetFrameRects()
|
||||
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
|
||||
local fontKey = ("%dx%d"):format(width, height)
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
|
||||
-- area changes (rotation, resize, inset changes).
|
||||
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
|
||||
if self.fontKey ~= fontKey then
|
||||
self.fontKey = fontKey
|
||||
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).
|
||||
-- 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
|
||||
-- match seamlessly.
|
||||
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
|
||||
do
|
||||
local cx, cy = width / 2, 0
|
||||
local rx, ry = width * 1.3, height * 1.08
|
||||
local cx, cy = fullW / 2, 0
|
||||
local rx, ry = fullW * 1.3, fullH * 1.08
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0,
|
||||
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.
|
||||
do
|
||||
local cx, cy = width / 2, height * 0.45
|
||||
local rx, ry = width * 0.78, height * 0.78
|
||||
local cx, cy = fullW / 2, fullH * 0.45
|
||||
local rx, ry = fullW * 0.78, fullH * 0.78
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
|
||||
for i = 0, n do
|
||||
@@ -1810,7 +1811,7 @@ function RomImporter:draw()
|
||||
self.scanlineImage:setWrap("repeat", "repeat")
|
||||
self.scanlineImage:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
|
||||
end
|
||||
|
||||
-- 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)
|
||||
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.draw(self.bgMesh)
|
||||
|
||||
-- Centered content container (max ~1440 scaled units on very wide windows)
|
||||
-- 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 appX = (width - appW) / 2
|
||||
local appX = ox + (width - appW) / 2
|
||||
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
|
||||
local third = appW / 3
|
||||
|
||||
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
|
||||
local stripH = math.max(4, 6 * s)
|
||||
local stripY = oy
|
||||
local segs = {
|
||||
{ PAL.red, appX, third },
|
||||
{ PAL.blue, appX + third, third },
|
||||
@@ -1858,11 +1861,11 @@ function RomImporter:draw()
|
||||
}
|
||||
love.graphics.setBlendMode("add")
|
||||
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
|
||||
love.graphics.setBlendMode("alpha")
|
||||
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
|
||||
|
||||
-- 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))
|
||||
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
|
||||
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),
|
||||
-- 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 cX = appX + padH
|
||||
local cW = appW - 2 * padH
|
||||
local contentBottom = height - footerH - bannerBand
|
||||
local contentBottom = oy + height - footerH - bannerBand
|
||||
local cH = math.max(0, contentBottom - contentTop)
|
||||
|
||||
-- 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
|
||||
-- mod lists already rely on. While the page fits, `paged` is false and every
|
||||
-- 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 {}
|
||||
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
|
||||
local paged, pageScroll, maxPage =
|
||||
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
|
||||
self.pageScroll, self._pageMax = pageScroll, maxPage
|
||||
-- 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,
|
||||
-- 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
|
||||
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.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)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
@@ -2139,11 +2142,11 @@ function RomImporter:draw()
|
||||
-- save-slot rename modal (#205), drawn over everything
|
||||
if self._rename then
|
||||
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 dh = 128 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
|
||||
@@ -2185,11 +2188,11 @@ function RomImporter:draw()
|
||||
-- it is typed.
|
||||
if self._indexPrompt then
|
||||
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 dh = 168 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
|
||||
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
|
||||
or self._findDetails then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
end
|
||||
if self._modConfirm then
|
||||
local c = self._modConfirm
|
||||
@@ -2249,7 +2252,7 @@ function RomImporter:draw()
|
||||
local lineH = self.hintFont:getHeight() + 4 * s
|
||||
local dh = 36 * s + (#c.lines) * lineH + 56 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
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 dh = math.min(height - 48 * s, 360 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
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 dh = math.min(height - 48 * s, 420 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2393,7 +2396,7 @@ function RomImporter:draw()
|
||||
listH = listN * rowH
|
||||
dh = headerH + listH + footerH
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
|
||||
+23
-7
@@ -1,11 +1,26 @@
|
||||
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
|
||||
-- a distinct item id occupies one slot regardless of quantity; badges
|
||||
-- live in the inventory table but are not bag items. save.bagOrder
|
||||
-- keeps acquisition order like wBagItems (SELECT can reorder it).
|
||||
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
|
||||
-- constants/menu_constants.asm), but mods may replace that limit through
|
||||
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
|
||||
-- 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 = {}
|
||||
|
||||
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)
|
||||
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
|
||||
-- is needed and the bag is full, or when the stack would pass 99
|
||||
-- (AddItemToInventory's per-slot quantity cap).
|
||||
function Bag.add(save, id, qty)
|
||||
function Bag.add(save, id, qty, data)
|
||||
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
|
||||
end
|
||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||
|
||||
@@ -210,11 +210,12 @@ end
|
||||
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
|
||||
-- pass false when the script shows its own received-text row.
|
||||
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
|
||||
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
|
||||
-- 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
|
||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||
return math.huge
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ local function withdraw(game)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
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.")
|
||||
return
|
||||
end
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ local function buy(game, stock)
|
||||
list.footer = notEnough
|
||||
return
|
||||
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",
|
||||
Strings("You can't carry\nany more items."))
|
||||
return
|
||||
|
||||
@@ -109,34 +109,36 @@ function Editor.update(_dt)
|
||||
end
|
||||
|
||||
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))
|
||||
Editor.rects = {}
|
||||
|
||||
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
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 barH = 56 * s
|
||||
local btnH = 40 * 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)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
|
||||
col(PAL.stroke, 0.35)
|
||||
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)
|
||||
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
|
||||
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 }
|
||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||
Editor.rects.done, Editor.rects.reset = done, reset
|
||||
@@ -156,9 +158,9 @@ function Editor.draw()
|
||||
chromeBtn(done, "Done", PAL.green)
|
||||
|
||||
-- enable toggle card
|
||||
local cardY = barH + pad + 14 * s
|
||||
local cardY = oy + barH + pad + 14 * 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)
|
||||
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
@@ -190,7 +192,7 @@ function Editor.draw()
|
||||
local hint = on
|
||||
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."
|
||||
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)
|
||||
TouchControls:draw()
|
||||
|
||||
@@ -1803,7 +1803,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if h.x == fx and h.y == fy then
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
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!")))
|
||||
return true
|
||||
end
|
||||
@@ -2423,7 +2423,7 @@ function OverworldState:talkTo(npc)
|
||||
-- (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.
|
||||
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!")))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2414,14 +2414,15 @@ local function sellItem(id)
|
||||
return sold
|
||||
end
|
||||
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
|
||||
-- slots; a stack of an item already held costs nothing). This is why
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
|
||||
-- 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
|
||||
-- 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.
|
||||
local function freeBagSlots(needed, where)
|
||||
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
|
||||
if free >= needed then break end
|
||||
if ((G.save.inventory or {})[id] or 0) > 0 then
|
||||
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
|
||||
end
|
||||
end
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -203,4 +203,12 @@ stub.mouse = {
|
||||
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
@@ -96,9 +96,13 @@ local fired = false
|
||||
ri3.focus = function(self, f) if f then fired = true end end
|
||||
ri3:_pollPickedFiles(0.6)
|
||||
check(not fired, "an empty save dir consumes nothing")
|
||||
check(ri3.pickPending, "and stays armed while it is still within the timeout")
|
||||
ri3:_pollPickedFiles(200)
|
||||
check(not ri3.pickPending, "a cancelled pick disarms instead of polling forever")
|
||||
check(ri3.pickPending, "and stays armed, waiting for the pick to land")
|
||||
-- No timeout on purpose: a 120s disarm silently dropped iOS imports, because the
|
||||
-- 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.
|
||||
saveDir = { ["picked_mod.zip"] = "PK\003\004" }
|
||||
@@ -109,6 +113,43 @@ ri4.workState = "working"
|
||||
ri4:_pollPickedFiles(0.6)
|
||||
check(not fired4, "the poll stands down while an import is already running")
|
||||
|
||||
-- 6. THE ACTUAL #553 CAUSE lives in main.lua, not here. On Android
|
||||
-- love.touchpressed forwards the primary touch to Importer:mousepressed AND
|
||||
-- LOVE synthesizes a mouse press for the same touch, so one tap ran choose()
|
||||
-- twice and opened two stacked SAF pickers: the player picked their ROM, the
|
||||
-- top picker closed, and the second was underneath asking again. main.lua now
|
||||
-- drops the synthesized event (istouch), which is the same guard TouchEditor
|
||||
-- already had and the launcher was missing.
|
||||
--
|
||||
-- Deliberately NOT deduped here: tests/engine/save_import_retry_bug420.lua
|
||||
-- and rom_pick_error_bug442.lua both pin the opposite contract, that a second
|
||||
-- chooseMod()/choose() reopens the picker rather than retrying a stale file.
|
||||
-- Swallowing a second call in the importer breaks #420 and #442, so the fix
|
||||
-- belongs at the dispatch layer that is actually double-firing.
|
||||
saveDir = {}
|
||||
local picks = 0
|
||||
love.system.pickFile = function() picks = picks + 1; return true end
|
||||
local contract = importer("Android")
|
||||
contract:choose("red")
|
||||
contract:choose("red")
|
||||
check(picks == 2,
|
||||
"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.pickFile = saved.pickFile
|
||||
love.filesystem.getDirectoryItems = saved.getDirectoryItems
|
||||
|
||||
+29
-1
@@ -1696,6 +1696,7 @@ end
|
||||
-- ---------------------------------------------------------------- touch controls layout (#327)
|
||||
do
|
||||
local TC = require("src.core.TouchControls")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local cfg = TC.normalizeConfig(nil)
|
||||
eq(cfg.enabled, true, "touchControls default enabled")
|
||||
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.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)
|
||||
TC.enabled = true
|
||||
TC.active = true
|
||||
@@ -1743,16 +1750,37 @@ do
|
||||
-- custom position applied through layout()
|
||||
local g = love.graphics
|
||||
local oldDim, oldFont = g.getDimensions, g.newFont
|
||||
local oldSafe = love.window and love.window.getSafeArea
|
||||
g.getDimensions = function() return 400, 800 end
|
||||
g.newFont = function() return { getWidth = function() return 10 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()
|
||||
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
|
||||
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()
|
||||
check(TC.positions == nil, "clearPositions wipes overrides")
|
||||
g.getDimensions, g.newFont = oldDim, oldFont
|
||||
if oldSafe then love.window.getSafeArea = oldSafe
|
||||
else love.window.getSafeArea = nil end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
|
||||
|
||||
@@ -181,12 +181,13 @@ end
|
||||
do
|
||||
-- the bag has a hard slot cap; the picker must refuse past it
|
||||
local S = newState()
|
||||
local capacity = Bag.capacity(S.data)
|
||||
local added = 0
|
||||
for _, id in ipairs(S.cat.items) do
|
||||
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
|
||||
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
|
||||
local spare
|
||||
for _, id in ipairs(S.cat.items) do
|
||||
|
||||
@@ -427,7 +427,7 @@ local function tabCount(id)
|
||||
return tostring(n)
|
||||
elseif id == "items" then
|
||||
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
|
||||
local n = 0
|
||||
for _ in pairs(S.save.flags or {}) do n = n + 1 end
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
--
|
||||
-- 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,
|
||||
-- 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 PartyMod = require("src.pokemon.Party")
|
||||
@@ -338,11 +339,13 @@ end
|
||||
|
||||
function Ops.addToBag(S, id)
|
||||
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)")
|
||||
:format(id, Bag.slots(S.save), Bag.CAPACITY))
|
||||
:format(id, Bag.slots(S.save), capacity))
|
||||
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
|
||||
|
||||
function Ops.bagAdjust(S, id, delta)
|
||||
@@ -352,7 +355,7 @@ function Ops.bagAdjust(S, id, delta)
|
||||
if have >= Ops.STACK_MAX then
|
||||
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
|
||||
end
|
||||
Bag.add(S.save, id, delta)
|
||||
Bag.add(S.save, id, delta, S.data)
|
||||
else
|
||||
Bag.remove(S.save, id, -delta)
|
||||
if not S.save.inventory[id] then
|
||||
|
||||
@@ -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
|
||||
-- S.save.pcItems dict with no slot cap).
|
||||
--
|
||||
@@ -178,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
|
||||
-- --------------------------------------------------------------- bag
|
||||
local order = Bag.order(S.save)
|
||||
local capacity = Bag.capacity(S.data)
|
||||
Kit.card(bagX, y, listW, h)
|
||||
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)
|
||||
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,
|
||||
slotFrac >= 1 and PAL.yellow or PAL.blue)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user