launcher editor and widescreen battle

This commit is contained in:
bryanthaboi
2026-07-28 12:00:15 -04:00
parent f9f38d161f
commit 8539a6b268
45 changed files with 5236 additions and 1797 deletions
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env bash
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
# stock OS (Allwinner H700, 64-bit Stock OS / Stock OS Mod + PortMaster).
#
# The stock Anbernic firmware resolves ports relative to the launch script
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
# device does not need a separate love_11.5 runtime download on first launch.
#
# Usage:
# ./build-rg34xxsp.sh [--version X.Y.Z]
#
# Output:
# dist/rg34xxsp/gen1recomp-rg34xxsp.zip
#
# Install on device:
# 1. Flash / run 64-bit Stock OS (or Stock OS Mod) with PortMaster installed.
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
# roms/PORTS/Gen1recomp.sh
# roms/PORTS/gen1recomp/...
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
# folder when zenity is missing).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HERE="$ROOT/.bazinga"
CACHE="$HERE/cache/rg34xxsp"
WORK="$HERE/work/rg34xxsp"
DIST="$ROOT/dist/rg34xxsp"
APP_NAME="gen1recomp"
PORT_DIR_NAME="gen1recomp"
LAUNCHER_NAME="Gen1recomp.sh"
LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
command -v curl >/dev/null || fail "curl is required"
command -v zip >/dev/null || fail "zip is required"
command -v unzip >/dev/null || fail "unzip is required"
mkdir -p "$CACHE" "$WORK" "$DIST"
download() {
local url="$1" dest="$2"
if [ -f "$dest" ] && [ -s "$dest" ]; then
return 0
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|| fail "download failed: $url"
mv "$dest.tmp" "$dest"
}
# --------------------------------------------------------------- game tree
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
# main.lua and RomImporter can scan it without zenity/kdialog.
say "staging lovegame/"
GAME_SRC="$WORK/lovegame"
rm -rf "$GAME_SRC"
mkdir -p "$GAME_SRC"
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "payload unexpectedly contains generated ROM data"
fi
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
# Stamp release version into the staged tree only (never the working tree).
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
"$GAME_SRC/src/core/Version.lua" \
|| fail "version stamp failed"
else
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
fi
# Portable marker: saves + ROM cache live next to the game on the SD card.
: > "$GAME_SRC/portable.txt"
# --------------------------------------------------------------- love runtime
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
LOVE_BIN="$CACHE/love.aarch64"
LOVE_LIB="$CACHE/liblove-11.5.so"
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
MODPLUG_LIB="$CACHE/libmodplug.so.1"
OGG_LIB="$CACHE/libogg.so.0"
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
# Sanity: love stub must be an aarch64 ELF.
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
# --------------------------------------------------------------- port tree
say "assembling port package"
PORT_ROOT="$WORK/port"
rm -rf "$PORT_ROOT"
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
"$PORT_ROOT/$PORT_DIR_NAME/conf"
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
# Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
see https://love2d.org/ for full terms.
EOF
# --------------------------------------------------------------- launcher
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
# casing and mount points (mmc / sdcard / TF1 / TF2).
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
# Uses SHDIR-relative paths so stock firmware finds the game folder.
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
if [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/roms/PORTS/PortMaster"
fi
SHDIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
GAMEDIR="$SHDIR/gen1recomp"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# Mali / H700: prefer GLES where available
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
fi
if type pm_platform_helper >/dev/null 2>&1; then
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi
./bin/love.aarch64 "$GAMEDIR/lovegame"
if type pm_finish >/dev/null 2>&1; then
pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi
EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
# --------------------------------------------------------------- metadata
cat > "$PORT_ROOT/port.json" <<EOF
{
"version": 2,
"name": "gen1recomp.zip",
"items": [
"$LAUNCHER_NAME",
"$PORT_DIR_NAME"
],
"items_opt": null,
"attr": {
"title": "gen1recomp",
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
"inst": "Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM. Requires 64-bit Stock OS with PortMaster.",
"genres": ["adventure", "rpg"],
"porter": ["gen1recomp"],
"image": {},
"rtr": true,
"runtime": null,
"reqs": [],
"arch": ["aarch64"]
}
}
EOF
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<gameList>
<game>
<path>./$LAUNCHER_NAME</path>
<name>gen1recomp</name>
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
<releasedate>20250101T000000</releasedate>
<developer>the bois club</developer>
<publisher>the bois club</publisher>
<genre>RPG</genre>
</game>
</gameList>
EOF
cat > "$PORT_ROOT/README.md" <<'EOF'
## gen1recomp (RG34XXSP / Anbernic stock OS)
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP (H700) 64-bit stock OS
with PortMaster.
### Install
1. Use **64-bit Stock OS** (or Stock OS Mod) with PortMaster installed.
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
### First run
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
**Choose ROM** — the game scans that folder. After import, the ROM-derived
cache and saves stay beside the game (`portable.txt`).
Canonical SHA-1 (1 MiB US carts only):
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
### Controls
Physical controls map through PortMaster / SDL. Rebind in-game under
OPTIONS → CONTROLS.
### Thanks
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
EOF
# --------------------------------------------------------------- zip
ZIP_OUT="$DIST/$APP_NAME-rg34xxsp.zip"
rm -f "$ZIP_OUT"
say "packing $ZIP_OUT"
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
say "done."
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
say "copy onto the RG34XXSP SD card under roms/PORTS/, then drop your .gb into gen1recomp/lovegame/"
+6 -1
View File
@@ -13,7 +13,12 @@ function love.conf(t)
_G.POKEPORT_DEV_MODE = developer
if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
-- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an
-- empty directory in a packaged build, so `--editor` could not find
-- data/generated at all (SaveIO.defaultPath already assumed this name).
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
t.window.title = "Pokemon Save Editor"
t.window.width = 1280
t.window.height = 800
+1 -1
View File
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
## Map scripts
+76
View File
@@ -163,6 +163,7 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
SuperPalettes + per-species mon colors)
@@ -183,6 +184,36 @@ void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
## Widescreen battle layout
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
which gives battles a 304×144 native-pixel surface and a Gen 3-style
arrangement on it:
- the foe's status box upper left, the foe's picture upper right;
- the player's picture lower left, the player's status box lower right,
with a longer HP bar and the numeric HP under it;
- a full-width message window;
- a split "What will X do?" prompt / 2×2 command window;
- a 2×2 move menu, navigated with all four directions, with a PP and type
panel attached to its right.
Only the composition changes. Pictures, palettes, HP-bar colors, font
pages, window borders, sounds, animations, timing and every battle rule
stay the engine's, so a COLORS mode or an asset mod still owns the look.
Each side's picture keeps its original pixels and placement math and is
composited into its own region of the wider battlefield -- nothing is
scaled or squeezed -- and animations, which are authored in the original
160-pixel space, shift as one rigid group onto whichever side they play
on. The whole screen is drawn at the window's integer fit scale for the
wider surface, so a 304-pixel screen is drawn a step smaller than a
160-pixel one in the same window.
The wide surface is live only while the battle itself is the screen on
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
brings the classic surface back with it.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
@@ -230,3 +261,48 @@ be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them.
See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher)
The save editor ships inside every build instead of being a developer-only
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
Delete on every slot that actually holds a save. Edit suspends the
launcher, opens that slot's file in the editor, and **Close** hands the
process back to the launcher with the slot list re-read (a rename, a badge
or a dex change shows up on the row immediately). Unsaved edits arm a
confirm first, so leaving cannot lose work. `love . --editor` still opens
it standalone, where Close quits instead; `--save <path>` points it at any
file, and a save can be dragged onto the window.
The editor now wears the launcher's visual language - the same navy radial
field, 16px translucent cards, tri-colour version rail and green/yellow/red
semantics - so the two windows read as one app. Six tabs:
- **Party**: the roster with sprites, HP bars and level chips on the left,
and the mon inspector permanently docked on the right instead of floating
over the list. Species, level, DVs and moves all round-trip through the
Gen 1 formulas, so the inspector can never show illegal stats.
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
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
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.
- **Map**: any map rendered with the game's own renderer, warps followable,
and the player / lastHeal / lastOutdoor spawn points settable by clicking
a cell. Setting lastOutdoor on a map the game would not accept as an
outdoor source is refused with the reason.
- **Dex**: seen / owned completion meters and a four-column grid; owning
implies seen and un-seeing clears owned, exactly as the game requires.
Two rules run through all of it. Every mutation goes through one funnel
that sets the dirty flag and writes the status line together, so nothing
changes silently and no branch can quietly no-op - "Party is full", "Bag is
full", "click a cell first" all say so. And every destructive verb (Remove,
Release, Clear all, Wipe dex) arms on the first click and commits on the
second, relabelling itself to `Confirm?` in between.
A validation pill in the tab rail mirrors what the running game would
quarantine on load; clicking it jumps to the tab holding the first problem.
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# After first boot of the freshly flashed Stock OS Mod, reinsert the SD card
# and run this to install gen1recomp + Red/Blue ROMs into roms/PORTS.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
STAGE="$ROOT/.bazinga/work/rg34xxsp-install"
DECPREP="$(cd "$ROOT/../decprep" && pwd)"
ZIP="$ROOT/dist/rg34xxsp/gen1recomp-rg34xxsp.zip"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Find the Anbernic user/ROMs volume (usually "NO NAME" after first-boot expand).
find_roms_root() {
local v candidate
for v in /Volumes/*; do
[ -d "$v" ] || continue
# Prefer a volume that already has Roms/ or PORTS/
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ]; then
echo "$v"
return 0
fi
done
# Fallback: large FAT volume named NO NAME on disk8
for v in "/Volumes/NO NAME" /Volumes/NO\ NAME /Volumes/ROMS /Volumes/EASYROMS; do
if [ -d "$v" ]; then
echo "$v"
return 0
fi
done
return 1
}
say "looking for Anbernic ROMs volume"
ROMS_ROOT="$(find_roms_root)" || fail "no ROMs volume mounted. Boot the RG34XXSP once (wait for first-boot setup), power off, reinsert the SD, then rerun."
say "using: $ROMS_ROOT"
# Resolve PORTS dir (stock uses Roms/PORTS)
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
PORTS="$ROMS_ROOT/Roms/PORTS"
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
PORTS="$ROMS_ROOT/roms/PORTS"
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
PORTS="$ROMS_ROOT/Roms/ports"
elif [ -d "$ROMS_ROOT/PORTS" ]; then
PORTS="$ROMS_ROOT/PORTS"
else
mkdir -p "$ROMS_ROOT/Roms/PORTS"
PORTS="$ROMS_ROOT/Roms/PORTS"
fi
say "PORTS: $PORTS"
# Refresh staged payload
mkdir -p "$STAGE/PORTS"
if [ -f "$ZIP" ]; then
rm -rf "$STAGE/PORTS/Gen1recomp.sh" "$STAGE/PORTS/gen1recomp" "$STAGE/PORTS/port.json" \
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
else
fail "missing $ZIP — run ./build-rg34xxsp.sh first"
fi
# Ensure ROMs are in lovegame (Choose ROM scans this folder on stock OS)
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
say "copying gen1recomp port"
rm -rf "$PORTS/gen1recomp" "$PORTS/Gen1recomp.sh"
cp -R "$STAGE/PORTS/gen1recomp" "$PORTS/"
cp -f "$STAGE/PORTS/Gen1recomp.sh" "$PORTS/"
cp -f "$STAGE/PORTS/port.json" "$PORTS/gen1recomp/" 2>/dev/null || true
cp -f "$STAGE/PORTS/README.md" "$PORTS/gen1recomp/" 2>/dev/null || true
chmod +x "$PORTS/Gen1recomp.sh" "$PORTS/gen1recomp/bin/love.aarch64"
# Also drop carts in the stock GB folder for the emulator library
GB_DIR=""
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
done
if [ -n "$GB_DIR" ]; then
say "copying .gb into $GB_DIR"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
fi
sync
say "installed:"
ls -lh "$PORTS/Gen1recomp.sh"
ls -lh "$PORTS/gen1recomp/lovegame/"*.gb
say "eject the SD, insert TF1 in the RG34XXSP, open Ports → Gen1recomp, Choose ROM."
+116 -7
View File
@@ -1,8 +1,12 @@
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
-- game-data cache from a user-provided ROM on first boot.
--
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
-- editor tool (tools/save-editor/) instead of the game.
-- The save editor (tools/save-editor/) ships inside every build and is
-- reachable two ways:
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
-- * from the launcher: Edit on a save row, which suspends the launcher,
-- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below)
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
@@ -31,6 +35,105 @@ local function scriptedIterations()
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
-- ------------------------------------------------------------ save editor
-- The launcher instance parked while the editor is up, plus the version whose
-- cache the editor mounted (so closing can put the read path back).
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
-- live inside the .love archive, which the stock Lua searcher cannot open.
local function addEditorRequirePath()
local fs = love.filesystem
if not (fs.setRequirePath and fs.getRequirePath) then
-- very old LOVE: a source checkout still resolves through package.path
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
return
end
local current = fs.getRequirePath()
if current:find("tools/save%-editor") then return end
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
.. current)
end
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
-- design size, so grow it while editing and put it back on Close. Never
-- shrinks, never touches a fullscreen or mobile window.
local function resizeForEditor()
if not (love.window and love.window.getMode and love.window.setMode) then return end
local osName = love.system.getOS()
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
local w, h, flags = love.window.getMode()
if flags.fullscreen then return end
local dw, dh = love.window.getDesktopDimensions()
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
if wantW <= w and wantH <= h then return end
editorWindow = { w = w, h = h }
love.window.setMode(wantW, wantH, flags)
end
local function restoreWindow()
if not editorWindow then return end
local _, _, flags = love.window.getMode()
love.window.setMode(editorWindow.w, editorWindow.h, flags)
editorWindow = nil
end
-- Open the editor on a launcher save row. The version's cache has to be
-- mounted before the editor's Data:load runs, or a Blue save would be edited
-- against Red's species/item tables.
local function openEditor(version, slotId)
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
if Importer then
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] =
{ ok = false, text = "Could not resolve that save slot on disk." }
end
return
end
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version)
require("src.import.CacheFs").mountVersion(version)
editorVersion = version
editorHost = Importer
Importer = nil
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end })
end
-- Back to the launcher. Everything the editor mounted or cached has to come
-- back out: the version overlay (CacheFs) and the generated modules require
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
-- with this one's data.
function closeEditor()
local version = editorVersion
editorMode = false
if EditorApp and EditorApp.unload then EditorApp.unload() end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
editorVersion = nil
restoreWindow()
Importer = editorHost
editorHost = nil
if Importer and version and Importer.savesChanged then
Importer:savesChanged(version)
end
end
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
@@ -81,12 +184,17 @@ function love.load(args)
end
love.graphics.setDefaultFilter("nearest", "nearest")
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
-- cache has to be mounted before the editor's Data:load.
if editorMode then
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
local version = os.getenv("POKEPORT_VERSION") or "red"
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(savePath)
EditorApp.load(savePath, { version = version })
return
end
@@ -127,10 +235,11 @@ function love.load(args)
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
-- Edit on a save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport })
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
end
function love.update(dt)
+13 -1
View File
@@ -56,16 +56,28 @@ done
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# --------------------------------------------------------------- game.love
# tools/save-editor is part of the shipped app, not a dev-only script: the
# launcher's Edit button on a save row opens it in-process (main.lua), and
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
# love.filesystem's require path, so it has to live inside the archive.
say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
# The editor is only reachable if its entry point and both module directories
# made it in; a silent miss would ship a launcher whose Edit button crashes.
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua; do
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|| fail "game.love is missing $required (save editor would not load)"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version
+6 -1
View File
@@ -137,14 +137,19 @@ pack_game_love() {
say "packing game.love for love-android embed flavor"
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
+6 -2
View File
@@ -164,15 +164,19 @@ pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only.
# Same payload as scripts/build.sh / build_android.sh: game sources plus
# tools/save-editor, which the launcher's Edit button opens in-process.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
+6
View File
@@ -116,7 +116,13 @@ if [ -f data/generated/maps.lua ]; then
echo "-- T3 content: skipped (--quick)"
else
run_tier "T3 content behavior (Red)" run_content_behavior
# The save editor ships inside every build (the launcher's Edit button on
# a save row opens it), so its panel suites run in CI rather than by hand.
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi
else
+67 -11
View File
@@ -27,6 +27,7 @@ local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local WideBattle = require("src.battle.WideBattle")
local BattleState = {}
BattleState.__index = BattleState
@@ -35,9 +36,33 @@ BattleState.isOpaque = true
-- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
-- differs; every battler, queue and animation below is shared. The wide
-- layout is live only while this battle is the state being drawn on top --
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
-- goes back with it and the battle underneath is not drawn at all.
function BattleState:wideLayout()
local options = self.game and self.game.save and self.game.save.options
if not options or options.battleLayout ~= "wide" then return false end
local stack = self.game.stack
return (stack and stack.top and stack:top()) == self
end
-- Renderer:setUISize asks the top state for its surface before anything draws
function BattleState:uiSize()
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
return 160, 144
end
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
-- SGB whole-screen remap must not run over it.
function BattleState.sgbPalettes() return nil end
-- SGB whole-screen remap must not run over it. The wide layout still
-- needs a zone list of its own: the invented 160x144 one would leave its
-- extra columns unremapped in the forced-mono modes (WideBattle.zones).
function BattleState:sgbPalettes()
if self:wideLayout() then return WideBattle.zones() end
return nil
end
local Rulesets = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
@@ -1423,7 +1448,14 @@ function BattleState:update(dt)
if self.phase == "moveSelect" then
local moves = self.player.curMoves
if input:wasPressed("up") then
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
-- four directions navigate it; nil means no direction was pressed and
-- A / B / SELECT below behave the same in either layout.
local grid = self:wideLayout()
and WideBattle.navigate(self.moveIndex, #moves, input)
if grid then
self.moveIndex = grid
elseif input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
@@ -1465,7 +1497,13 @@ function BattleState:update(dt)
-- (core.asm:2553-2557), so there is no backing out with B.
if self.phase == "mimicSelect" then
local moves = self.mimicMoves
if input:wasPressed("up") then
-- the copy menu shares the widescreen move grid, so it navigates the
-- same way there (the classic layout keeps the vertical list)
local grid = self:wideLayout()
and WideBattle.navigate(self.mimicIndex, #moves, input)
if grid then
self.mimicIndex = grid
elseif input:wasPressed("up") then
self.mimicIndex = self.mimicIndex > 1 and self.mimicIndex - 1 or #moves
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
@@ -4038,7 +4076,12 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
-- while an SE effect displaces the pic, confine it to its side's
-- tile window like the GB tilemap does (the pic can never overwrite
-- the HUD columns or the text box rows)
-- ...except under the widescreen layout, where the side's own region
-- scissor is already that window on a battlefield the classic tile
-- columns do not describe (an 88..160 clip would fall entirely outside
-- the enemy's region and erase the pic).
local clip = love.graphics.setScissor and love.graphics.intersectScissor
and not self.wideRegion
local scx, scy, scw, sch
if clip then
scx, scy, scw, sch = love.graphics.getScissor()
@@ -4396,15 +4439,21 @@ end
-- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy)
-- onlySide ("player" / "enemy") draws one side's pic alone, and
-- skipMenuClip drops the move-menu row clip below: the widescreen layout
-- composites each side into its own region of a taller battlefield, where
-- neither the other side's pixels nor the classic menu rows apply.
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
-- The move-select boxes are BG tiles on the GB, so they REPLACE the
-- player pic's rows: the TYPE/PP box at (0,8) (PrintMenuItem) wipes
-- pic rows 8+, and Mimic's copy menu at (0,7) (MoveSelectionMenu
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
-- layer in the colorized pipeline, so clip them to the visible rows.
local g = love.graphics
local clipY = self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64 or nil
local clipY = not skipMenuClip
and (self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64)
or nil
local clipped, cs1, cs2, cs3, cs4
if clipY and g.getScissor and g.intersectScissor then
cs1, cs2, cs3, cs4 = g.getScissor()
@@ -4412,14 +4461,15 @@ function BattleState:drawPicsLayer(slide, sx, sy)
clipped = true
end
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
if self.showEnemyTrainer and self.trainerPic then
if onlySide ~= "player" and self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
-- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317)
love.graphics.draw(img, ex + self:picOffset("foe"), ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
elseif onlySide ~= "player"
and self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
@@ -4448,7 +4498,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then
if onlySide ~= "enemy" and self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic)
@@ -4464,7 +4514,8 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- picOffset: SlideTrainerPicOffScreen walking the back pic off the left
love.graphics.draw(img, dx + slide + sx + self:picOffset("back"),
dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer
elseif onlySide ~= "enemy"
and self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
love.graphics.setColor(1, 1, 1, 1)
@@ -4747,6 +4798,11 @@ function BattleState:drawTextArea()
end
function BattleState:draw()
if self:wideLayout() then return WideBattle.draw(self) end
return self:drawClassic()
end
function BattleState:drawClassic()
-- AskName: ClearSprites + wild ClearScreenArea -- white field under the
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
if self.blankForAskName then
+379
View File
@@ -0,0 +1,379 @@
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
--
-- The battle simulation, timing, animations and rules stay BattleState's;
-- this module only replaces the composition and asks the renderer for a
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
-- border glyphs, species palettes and HP tiles all resolve through the
-- engine, so a COLORS mode or an asset mod still owns the look.
--
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
-- upper left with its picture upper right, the player's picture lower left
-- with their status lower right, a full-width message window, a split
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
local Font = require("src.render.Font")
local HudTiles = require("src.render.HudTiles")
local PaletteFX = require("src.render.PaletteFX")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local TypeChart = require("src.battle.TypeChart")
local WideBattle = {
WIDTH = 304,
HEIGHT = 144,
-- everything above this line is battlefield; the 40 rows below it are
-- the message / command / move windows
FIELD_BOTTOM = 104,
}
-- The forced-mono display modes re-threshold the whole finished frame
-- through the shade shader, and picImage hands them raw DMG grays for that
-- (#207). The wide layout has to know: it exposes a matching whole-surface
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
local function monoMode()
local m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic"
end
local function shownHP(battler)
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
end
-- a name truncated to `pixels` with a trailing '.', measured through the
-- font's own advances so a variable-width page still fits
local function fitName(text, pixels)
local spans = Font.split(text or "")
local n = Font.spansFitting(spans, pixels)
if n >= #spans then return text or "" end
local out = {}
for i = 1, math.max(0, n - 1) do
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
end
return table.concat(out) .. "."
end
local function saveScissor()
if not love.graphics.getScissor then return nil end
local x, y, w, h = love.graphics.getScissor()
if x == nil then return false end
return { x, y, w, h }
end
local function restoreScissor(saved)
if not love.graphics.setScissor then return end
if saved and saved ~= false then
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
else
love.graphics.setScissor()
end
end
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
-- The scissor is in canvas space, so it bounds the region itself while the
-- translate moves the classic 160x144 coordinates into it.
local function inRegion(x, y, w, h, dx, dy, fn)
local g = love.graphics
local saved = saveScissor()
g.setScissor(x, y, w, h)
g.push()
g.translate(dx, dy)
fn()
g.pop()
restoreScissor(saved)
end
local function levelAt(battle, battler, x, y)
if battler.shownStatus then
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
else
HudTiles.tile(0x6E, x, y) -- '<LV>'
Font.draw(tostring(battler.mon.level), x + 8, y)
end
end
-- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original).
local function drawStatusPanel(battle, battler, x, y, player)
local tx, ty = math.floor(x / 8), math.floor(y / 8)
local tw, th = player and 15 or 16, player and 5 or 4
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
local nameWidth = player and 64 or 80
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler),
stats = battler.mon.stats,
}, nil, monoMode(), tw - 5)
if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
-- out to the wide screen's own corners
local function drawIntroBalls(battle)
if not battle.introBalls then return end
if battle.enemyParty and
(battle.kind == "trainer" or battle.kind == "link") then
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
end
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
end
local function drawHUDs(battle, slide)
if battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
drawStatusPanel(battle, battle.enemy, 0, 0, false)
end
if battle.safari then
Font.drawBox(23, 7, 15, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
elseif battle.player and not battle.demo and not battle.showPlayerBack
and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
end
local function drawMessageBox(battle)
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
if battle.scrollPx and battle.scrollPx > 0 then
battle.scrollPx = battle.scrollPx - 2
if battle.scrollPx <= 0 then battle.scrollPx = nil end
end
local off = battle.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(battle.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
Font.drawCode(0xEE, 288, 132)
end
end
local function drawCommandMenu(battle)
local col = (battle.menuIndex - 1) % 2
local row = math.floor((battle.menuIndex - 1) / 2)
if battle.safari then
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BALLx"), 16, 112)
Font.draw(Strings("BAIT"), 168, 112)
Font.draw(Strings("THROW ROCK"), 16, 128)
Font.draw(Strings("RUN"), 168, 128)
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
return
end
-- the prompt on the left, the 2x2 commands on the right
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112) -- 'PK'
Font.drawCode(0xE2, 248, 112) -- 'MN'
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
end
local function drawMoveDetails(battle, move)
Font.drawBox(28, 13, 10, 5)
if not move then return end
local def = battle.data.moves[move.id]
if not def then return end
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
end
local function drawMoveGrid(battle, moves, selected)
-- The 8px font needs 28 tiles for two complete twelve-character move
-- names plus their cursors; the details panel gets the other ten.
Font.drawBox(0, 13, 28, 5)
love.graphics.setColor(0, 0, 0, 1)
for i, move in ipairs(moves or {}) do
local col = (i - 1) % 2
local row = math.floor((i - 1) / 2)
local x, y = col == 0 and 16 or 120, 112 + row * 16
local def = battle.data.moves[move.id]
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
end
local col = (selected - 1) % 2
local row = math.floor((selected - 1) / 2)
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
drawMoveDetails(battle, moves and moves[selected])
end
local function drawMoveMenu(battle)
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
if battle.moveSwapIndex then
local col = (battle.moveSwapIndex - 1) % 2
local row = math.floor((battle.moveSwapIndex - 1) / 2)
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
end
end
local function drawTextArea(battle)
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
drawMessageBox(battle)
elseif battle.phase == "menu" then
drawCommandMenu(battle)
elseif battle.phase == "moveSelect" then
drawMoveMenu(battle)
elseif battle.phase == "mimicSelect" then
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
else
Font.drawBox(0, 13, 38, 5)
end
end
-- Battle animations are authored in the original 160px coordinate space.
-- Shift each complete OAM frame as one rigid group between the new player
-- and enemy anchors: drawing the whole animation through both side regions
-- would duplicate any tiles overlapping the other side's source range (most
-- visibly the send-out POOF reappearing on the far right).
function WideBattle.animationOffset(sprites)
if not sprites or #sprites == 0 then return 0, 0 end
local minX, maxX = math.huge, -math.huge
for _, sprite in ipairs(sprites) do
minX = math.min(minX, sprite.x - 8)
maxX = math.max(maxX, sprite.x)
end
local center = (minX + maxX) / 2
local t = math.max(0, math.min(1, (center - 40) / 80))
return math.floor(20 + 116 * t + 0.5),
math.floor(8 * (1 - t) + 0.5)
end
local function currentAnimationSprites(battle)
if battle.animPlaying and battle.animPlayer then
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
return step and step.sprites
end
if battle.lockedBall and battle.animPlayer then
return battle.lockedBall
end
end
local function drawAnimationLayer(battle)
local sprites = currentAnimationSprites(battle)
if not sprites or #sprites == 0 then return end
local dx, dy = WideBattle.animationOffset(sprites)
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
function() battle:drawAnimLayer(false) end)
end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
-- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it.
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
local fx = battle.fx
local sx = (fx and fx.shakeX) or 0
local sy = (fx and fx.shakeY) or 0
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2
end
local slide = (battle.introSlide or 0) * 4
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
drawAnimationLayer(battle)
drawTextArea(battle)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
g.setColor(1, 1, 1, 1)
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, battle)
end
end
-- The palette zones for the wide surface. The composition already resolves
-- species colors, paper shade and HP-bar colors itself, so the colorized
-- modes take the trueColor opt-out (`colors = false`) over the whole
-- surface; the forced-mono modes still want their whole-screen remap, and
-- get one sized to the wide surface instead of the 160x144 rectangle
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
function WideBattle.zones()
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
if monoMode() then
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
-- the inverted permutation), exactly as it does for ensureZones' zone
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
end
return { { colors = false, x = 0, y = 0, w = w, h = h } }
end
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
-- and a direction pointing at an empty slot holds the current one.
function WideBattle.moveGridIndex(index, count, direction)
if count < 1 then return nil end
local row = math.floor((index - 1) / 2)
local col = (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local otherRow = 1 - row
local other = otherRow * 2 + col + 1
return other <= count and other or index
end
local DIRECTIONS = { "left", "right", "up", "down" }
-- the slot a directional press selects, or nil when none was pressed (the
-- caller then runs its normal list navigation / A / B / SELECT handling)
function WideBattle.navigate(index, count, input)
for _, key in ipairs(DIRECTIONS) do
if input:wasPressed(key) then
return WideBattle.moveGridIndex(index, count, key)
end
end
return nil
end
return WideBattle
+16 -4
View File
@@ -206,10 +206,15 @@ function Data:load()
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
-- Drop every namespace the mod merge created and evict the generated modules
-- from package.loaded, so the next load() re-reads them off disk instead of
-- handing back the cached tables. Two callers:
-- * reloadGenerated below (dev hot reload)
-- * main.lua, when the launcher closes the save editor -- the editor may
-- have loaded the OTHER game's cache, and require would otherwise serve
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
-- clears the matching read-path overlay).
function Data:unloadGenerated()
local pristine = self._pristineKeys
if pristine then
for key in pairs(self) do
@@ -222,6 +227,13 @@ function Data:reloadGenerated()
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
end
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
self:unloadGenerated()
self:load()
end
+10
View File
@@ -238,6 +238,16 @@ function Game:draw()
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
-- The UI surface is resolved once, before any state draws: the top state
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
-- asks for 304x144). Anything else keeps the classic surface, so a menu
-- pushed over a wide battle brings the screen straight back to 160x144.
local top = self.stack:top()
if top and top.uiSize then
Renderer:setUISize(top:uiSize())
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
Renderer:beginFrame(worldBelow)
self.stack:draw()
-- SGB colorization: the topmost state that knows its palette owns the
+22
View File
@@ -194,6 +194,9 @@ function SaveData.defaultOptions()
textSpeed = 3,
animations = true,
battleStyle = "shift",
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -443,6 +446,25 @@ function SaveData.slotSummary(save)
}
end
-- The absolute on-disk path of a slot's save file, for the one caller that
-- cannot go through love.filesystem: the save editor reads and writes with
-- raw io.* so it can also open a file the player dragged in from anywhere.
-- Resolves against the same root persistFs would write to -- the portable
-- game folder when portable mode is on, otherwise LOVE's save directory --
-- so Edit on a launcher save row lands on the file the game actually plays.
-- nil when neither root is available (headless tests with an injected fs).
function SaveData.slotDiskPath(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) or not slotId then return nil end
local base = SaveData.portableBaseDir()
or (love and love.filesystem and love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory())
if not base then return nil end
local sep = package.config:sub(1, 1)
local rel = select(1, slotNames(version, slotId))
return base .. sep .. rel:gsub("/", sep)
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
+62
View File
@@ -148,6 +148,37 @@ local function mountReadable(dir, append)
return fn(dir, append)
end
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
-- CacheFs.unmountVersion needs it: the launcher can open the save editor on
-- one game's cache and then Play the other, and an overlay left mounted
-- would win the read path for the rest of the process.
local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_unmount end)
if oks and fn then
physfsUnmountFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= 0
end
break
end
end
end
return physfsUnmountFn
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
@@ -323,4 +354,35 @@ function CacheFs.mountVersion(version)
return false
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue save, close
-- it, and press Play on Red: with blue/ still prepended, Red's
-- require("data.generated.*") and its generated art would silently resolve to
-- Blue's files. Callers must also drop the generated modules from
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
-- fixes the read path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end
local sub = prefix:gsub("/+$", "")
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
local done = false
local fn = resolveUnmount()
if fn and base then
done = fn(base .. SEP .. sub) or done
end
-- also drop the love.filesystem.mount fallback, which registers the folder
-- under its bare name rather than its absolute path
if love.filesystem.unmount then
done = love.filesystem.unmount(sub) or done
end
return done
end
return CacheFs
+78 -9
View File
@@ -474,7 +474,10 @@ end
-- own cache (Red at the root, Blue under blue/), so both can be imported and
-- played side by side. onComplete(version) hands the chosen game off to boot.
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- forceImport (treat every version as not-yet-imported, so re-import is forced).
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
-- row -- main.lua opens the bundled save editor on that slot; when it is not
-- supplied the Edit label is not drawn at all).
function RomImporter.new(onComplete, opts)
opts = opts or {}
local android = love.system.getOS() == "Android"
@@ -483,6 +486,7 @@ function RomImporter.new(onComplete, opts)
onComplete = onComplete,
launcher = opts.launcher or false,
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
android = android,
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
logo = love.graphics.newImage("assets/logo/logo.png"),
@@ -970,9 +974,30 @@ function RomImporter:choose(version)
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then
self:startPath(path)
elseif love.system.getOS() ~= "OS X"
and love.system.getOS() ~= "Windows"
and love.system.getOS() ~= "Linux" then
return
end
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
-- kdialog. Fall back to the same "drop a .gb next to the game" scan used
-- on Android, which works when the game is launched as an unpacked
-- directory (see build-rg34xxsp.sh).
local name, data = findPendingRom(self.ready)
if name then
self:startData(data, name)
return
end
if love.system.getOS() == "Linux" then
local where = love.filesystem.getSourceBaseDirectory
and love.filesystem.getSourceBaseDirectory()
or love.filesystem.getSource and love.filesystem.getSource()
or "the game folder"
self.notice = {
version = self.chooseVersion,
status = "No file picker. Copy your .gb into:",
detail = where,
}
return
end
if love.system.getOS() ~= "OS X" and love.system.getOS() ~= "Windows" then
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
end
end
@@ -1202,6 +1227,7 @@ function RomImporter:draw()
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
-- version with no panel drawn this frame) cannot inherit last frame's rows.
self.slotRects = nil
self.slotEditRects = nil
self.newSlotRect = nil
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
-- last frame's mod toggles / import button.
@@ -1619,17 +1645,24 @@ function RomImporter:mousepressed(x, y, button)
end
return
end
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- label never also selects the row. On desktop a press only ARMS a click:
-- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
-- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap
-- on either never also selects the row. On desktop a press only ARMS a row
-- click: _updateSlotDrag commits it on release when the pointer did not move
-- (a moved pointer scrolls instead). Android has no reliable pointer
-- polling, so it selects on press. Edit and Delete fire immediately (small
-- fixed targets, no scroll conflict).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotEditRects or {}) do
if inside(r, x, y) then
if self.onEditSave then self.onEditSave(self.panelVersion, r.id) end
return
end
end
for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then
if self.android then
@@ -2091,6 +2124,13 @@ function RomImporter:_ensureSlots(version)
if not self.slots[version] then self:_refreshSlots(version) end
end
-- The host calls this when the save editor closes: the edited slot's player
-- name, badge count and dex total all feed the cached row summary, so it has
-- to be re-read rather than trusted across the round trip.
function RomImporter:savesChanged(version)
self:_refreshSlots(version)
end
-- Point the active slot at id (persisted immediately, per the contract) and
-- reflect it in the LOADED pill without a full relist.
function RomImporter:_selectSlot(version, id)
@@ -2208,6 +2248,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
self.slotEditRects = {}
elseif listH > 0 then
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
@@ -2227,6 +2268,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects = {}
self.slotDeleteRects = {}
self.slotEditRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do
@@ -2253,6 +2295,24 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- Edit label, immediately left of Delete: opens the bundled save
-- editor (tools/save-editor) on this slot's file. Only drawn when the
-- host supplied onEditSave and the slot actually holds a save -- there
-- is nothing to edit in an empty slot, and offering it would open the
-- editor on a new-game stub the player never asked for.
local erect = nil
if self.onEditSave and slot.exists then
local edText = "Edit"
local edW = self.hintFont:getWidth(edText)
local edX = delX - 14 * s - edW
erect = { x = edX - 6 * s, y = delY - 4 * s,
width = edW + 12 * s, height = delH + 8 * s, id = slot.id }
local ehot = self:_hover(erect)
col(ehot and PAL.blue or PAL.warning)
love.graphics.print(edText, edX, delY)
rightReserve = rightReserve + edW + 20 * s
end
-- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0
if selected then
@@ -2301,6 +2361,15 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
if erect then
local evy = math.max(erect.y, listTop)
local evy2 = math.min(erect.y + erect.height, listBottom)
if evy2 > evy then
self.slotEditRects[#self.slotEditRects + 1] =
{ x = erect.x, y = evy, width = erect.width, height = evy2 - evy,
id = slot.id }
end
end
end
end
love.graphics.setScissor()
+14 -5
View File
@@ -127,6 +127,11 @@ end
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red).
--
-- segments: how many 8px cells the bar spans (6, the hardware width,
-- unless a caller asks for more -- the widescreen battle layout has room
-- for a longer bar in the same tiles). The color thresholds scale with
-- it so a wider bar turns yellow and red at the same fractions of full.
--
-- grayFill (#229): when the caller will colorize this bar with an SGB
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
@@ -136,18 +141,22 @@ end
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end
local tint
if not grayFill then
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local green = math.ceil(27 * segments / 6)
local yellow = math.ceil(10 * segments / 6)
local name = px >= green and "GREENBAR"
or px >= yellow and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
@@ -157,11 +166,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
math.min(1, c[3] / 170), 1 }
end
end
for i = 0, 5 do
for i = 0, segments - 1 do
local seg = math.min(8, math.max(0, px - i * 8))
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
end
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
end
return HudTiles
+38 -5
View File
@@ -15,8 +15,15 @@ local Runtime = require("src.mods.Runtime")
local Renderer = {}
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
-- screen is laid out in; uiWidth/uiHeight are the surface actually
-- allocated this frame, which a state may widen through setUISize (the
-- widescreen battle layout asks for 304x144). Anything drawing a normal
-- 160x144 screen can keep reading WIDTH/HEIGHT.
Renderer.WIDTH = 160
Renderer.HEIGHT = 144
Renderer.MAX_UI_WIDTH = 640
Renderer.MAX_UI_HEIGHT = 576
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
@@ -83,7 +90,8 @@ function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -115,7 +123,31 @@ end
-- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
local w, h = self:uiSize()
return math.max(1, math.floor(math.min(pw / w, ph / h)))
end
-- the native-pixel UI surface in use right now
function Renderer:uiSize()
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
end
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
-- only when the size actually changes, so the classic path never rebuilds
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
-- both ends -- never smaller than the Game Boy screen every layout assumes,
-- never large enough for a bad request to allocate an unbounded canvas.
function Renderer:setUISize(w, h)
if type(w) ~= "number" or type(h) ~= "number"
or w < self.WIDTH or h < self.HEIGHT
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
w, h = self.WIDTH, self.HEIGHT
end
w, h = math.floor(w), math.floor(h)
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
if self.canvas and self.canvas.release then self.canvas:release() end
self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest")
end
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
@@ -443,10 +475,11 @@ function Renderer:endFrame(zones, worldZones)
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
+1
View File
@@ -107,6 +107,7 @@ local function defaultsSave()
modData = {},
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
battleLayout = "og",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
+11
View File
@@ -142,6 +142,17 @@ local function buildRows(game)
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
return true
end },
-- OG is the classic 160x144 battle screen; WIDE is the 304x144
-- widescreen composition (src/battle/WideBattle.lua)
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
value = function(g)
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
end,
step = function(g)
local o = g.save.options
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
return true
end },
{ id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end,
step = function(g, dir)
+76
View File
@@ -0,0 +1,76 @@
-- Driver: BATTLE LAYOUT = WIDE. Opens a wild battle on the 304x144
-- surface, captures the command screen, the 2x2 move menu and a player-side
-- send-out POOF, and checks the surface is handed back on the way out.
-- POKEPORT_DRIVER=tests/drivers/wide_battle_test.lua \
-- POKEPORT_IDENTITY=wide POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local Renderer = require("src.render.Renderer")
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.options.battleLayout = "wide"
-- MEW's back pic is a large 2x sprite: it catches a side region drawn
-- twice, which would leak detached pieces of it into the far right.
game.save.party = { Pokemon.new(game.data, "MEW", 100) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
local battle = BattleState.newWild(game, "PIDGEY", 3, { onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(360)
check("the battle is on top", getmetatable(game.stack:top()) == BattleState)
check("the battle asked for the 304x144 surface",
Renderer.uiWidth == 304 and Renderer.uiHeight == 144)
battle.introSlide = 0
battle.introBalls = nil
battle.showEnemyTrainer = false
battle.showPlayerBack = false
battle.enemySendingOut = false
battle.sendingOut = false
battle.phase = "menu"
battle.menuIndex = 1
U.wait(2)
check("command screenshot", U.shot(game, DIR .. "/wide_command.png"))
battle.phase = "moveSelect"
battle.moveIndex = 1
if #battle.player.curMoves >= 2 then
U.tap(game, "right")
U.wait(2)
check("RIGHT crosses the move grid", battle.moveIndex == 2)
end
U.wait(2)
check("move menu screenshot", U.shot(game, DIR .. "/wide_moves.png"))
-- Freeze a player-side POOF on a populated frame: it must composite once,
-- at the player anchor, and never repeat on the right.
battle.phase = "messages"
battle.current = nil
battle.animPlayer:start("POOF_ANIM", false)
battle.animPlayer.stepIndex = 3
battle.animPlaying = true
check("send-out POOF screenshot", U.shot(game, DIR .. "/wide_poof.png"))
battle.animPlaying = false
game.stack:pop()
-- a fast driver resumes several logic steps per rendered frame; leave
-- enough yields for the next Game:draw to resolve the overworld surface
U.wait(12)
check("leaving the battle restores the Game Boy surface",
Renderer.uiWidth == 160 and Renderer.uiHeight == 144)
U.log(failures == 0 and "WIDE_BATTLE_PASS" or "WIDE_BATTLE_FAIL",
("surface=%dx%d"):format(Renderer.uiWidth, Renderer.uiHeight))
love.event.quit(failures == 0 and 0 or 1)
end
+56
View File
@@ -0,0 +1,56 @@
-- BATTLE LAYOUT = WIDE (src/battle/WideBattle.lua): the surface it asks
-- for, the 2x2 move-grid navigation, and the rigid per-frame offset that
-- moves an animation authored in 160px space onto one of the two anchors.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local WideBattle = require("src.battle.WideBattle")
local Renderer = require("src.render.Renderer")
T.eq(WideBattle.WIDTH, 304, "the wide layout runs on a 304px native surface")
T.eq(WideBattle.HEIGHT, 144, "the wide surface keeps the native height")
T.eq(WideBattle.FIELD_BOTTOM, 104,
"the lower 40 rows are the message / command windows")
-- move grid: slots are laid out 1 2 / 3 4
T.eq(WideBattle.moveGridIndex(1, 4, "right"), 2, "RIGHT crosses the row")
T.eq(WideBattle.moveGridIndex(2, 4, "left"), 1, "LEFT crosses the row")
T.eq(WideBattle.moveGridIndex(1, 4, "down"), 3, "DOWN crosses the column")
T.eq(WideBattle.moveGridIndex(4, 4, "up"), 2, "UP crosses the column")
T.eq(WideBattle.moveGridIndex(3, 3, "right"), 3,
"an absent fourth move cannot be selected")
T.eq(WideBattle.moveGridIndex(1, 0, "right"), nil,
"an empty move list has nothing to navigate")
local function pressing(key)
return { wasPressed = function(_, k) return k == key end }
end
T.eq(WideBattle.navigate(1, 4, pressing("right")), 2,
"navigate maps a direction onto the grid")
T.eq(WideBattle.navigate(1, 4, pressing("a")), nil,
"navigate leaves A / B / SELECT to the battle engine")
-- animation frames shift as one rigid group toward the side they play on
local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } })
T.eq(px, 20, "a player-side frame lands on the player anchor")
T.eq(py, 8, "a player-side frame lands on the player baseline")
local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } })
T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor")
T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline")
-- the surface: a request outside the bounds falls back to the GB screen,
-- and the canvas is only reallocated when the size actually changes
Renderer:init()
T.eq(select(1, Renderer:uiSize()), 160, "the default surface is the GB screen")
Renderer:setUISize(WideBattle.WIDTH, WideBattle.HEIGHT)
local w, h = Renderer:uiSize()
T.eq(w, 304, "setUISize widens the surface")
T.eq(h, 144, "setUISize keeps the height")
Renderer:setUISize(64, 64)
T.eq(select(1, Renderer:uiSize()), 160,
"a surface smaller than the GB screen falls back")
Renderer:setUISize(99999, 99999)
T.eq(select(1, Renderer:uiSize()), 160, "an oversized surface falls back")
Renderer:setUISize(160, 144)
T.finish("wide battle layout")
+17 -1
View File
@@ -56,7 +56,23 @@ stub.graphics = {
return batch
end,
draw = noop, rectangle = noop, clear = noop,
setDefaultFilter = noop, print = noop,
setDefaultFilter = noop, print = noop, printf = noop,
line = noop, circle = noop, setLineWidth = noop,
-- Fonts: the save editor lays itself out from font metrics, so a headless
-- draw needs measurable text. A fixed 6px advance / 12px line is enough
-- for the layout to be exercised (tests assert state, never pixels).
-- newMesh / stencil stay absent on purpose: tools/save-editor/Theme.lua
-- probes for them and falls back to flat fills, which is the path a
-- headless run should take.
newFont = function(size)
local px = size or 12
return {
getWidth = function(_, text) return #tostring(text) * math.max(1, px * 0.5) end,
getHeight = function() return px end,
}
end,
setFont = function(f) gstate.font = f end,
getFont = function() return gstate.font end,
setColor = function(r, g, b, a) gstate.color = { r, g, b, a } end,
getColor = function()
local c = gstate.color
+3 -1
View File
@@ -290,7 +290,9 @@ do
end },
stack = { pop = function() end } }
local menu = OptionsMenu.new(game)
menu.index = 4
for i, row in ipairs(menu.rows) do
if row.id == "ruleset" then menu.index = i end
end
local function press(key)
pressed = { [key] = true }
menu:update(1 / 60)
+26 -20
View File
@@ -281,9 +281,9 @@ local function optGame()
}
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
"speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
@@ -292,11 +292,11 @@ end
-- ruleset row cycles the sorted non-hidden registry ids showing name
om.game.save.options.ruleset = "gen1_faithful"
check(om.rows[4].value(om.game) == "GEN 1", "ruleset row shows record.name")
om.rows[4].step(om.game, 1)
check(om.rows[5].value(om.game) == "GEN 1", "ruleset row shows record.name")
om.rows[5].step(om.game, 1)
check(om.game.save.options.ruleset == "modern_clean",
"ruleset row cycles sorted registry ids")
om.rows[4].step(om.game, 1)
om.rows[5].step(om.game, 1)
check(om.game.save.options.ruleset == "gen1_faithful",
"hidden rulesets are excluded from the cycle")
@@ -309,9 +309,15 @@ om.rows[2].step(om.game, 1)
check(om.game.save.options.animations == false, "animations toggles off")
om.rows[3].step(om.game, 1)
check(om.game.save.options.battleStyle == "set", "battle style flips to SET")
om.rows[5].step(om.game, -1)
check(om.rows[4].value(om.game) == "OG", "battle layout starts on the OG screen")
om.rows[4].step(om.game, 1)
check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE")
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
om.rows[4].step(om.game, 1)
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
om.rows[6].step(om.game, -1)
check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
for _ = 1, 10 do om.rows[6].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows
@@ -319,30 +325,30 @@ local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(om.rows[11].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[11].step(om.game, 1)
check(om.rows[12].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[12].step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
om.rows[12].step(om.game, 1)
om.rows[13].step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER")
om.rows[12].step(om.game, 1)
om.rows[13].step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
om.rows[12].step(om.game, 1)
om.rows[13].step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[14].value(om.game) == "60",
check(om.rows[15].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[14].step(om.game, 1)
om.rows[15].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[14].value(om.game) == "75", "the MAX FPS row renders the cap")
check(om.rows[15].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[14].step(om.game, 1)
om.rows[15].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[14].step(om.game, -1)
om.rows[15].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
@@ -372,7 +378,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[16].activate(mgGame)
om.rows[17].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -382,7 +388,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[17].activate(cbGame)
om.rows[18].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+191 -69
View File
@@ -3,11 +3,18 @@
-- (If lua5.4 is missing, use the same interpreter as tests/run_tests.lua.)
--
-- Panel suites (Boxes/Items, Events/Dex, Map) live in separate files so each
-- can define its own harness without colliding with this runner:
-- can define its own harness without colliding with this runner, and each is
-- its own tier in scripts/test.sh:
-- tests/save_editor_task6_tests.lua
-- tests/save_editor_task7_tests.lua
-- tests/save_editor_task8_tests.lua
-- tests/save_editor_mod_tests.lua
-- See tools/save-editor/README.md for the full list.
--
-- All of them drive tools/save-editor/Ops.lua rather than clicking pixel
-- coordinates: the panels are layout over Ops, so the rules live there and a
-- redesign cannot silently invalidate the suites (which is exactly what the
-- old coordinate-based tests did not survive).
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
@@ -172,13 +179,10 @@ do
check(s.dirty == true, "State.markDirty sets dirty")
end
-- Party/MonEditor panels: drive Kit's immediate-mode hit-testing by placing
-- the "mouse" at the exact coordinates each panel draws its widgets at
-- (mirroring the layout constants in panels/{Party,MonEditor}.lua), so the
-- click handlers run for real without a live window.
local Kit = require("Kit")
local Party = require("Party")
local MonEditor = require("MonEditor")
-- Party roster + the docked mon inspector. Both are pure layout over
-- tools/save-editor/Ops.lua, so the rules are asserted against Ops directly
-- instead of against pixel coordinates the design can (and did) move.
local Ops = require("Ops")
local Pokemon = require("src.pokemon.Pokemon")
do
@@ -191,74 +195,100 @@ do
S.save.party = { wartortle, pidgey }
S.selectedParty = 1
local px, py = 12, 80
Ops.selectParty(S, 2)
eq(S.selectedParty, 2, "selectParty selects the row")
check(S.editingMon == pidgey, "selectParty points the inspector at that mon")
Kit.beginFrame(px + 10, py + 24 + 22 + 5, true) -- row 2 of the list
Party.draw(S, Kit, px, py)
eq(S.selectedParty, 2, "Party list click selects row")
check(S.editingMon == pidgey, "Party list click sets editingMon")
Kit.beginFrame(px + 10, py + 200 + 10, true) -- Add button
Party.draw(S, Kit, px, py)
eq(#S.save.party, 3, "Party Add appends a mon")
check(S.dirty == true, "Party Add marks dirty")
Ops.partyAdd(S)
eq(#S.save.party, 3, "partyAdd appends a mon")
check(S.dirty == true, "partyAdd marks the save dirty")
S.dirty = false
S.selectedParty = 3
Kit.beginFrame(px + 110 + 10, py + 200 + 10, true) -- Remove button
Party.draw(S, Kit, px, py)
eq(#S.save.party, 2, "Party Remove drops selected mon")
check(Ops.partyRemove(S) == false, "partyRemove arms on the first call")
eq(#S.save.party, 3, "an armed partyRemove has not removed anything")
check(Ops.partyRemove(S) == true, "partyRemove commits on the second call")
eq(#S.save.party, 2, "the committed partyRemove drops the selected mon")
S.selectedParty = 2
Kit.beginFrame(px + 220 + 10, py + 200 + 10, true) -- Move Up button
Party.draw(S, Kit, px, py)
eq(S.selectedParty, 1, "Party Move Up updates selection")
check(S.save.party[1] == pidgey, "Party Move Up swaps order")
Ops.partyMove(S, -1)
eq(S.selectedParty, 1, "partyMove up follows the mon to its new slot")
check(S.save.party[1] == pidgey, "partyMove up swaps the two slots")
S.dirty = false
check(Ops.partyMove(S, -1) == false, "the lead mon cannot move further up")
check(S.dirty == false, "a refused partyMove does not dirty the save")
check(S.status:match("lead mon") ~= nil, "a refused partyMove explains itself")
-- a full party refuses another mon
while #S.save.party < require("src.pokemon.Party").MAX do
table.insert(S.save.party, MonOps.create(Data, "PIDGEY", 5))
end
S.dirty = false
check(Ops.partyAdd(S) == false, "partyAdd refuses a full party")
check(S.status:match("Party is full") ~= nil, "a refused partyAdd explains itself")
end
do
local S = State.new()
S.data = Data
S.cat = Catalog.build(Data)
S.save = SaveData.newGame()
local mon = MonOps.create(Data, "WARTORTLE", 20)
S.editingMon = mon
local mx, my = 640, 80
local levelBefore = mon.level
local hpStatBefore = mon.stats.hp
Kit.beginFrame(mx + 148 + 10, my + 84 + 10, true) -- "+1" level button
MonEditor.draw(S, Kit, mx, my)
eq(mon.level, levelBefore + 1, "MonEditor +1 level button")
check(mon.stats.hp >= hpStatBefore, "MonEditor level up recalcs stats")
check(S.dirty == true, "MonEditor level change marks dirty")
Ops.setLevel(S, mon, mon.level + 1)
eq(mon.level, levelBefore + 1, "setLevel raises the level")
check(mon.stats.hp >= hpStatBefore, "a level change recalculates stats")
check(S.dirty == true, "a level change marks the save dirty")
S.dirty = false
local dvY = my + 154
Ops.setLevel(S, mon, 999)
eq(mon.level, 100, "setLevel clamps at 100")
Ops.setLevel(S, mon, -5)
eq(mon.level, 1, "setLevel clamps at 1")
local attackBefore = mon.dvs.attack
Kit.beginFrame(mx + 160 + 5, dvY + 5, true) -- attack DV "+" button
MonEditor.draw(S, Kit, mx, my)
eq(mon.dvs.attack, math.min(15, attackBefore + 1), "MonEditor DV attack + button")
Ops.setDv(S, mon, "attack", attackBefore + 1)
eq(mon.dvs.attack, math.min(15, attackBefore + 1), "setDv adjusts a DV")
Ops.setDv(S, mon, "attack", 99)
eq(mon.dvs.attack, 15, "setDv clamps at 15")
Ops.setDv(S, mon, "attack", -1)
eq(mon.dvs.attack, 0, "setDv clamps at 0")
-- the HP DV is the parity nibble of the other four, never set directly
eq(mon.dvs.hp,
(mon.dvs.attack % 2) * 8 + (mon.dvs.defense % 2) * 4
+ (mon.dvs.speed % 2) * 2 + (mon.dvs.special % 2),
"setDv re-derives the HP DV from the other four")
local hpDvY = dvY + 4 * 30 + 6
local movesY = hpDvY + 34
local slot1Y = movesY + 24
local moveBefore = mon.moves[1] and mon.moves[1].id
Kit.beginFrame(mx + 10, slot1Y + 10, true) -- move slot 1
MonEditor.draw(S, Kit, mx, my)
check(mon.moves[1] ~= nil, "MonEditor move slot has a move after cycle")
check(mon.moves[1].id ~= moveBefore, "MonEditor move slot cycles to a different move")
Ops.cycleMove(S, mon, 1)
check(mon.moves[1] ~= nil, "cycleMove leaves a move in the slot")
check(mon.moves[1].id ~= moveBefore, "cycleMove moves on to a different move")
local actionsY = movesY + 24 + 4 * 30 + 10
Kit.beginFrame(mx + 10, actionsY + 10, true) -- Reset moves to learnset
MonEditor.draw(S, Kit, mx, my)
Ops.clearMove(S, mon, 1)
eq(mon.moves[1], nil, "clearMove empties the slot")
S.dirty = false
check(Ops.clearMove(S, mon, 1) == false, "clearing an empty slot is a no-op")
check(S.dirty == false, "a no-op clearMove does not dirty the save")
Ops.resetMoves(S, mon)
local def = Data.pokemon[mon.species]
local learned = Pokemon.movesAtLevel(def, mon.level)
eq(#mon.moves, #learned, "MonEditor reset moves matches learnset size")
eq(#mon.moves, #learned, "resetMoves matches the learnset size")
Kit.beginFrame(mx + 10, actionsY + 38 + 10, true) -- Close
MonEditor.draw(S, Kit, mx, my)
check(S.editingMon == nil, "MonEditor Close clears editingMon")
mon.hp = 1
Ops.healMon(S, mon)
eq(mon.hp, mon.stats.hp, "healMon restores full HP")
S.dirty = false
check(Ops.healMon(S, mon) == false, "healing an already-full mon is a no-op")
local speciesBefore = mon.species
Ops.stepSpecies(S, mon, 1)
check(mon.species ~= speciesBefore, "stepSpecies changes the species")
eq(mon.level, 1, "stepSpecies keeps the level")
end
-- App.load corrupt-save vs missing-save (Important fix #2): App.load takes
@@ -266,17 +296,12 @@ end
-- touching the real default save file.
local App = require("App")
-- App.draw() sources its click state from App.mousepressed() + the mouse
-- position at draw time (not from a Kit.beginFrame call made by the test),
-- so simulating a click means moving the mouse and pressing before drawing.
local appMouseX, appMouseY = 0, 0
love.mouse = { getPosition = function() return appMouseX, appMouseY end }
local function clickApp(x, y)
appMouseX, appMouseY = x, y
App.mousepressed(x, y, 1)
App.draw()
end
-- App.draw() reads the pointer at draw time, so a headless draw needs a mouse
-- module. Parked off-screen: these tests call App.save/App.reload/App.close
-- directly (the chrome is layout over those, exactly like the panels are
-- layout over Ops) and use App.draw only as a "does the whole editor still
-- paint" smoke test.
love.mouse = { getPosition = function() return -1, -1 end }
do
local tmpPath = os.tmpname() .. "-missing-save.lua"
@@ -301,9 +326,9 @@ do
eq(s.allowSave, false, "App.load corrupt-file: allowSave set false")
check(s.status:match("Corrupt save") ~= nil, "App.load corrupt-file status mentions corrupt save")
-- Clicking Save while loadError is set must be a no-op: file on disk
-- (the corrupt real save) must not be overwritten by the stub.
clickApp(110 + 10, 6 + 10) -- Save button
-- Save while loadError is set must be a no-op: the file on disk (the
-- corrupt real save) must not be overwritten by the stub we are editing.
App.save()
local unchanged = io.open(tmpPath, "rb")
local contents = unchanged:read("*a")
unchanged:close()
@@ -314,7 +339,7 @@ do
local fixed = io.open(tmpPath, "wb")
fixed:write(SaveData.encode(SaveData.newGame()))
fixed:close()
clickApp(200 + 10, 6 + 10) -- Reload button
App.reload()
eq(App.getState().loadError, false, "Reload after fixing the file clears loadError")
eq(App.getState().allowSave, true, "Reload after fixing the file re-enables allowSave")
@@ -322,21 +347,118 @@ do
end
do
-- Optional fix: quit-confirmation re-arms once new edits land, so a
-- prior "press quit again" arming doesn't leak across separate edits.
-- The quit / close confirmation re-arms once new edits land, so a prior
-- "press quit again" arming cannot be spent discarding later changes.
local tmpPath = os.tmpname() .. "-quitarmed-save.lua"
os.remove(tmpPath)
App.load(tmpPath)
local s = App.getState()
s._quitArmed = true
s.tab = "items"
clickApp(12 + 132 + 10, 80 + 22 + 10) -- Items panel "+10" money button
Ops.addMoney(s, 10)
eq(App.getState()._quitArmed, false, "A fresh dirty edit resets _quitArmed")
eq(App.getState()._openArmed, false, "A fresh dirty edit resets _openArmed")
os.remove(tmpPath)
end
do
-- Close: unsaved edits arm once, and the teardown itself is deferred to the
-- end of the frame -- doing it inline left the rest of App.draw painting
-- against a state that had already been unloaded.
local tmpPath = os.tmpname() .. "-close-save.lua"
local f = io.open(tmpPath, "wb")
f:write(SaveData.encode(SaveData.newGame()))
f:close()
local closed = 0
App.load(tmpPath, { version = "red", slotId = "slot1", embedded = true,
onClose = function() closed = closed + 1 end })
local s = App.getState()
Ops.addMoney(s, 10)
check(App.close() == false, "Close with unsaved edits arms instead of leaving")
eq(closed, 0, "an armed Close has not left yet")
check(s.status:match("Unsaved changes") ~= nil, "an armed Close explains itself")
check(App.close() == true, "a second Close goes through")
eq(closed, 0, "Close does not tear down mid-dispatch")
check(s._closeRequested, "Close records the request for the end of the frame")
App.draw()
eq(closed, 1, "the deferred Close ran once the frame finished")
-- the host (main.lua's closeEditor) is what unloads; after that, events
-- still in flight must not crash it
App.unload()
eq(App.getState(), nil, "App.unload drops the editor state")
App.draw()
App.keypressed("escape")
App.wheelmoved(0, 1)
eq(App.quit(), false, "a torn-down editor never blocks quit")
check(true, "post-close events are tolerated")
os.remove(tmpPath)
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- Save and Reload need a modifier: a bare letter key is one stray keystroke
-- away from writing the file, and there is no undo.
local tmpPath = os.tmpname() .. "-shortcut-save.lua"
local f = io.open(tmpPath, "wb")
f:write(SaveData.encode(SaveData.newGame()))
f:close()
App.load(tmpPath)
local s = App.getState()
local before = s.save.money
Ops.addMoney(s, 10)
check(s.dirty, "the edit landed")
love.keyboard = { isDown = function() return false end }
App.keypressed("s")
check(App.getState().dirty, "bare s does not save")
App.keypressed("r")
eq(App.getState().save.money, before + 10, "bare r does not discard the edit")
love.keyboard = { isDown = function() return true end }
App.keypressed("s")
check(App.getState().dirty == false, "Cmd/Ctrl+S saves")
love.keyboard = { isDown = function() return false end }
os.remove(tmpPath)
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- Whole-editor smoke test: every tab has to survive a real headless draw,
-- which is what catches a layout that divides by a nil font metric or
-- indexes a save field the panel assumed was always present.
local tmpPath = os.tmpname() .. "-draw-save.lua"
local data = SaveData.newGame()
data.party = { MonOps.create(Data, "CHARIZARD", 100) }
local f = io.open(tmpPath, "wb")
f:write(SaveData.encode(data))
f:close()
App.load(tmpPath, { version = "red" })
local s = App.getState()
for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do
s.tab = tab
local ok, err = pcall(App.draw)
check(ok, "the " .. tab .. " tab draws headlessly: " .. tostring(err))
end
-- and with a mon selected, which is a different code path in the inspector
s.tab = "party"
Ops.selectParty(s, 1)
local ok, err = pcall(App.draw)
check(ok, "the party inspector draws with a selection: " .. tostring(err))
os.remove(tmpPath)
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- Open... / App.openPath: switch to another save; dirty needs a second open.
local a = os.tmpname() .. "-open-a.lua"
+24 -17
View File
@@ -2542,9 +2542,16 @@ do
eq(og.save.options.videoMode, "windowed",
"new saves default VIDEO MODE to WINDOWED")
eq(om.scroll, 0, "options viewport starts at the top")
for _ = 1, 4 do press("down") end
eq(om.index, 5, "cursor reaches MUSIC VOL")
eq(om.scroll, 1, "viewport scrolls to keep MUSIC VOL on screen")
for _ = 1, 3 do press("down") end
eq(om.index, 4, "cursor reaches BATTLE LAYOUT")
press("a")
eq(og.save.options.battleLayout, "wide",
"A switches the battle screen to the WIDE layout")
press("a")
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
for _ = 1, 2 do press("down") end
eq(om.index, 6, "cursor reaches MUSIC VOL")
eq(om.scroll, 2, "viewport scrolls to keep MUSIC VOL on screen")
press("left")
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
press("right")
@@ -2559,25 +2566,25 @@ do
press("a")
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
press("down")
eq(om.index, 8, "cursor reaches COLORS")
eq(om.index, 9, "cursor reaches COLORS")
press("a")
for _ = 1, 4 do press("a") end
press("down")
eq(om.index, 9, "cursor reaches TILT")
eq(om.index, 10, "cursor reaches TILT")
press("a")
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
eq(Tilt.level, 1, "Tilt level tracks TILT option")
press("a"); press("a"); press("a")
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
press("down")
eq(om.index, 10, "cursor reaches GBC FX")
eq(om.index, 11, "cursor reaches GBC FX")
press("a")
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down")
eq(om.index, 11, "cursor reaches ZOOM")
eq(om.index, 12, "cursor reaches ZOOM")
local ZoomOpt = require("src.render.Zoom")
press("a")
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
@@ -2585,7 +2592,7 @@ do
press("left")
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
press("down")
eq(om.index, 12, "cursor reaches VOID FILL")
eq(om.index, 13, "cursor reaches VOID FILL")
local TR = require("src.render.TileRenderer")
press("a")
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
@@ -2595,7 +2602,7 @@ do
press("a")
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
press("down")
eq(om.index, 13, "cursor reaches VIDEO MODE")
eq(om.index, 14, "cursor reaches VIDEO MODE")
press("a")
eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS")
@@ -2603,7 +2610,7 @@ do
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 14, "cursor reaches MAX FPS")
eq(om.index, 15, "cursor reaches MAX FPS")
press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
@@ -2612,7 +2619,7 @@ do
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
press("down")
eq(om.index, 15, "cursor reaches GAME SPEED")
eq(om.index, 16, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
@@ -2621,19 +2628,19 @@ do
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 16, "cursor reaches MODS")
eq(om.index, 17, "cursor reaches MODS")
press("down")
eq(om.index, 17, "cursor reaches CONTROLS")
eq(om.index, 18, "cursor reaches CONTROLS")
press("down")
eq(om.index, 18, "CANCEL stays the fixed final row")
eq(om.scroll, 13, "CANCEL keeps the last option boxes on screen")
eq(om.index, 19, "CANCEL stays the fixed final row")
eq(om.scroll, 14, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 18, "up from the top wraps to CANCEL")
eq(om2.scroll, 13, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 19, "up from the top wraps to CANCEL")
eq(om2.scroll, 14, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)
+166 -182
View File
@@ -1,6 +1,12 @@
-- Headless tests for the Task 6 Boxes + Items save-editor panels.
-- Run from repo root: lua5.4 tests/save_editor_task6_tests.lua
-- (Standalone: does not require editing tests/run_save_editor_tests.lua.)
-- Headless tests for the save editor's Boxes + Items behaviour.
-- Run from repo root: luajit tests/save_editor_task6_tests.lua
-- (also chained from tests/run_save_editor_tests.lua so CI covers it)
--
-- These drive tools/save-editor/Ops.lua rather than clicking pixel
-- coordinates. The panels are pure layout now: every rule the old click
-- tests were really asserting (party/box capacity, the money clamp, the bag
-- slot cap, arm-then-confirm on destructive verbs) lives in Ops, so asserting
-- it there means a panel redesign cannot silently invalidate the suite.
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
@@ -30,16 +36,13 @@ Data:load()
local Catalog = require("Catalog")
local MonOps = require("MonOps")
local Ops = require("Ops")
local State = require("State")
local SaveData = require("src.core.SaveData")
local Kit = require("Kit")
local BoxesMod = require("src.pokemon.Boxes")
local PartyMod = require("src.pokemon.Party")
local Bag = require("src.inventory.Bag")
local Boxes = require("Boxes")
local Items = require("Items")
local function newState()
local S = State.new()
S.data = Data
@@ -49,212 +52,193 @@ local function newState()
return S
end
-- Boxes panel ---------------------------------------------------------
-- Boxes ---------------------------------------------------------------
do
local S = newState()
local px, py = 12, 80
local boxes = Ops.boxes(S)
-- Add new mon to box 1
local listH = BoxesMod.CAPACITY * 18
local actionsY = py + 34 + listH + 10
Kit.beginFrame(px + 220 + 10, actionsY + 10, true) -- "Add new mon" button
Boxes.draw(S, Kit, px, py)
eq(#S.save.boxes[1], 1, "Boxes Add new mon appends to box 1")
check(S.dirty == true, "Boxes Add new mon marks dirty")
Ops.boxAdd(S)
eq(#boxes[1], 1, "boxAdd puts a mon in the current box")
check(S.dirty, "boxAdd marks the save dirty")
check(S.status:match("box 1 slot 1") ~= nil, "boxAdd says where the mon landed")
check(S.editingMon == boxes[1][1], "boxAdd selects the new mon for the inspector")
Ops.selectBoxSlot(S, 1)
eq(S.selectedBoxSlot, 1, "selectBoxSlot selects the slot")
check(S.editingMon == boxes[1][1], "selectBoxSlot points the inspector at the mon")
-- withdraw moves box -> party
Ops.withdraw(S)
eq(#boxes[1], 0, "withdraw empties the box slot")
eq(#S.save.party, 1, "withdraw appends to the party")
eq(S.selectedParty, 1, "withdraw selects the withdrawn mon's party slot")
-- deposit moves party -> box (Boxes.deposit picks the box)
Ops.deposit(S)
eq(#S.save.party, 0, "deposit removes the mon from the party")
eq(#boxes[1], 1, "deposit fills the current box first")
check(S.status:match("into box 1") ~= nil, "deposit reports the destination box")
end
do
-- withdraw refuses (and says so) when the party is already full
local S = newState()
for _ = 1, PartyMod.MAX do
table.insert(S.save.party, MonOps.create(Data, S.cat.species[1], 5))
end
Ops.boxAdd(S)
S.dirty = false
Ops.selectBoxSlot(S, 1)
local ok = Ops.withdraw(S)
check(ok == false, "withdraw returns false when the party is full")
eq(#S.save.party, PartyMod.MAX, "withdraw with a full party leaves the party alone")
eq(#Ops.boxes(S)[1], 1, "withdraw with a full party leaves the box alone")
check(S.dirty == false, "a refused withdraw does not dirty the save")
check(S.status:match("Party is full") ~= nil, "a refused withdraw explains itself")
end
do
-- release is destructive: first click arms, second commits
local S = newState()
Ops.boxAdd(S)
Ops.selectBoxSlot(S, 1)
S.dirty = false
-- Select the mon in the box list (row 1) to set editingMon
Kit.beginFrame(px + 10, py + 34 + 5, true) -- row 1 of the box list
Boxes.draw(S, Kit, px, py)
eq(S.selectedBoxSlot, 1, "Boxes list click selects slot")
check(S.editingMon == S.save.boxes[1][1], "Boxes list click sets editingMon")
-- Withdraw the selected box mon into the (empty) party
Kit.beginFrame(px + 10, actionsY + 10, true) -- "Withdraw" button
Boxes.draw(S, Kit, px, py)
eq(#S.save.party, 1, "Boxes Withdraw moves mon into party")
eq(#S.save.boxes[1], 0, "Boxes Withdraw removes mon from box")
-- Deposit that party mon back into the box
local depositY = actionsY + 40
Kit.beginFrame(px + 440 + 10, depositY + 10, true) -- "Deposit" button
Boxes.draw(S, Kit, px, py)
eq(#S.save.party, 0, "Boxes Deposit removes mon from party")
eq(#S.save.boxes[1], 1, "Boxes Deposit places mon back in box 1")
-- Release the mon from the box
Kit.beginFrame(px + 110 + 10, actionsY + 10, true) -- "Release" button
Boxes.draw(S, Kit, px, py)
eq(#S.save.boxes[1], 0, "Boxes Release removes mon from box")
check(S.editingMon == nil, "Boxes Release clears editingMon for released mon")
-- Box navigation with "<" / ">"
eq(S.selectedBox, 1, "starts on box 1")
Kit.beginFrame(px + 230 + 10, py + 10, true) -- ">" button
Boxes.draw(S, Kit, px, py)
eq(S.selectedBox, 2, "Boxes '>' advances to box 2")
Kit.beginFrame(px + 10, py + 10, true) -- "<" button
Boxes.draw(S, Kit, px, py)
eq(S.selectedBox, 1, "Boxes '<' returns to box 1")
check(Ops.release(S) == false, "release arms on the first call")
eq(#Ops.boxes(S)[1], 1, "an armed release has not released anything yet")
eq(Ops.armLabel(S, "box-release", "Release"), "Confirm?",
"an armed release relabels its button")
check(Ops.release(S) == true, "release commits on the second call")
eq(#Ops.boxes(S)[1], 0, "the committed release empties the slot")
check(S.dirty, "the committed release dirties the save")
eq(Ops.armLabel(S, "box-release", "Release"), "Release",
"committing disarms the button label")
end
do
-- Withdraw refuses when the party is full
-- a full box refuses another mon
local S = newState()
for i = 1, PartyMod.MAX do
table.insert(S.save.party, MonOps.create(Data, "RATTATA", 5))
local box = Ops.boxes(S)[1]
for _ = 1, BoxesMod.CAPACITY do
table.insert(box, MonOps.create(Data, S.cat.species[1], 5))
end
table.insert(S.save.boxes[1], MonOps.create(Data, "PIDGEY", 5))
local px, py = 12, 80
local listH = BoxesMod.CAPACITY * 18
local actionsY = py + 34 + listH + 10
Kit.beginFrame(px + 10, actionsY + 10, true) -- "Withdraw" button
Boxes.draw(S, Kit, px, py)
eq(#S.save.party, PartyMod.MAX, "Boxes Withdraw is a no-op when party is full")
eq(#S.save.boxes[1], 1, "Boxes Withdraw leaves mon in box when party is full")
end
-- Items panel -----------------------------------------------------------
do
local S = newState()
local px, py = 12, 80
local moneyBefore = S.save.money
local moneyBtnY = py + 22
Kit.beginFrame(px + 132 + 10, moneyBtnY + 10, true) -- "+10" button
Items.draw(S, Kit, px, py)
eq(S.save.money, moneyBefore + 10, "Items +10 money button")
check(S.dirty == true, "Items money change marks dirty")
S.dirty = false
check(Ops.boxAdd(S) == false, "boxAdd refuses a full box")
eq(#box, BoxesMod.CAPACITY, "a refused boxAdd adds nothing")
check(S.status:match("full") ~= nil, "a refused boxAdd explains itself")
end
Kit.beginFrame(px + 10, moneyBtnY + 10, true) -- "-100" button (money >= 0 clamp)
Items.draw(S, Kit, px, py)
eq(S.save.money, moneyBefore + 10 - 100 < 0 and 0 or moneyBefore + 10 - 100,
"Items -100 money button clamps at 0")
do
-- box navigation wraps in both directions and follows the save's currentBox
local S = newState()
Ops.stepBox(S, -1)
eq(S.selectedBox, BoxesMod.COUNT, "stepping back from box 1 wraps to the last box")
eq(S.save.currentBox, BoxesMod.COUNT, "the save's currentBox follows the selection")
Ops.stepBox(S, 1)
eq(S.selectedBox, 1, "stepping forward from the last box wraps to box 1")
end
-- Item picker cycles and adds to bag / PC
local pickerY = moneyBtnY + 40
local pickIdBefore = S.cat.items[S.itemPickerIdx or 1]
Kit.beginFrame(px + 280 + 10, pickerY + 10, true) -- ">" cycles picker
Items.draw(S, Kit, px, py)
check(S.cat.items[S.itemPickerIdx] ~= pickIdBefore or #S.cat.items == 1,
"Items picker '>' advances selection")
-- Items ---------------------------------------------------------------
-- point the picker at a known item id for deterministic add/remove checks
for i, id in ipairs(S.cat.items) do
if id == "MASTER_BALL" then S.itemPickerIdx = i break end
end
Kit.beginFrame(px + 320 + 10, pickerY + 10, true) -- "Add to Bag"
Items.draw(S, Kit, px, py)
eq(S.save.inventory.MASTER_BALL, 1, "Items Add to Bag adds MASTER_BALL to inventory")
check(S.dirty == true, "Items Add to Bag marks dirty")
do
local S = newState()
S.save.money = 100
Ops.addMoney(S, 1000)
eq(S.save.money, 1100, "addMoney adds")
Ops.addMoney(S, -100000)
eq(S.save.money, 0, "addMoney clamps at zero")
Ops.maxMoney(S)
eq(S.save.money, Ops.MONEY_MAX, "maxMoney tops the wallet out")
S.dirty = false
Kit.beginFrame(px + 440 + 10, pickerY + 10, true) -- "Add to PC"
Items.draw(S, Kit, px, py)
eq(S.save.pcItems.MASTER_BALL, 1, "Items Add to PC adds MASTER_BALL to pcItems")
-- Bag list remove
local bagLabelY = pickerY + 40
local bagListY = bagLabelY + 20
local bagPagerY = bagListY + 200 + 8
local bagActionsY = bagPagerY + 34
local order = Bag.order(S.save)
local idx = nil
for i, id in ipairs(order) do if id == "MASTER_BALL" then idx = i end end
check(idx ~= nil, "MASTER_BALL present in bag order")
S.selectedBagIdx = idx
Kit.beginFrame(px + 10, bagActionsY + 10, true) -- "Remove 1"
Items.draw(S, Kit, px, py)
check(S.save.inventory.MASTER_BALL == nil, "Items bag Remove 1 clears single-qty MASTER_BALL")
-- PC list remove all
S.save.pcItems.MASTER_BALL = 5
local pcLabelY = bagActionsY + 40
local pcListY = pcLabelY + 20
local pcPagerY = pcListY + 200 + 8
local pcActionsY = pcPagerY + 34
local pcOrder = {}
for id in pairs(S.save.pcItems) do table.insert(pcOrder, id) end
table.sort(pcOrder)
local pidx = nil
for i, id in ipairs(pcOrder) do if id == "MASTER_BALL" then pidx = i end end
S.selectedPcIdx = pidx
Kit.beginFrame(px + 110 + 10, pcActionsY + 10, true) -- "Remove all"
Items.draw(S, Kit, px, py)
check(S.save.pcItems.MASTER_BALL == nil, "Items PC Remove all clears MASTER_BALL")
-- Badges toggle directly on inventory
local badgeLabelY = pcActionsY + 40
local badgeY = badgeLabelY + 20
check(S.save.inventory.BOULDERBADGE == nil, "BOULDERBADGE starts unset")
Kit.beginFrame(px + 10, badgeY + 10, true) -- first badge button
Items.draw(S, Kit, px, py)
check(S.save.inventory.BOULDERBADGE == true, "Items badge toggle sets inventory flag")
Kit.beginFrame(px + 10, badgeY + 10, true) -- toggle again
Items.draw(S, Kit, px, py)
check(S.save.inventory.BOULDERBADGE == nil, "Items badge toggle clears inventory flag")
check(Ops.addMoney(S, 1000) == false, "addMoney at the cap is a no-op")
check(S.dirty == false, "a no-op addMoney does not dirty the save")
end
do
-- Bag cap: 20 distinct slots max (Bag.add returns false past capacity)
local S = newState()
local px, py = 12, 80
local pickerY = py + 22 + 40
for i = 1, Bag.CAPACITY do
S.save.inventory["FILLER_ITEM_" .. i] = 1
table.insert(Bag.order(S.save), "FILLER_ITEM_" .. i)
end
eq(Bag.slots(S.save), Bag.CAPACITY, "bag pre-filled to capacity")
local id = S.cat.items[1]
for i, id in ipairs(S.cat.items) do
if id == "MASTER_BALL" then S.itemPickerIdx = i break end
end
Kit.beginFrame(px + 320 + 10, pickerY + 10, true) -- "Add to Bag"
Items.draw(S, Kit, px, py)
check(S.save.inventory.MASTER_BALL == nil, "Items Add to Bag refuses a new slot past capacity")
Ops.addToBag(S, id)
eq(S.save.inventory[id], 1, "addToBag adds one")
Ops.bagAdjust(S, id, 1)
eq(S.save.inventory[id], 2, "bagAdjust +1 increments")
Ops.bagAdjust(S, id, -1)
eq(S.save.inventory[id], 1, "bagAdjust -1 decrements")
Ops.bagAdjust(S, id, -1)
eq(S.save.inventory[id], nil, "bagAdjust to zero clears the slot")
check(S.status:match("Removed the last") ~= nil,
"emptying a bag slot says so rather than printing x0")
Ops.addToBag(S, id)
Ops.bagAdjust(S, id, 1)
Ops.bagDrop(S, id)
eq(S.save.inventory[id], nil, "bagDrop removes the whole stack")
eq(#Bag.order(S.save), 0, "bagDrop removes the bag order entry too")
end
do
-- Bag/PC pagination: Prev/Next reach slots beyond the first VISIBLE_ROWS
-- (10), so all 20 bag slots stay selectable (Important fix #1).
-- the bag has a hard slot cap; the picker must refuse past it
local S = newState()
local px, py = 12, 80
local moneyBtnY = py + 22
local pickerY = moneyBtnY + 40
local bagLabelY = pickerY + 40
local bagListY = bagLabelY + 20
local bagPagerY = bagListY + 200 + 8
for i = 1, Bag.CAPACITY do
local id = "FILLER_ITEM_" .. i
S.save.inventory[id] = 1
table.insert(Bag.order(S.save), id)
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
end
eq(Bag.slots(S.save), Bag.CAPACITY, "the bag filled to its cap")
S.dirty = false
local spare
for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) and not S.save.inventory[id] then spare = id break end
end
check(spare ~= nil, "there is an item left over to try to add")
check(Ops.addToBag(S, spare) == false, "addToBag refuses once the bag is full")
check(S.dirty == false, "a refused addToBag does not dirty the save")
check(S.status:match("Bag is full") ~= nil, "a refused addToBag explains itself")
end
Kit.beginFrame(0, 0, false)
Items.draw(S, Kit, px, py)
eq(S.bagScroll, 0, "Bag list starts on page 1 (unscrolled)")
do
-- PC storage is a plain dict with a per-stack cap and no slot limit.
-- A new game already seeds one item there, so the assertions below track
-- the delta rather than assuming an empty dict.
local S = newState()
local seeded = #Ops.pcOrder(S)
local id
for _, candidate in ipairs(S.cat.items) do
if not Ops.pcItems(S)[candidate] then id = candidate break end
end
check(id ~= nil, "found an item not already in PC storage")
Kit.beginFrame(px + 100 + 10, bagPagerY + 10, true) -- "Next"
Items.draw(S, Kit, px, py)
eq(S.bagScroll, 10, "Bag 'Next' pager advances by VISIBLE_ROWS")
Ops.addToPc(S, id)
eq(#Ops.pcOrder(S), seeded + 1, "addToPc grows the PC order by one")
eq(Ops.pcItems(S)[id], 1, "addToPc creates the entry")
Ops.pcAdjust(S, id, 1)
eq(Ops.pcItems(S)[id], 2, "pcAdjust +1 increments")
Ops.pcAdjust(S, id, -2)
eq(Ops.pcItems(S)[id], nil, "pcAdjust down to zero removes the entry")
-- Row 10 of page 2 (scroll=10) is absolute slot 20, the last bag slot.
Kit.beginFrame(px + 10, bagListY + 9 * 20 + 5, true)
Items.draw(S, Kit, px, py)
eq(S.selectedBagIdx, 20, "Bag list click on page 2 reaches slot 20")
Ops.addToPc(S, id)
Ops.pcItems(S)[id] = Ops.STACK_MAX
S.dirty = false
check(Ops.pcAdjust(S, id, 1) == false, "pcAdjust refuses past the 99 stack cap")
eq(Ops.pcItems(S)[id], Ops.STACK_MAX, "a refused pcAdjust changes nothing")
Kit.beginFrame(px + 10, bagPagerY + 34 + 10, true) -- "Remove 1" on slot 20
Items.draw(S, Kit, px, py)
check(S.save.inventory.FILLER_ITEM_20 == nil, "Bag Remove 1 clears the paged-to slot")
Ops.pcDrop(S, id)
eq(Ops.pcItems(S)[id], nil, "pcDrop removes the entry")
eq(#Ops.pcOrder(S), seeded, "pcDrop shrinks the PC order back")
end
Kit.beginFrame(px + 10, bagPagerY + 10, true) -- "Prev"
Items.draw(S, Kit, px, py)
eq(S.bagScroll, 0, "Bag 'Prev' pager returns to page 1")
do
-- badges are boolean flags on inventory, toggled not stacked
local S = newState()
local ids = Ops.badgeIds(S)
check(#ids > 0, "the catalog exposes badge ids")
local id = ids[1]
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], true, "toggleBadge earns the badge")
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], nil, "toggleBadge removes the badge (nil, not false)")
end
print(string.format("save editor task 6 tests: %d passed, %d failed", passed, failed))
+147 -231
View File
@@ -1,11 +1,12 @@
-- Headless tests for the Task 7 Events + Dex panels.
-- Run from repo root: /opt/homebrew/Cellar/lua@5.4/5.4.8/bin/lua tests/save_editor_task7_tests.lua
-- Headless tests for the save editor's Events + Dex behaviour.
-- Run from repo root: luajit tests/save_editor_task7_tests.lua
-- (also chained from tests/run_save_editor_tests.lua so CI covers it)
--
-- Mirrors tests/run_save_editor_tests.lua's approach: drive Kit's
-- immediate-mode hit-testing by placing the "mouse" at the exact
-- coordinates each panel draws its widgets at (see the layout comments in
-- panels/Events.lua and panels/Dex.lua), so click handlers run for real
-- without a live LOVE window.
-- Driven through tools/save-editor/Ops.lua rather than by clicking pixel
-- coordinates: the rules worth protecting here are the save-shape ones
-- (flags write true/nil, object toggles write true/false, owning implies
-- seen, un-seeing clears owned, wholesale clears arm before they commit),
-- and those all live in Ops.
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
@@ -28,269 +29,184 @@ local function eq(a, b, msg)
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
end
local function count(t)
local n = 0
for _ in pairs(t or {}) do n = n + 1 end
return n
end
print("== save editor task 7 tests (Events + Dex) ==")
local Kit = require("Kit")
local Ops = require("Ops")
local State = require("State")
local SaveIO = require("SaveIO")
local SaveData = require("src.core.SaveData")
local Events = require("Events")
local Dex = require("Dex")
local px, py = 12, 80
-- ===== Events: Flags tab =====
do
local function newState()
local S = State.new()
S.events = { "EVENT_ALPHA", "EVENT_BEAT_BROCK", "EVENT_ZETA" }
S.cat = { species = { "BULBASAUR", "CHARMANDER", "SQUIRTLE", "PIKACHU" },
items = {}, moves = {} }
S.save = {
flags = {}, defeatedTrainers = {}, itemsTaken = {}, objectToggles = {},
party = {}, boxes = {},
}
return S
end
Kit.beginFrame(0, 0, false)
Events.draw(S, Kit, px, py)
eq(S.eventFilter, "", "Events.draw defaults eventFilter to empty string")
eq(S.eventsTab, "flags", "Events.draw defaults eventsTab to flags")
-- Events --------------------------------------------------------------
local listY = py + 64 + 32 -- contentY(+64) + list offset(+32)
do
local S = newState()
Kit.beginFrame(px + 10, listY + 10, true) -- row 1: EVENT_ALPHA
Events.draw(S, Kit, px, py)
check(S.save.flags.EVENT_ALPHA == true, "Flags row1 checkbox sets EVENT_ALPHA")
check(S.dirty == true, "Flags checkbox toggle marks dirty")
Ops.setFlag(S, "EVENT_ALPHA", true)
eq(S.save.flags.EVENT_ALPHA, true, "setFlag on writes true")
check(S.dirty, "setFlag dirties the save")
check(S.status:match("EVENT_ALPHA") ~= nil, "setFlag names the flag it changed")
Ops.setFlag(S, "EVENT_ALPHA", false)
eq(S.save.flags.EVENT_ALPHA, nil,
"setFlag off writes nil, not false (a false flag would still serialize)")
end
do
local S = newState()
Ops.setKey(S, "defeatedTrainers", "PEWTER_GYM_obj_1", true)
eq(S.save.defeatedTrainers.PEWTER_GYM_obj_1, true, "setKey marks a trainer beaten")
Ops.setKey(S, "defeatedTrainers", "PEWTER_GYM_obj_1", false)
eq(S.save.defeatedTrainers.PEWTER_GYM_obj_1, nil, "setKey off clears the entry")
Ops.setKey(S, "itemsTaken", "VIRIDIAN_FOREST_obj_3", true)
eq(S.save.itemsTaken.VIRIDIAN_FOREST_obj_3, true, "setKey works for itemsTaken too")
-- setKey creates the table when a save predates it
S.save.newTable = nil
Ops.setKey(S, "newTable", "k", true)
eq(S.save.newTable.k, true, "setKey creates a missing table")
end
do
-- object toggles are an explicit true/false override, NOT presence/absence:
-- false means "this object is hidden", which is different from "no override"
local S = newState()
Ops.setToggle(S, "CELADON_CITY", "gym_guide", true)
eq(S.save.objectToggles.CELADON_CITY.gym_guide, true, "setToggle on writes true")
Ops.setToggle(S, "CELADON_CITY", "gym_guide", false)
eq(S.save.objectToggles.CELADON_CITY.gym_guide, false,
"setToggle off writes false, not nil")
end
do
-- clearing a whole key table is destructive: arm, then commit
local S = newState()
S.save.defeatedTrainers = { a = true, b = true, c = true }
S.dirty = false
Kit.beginFrame(px + 10, listY + 22 + 10, true) -- row 2: EVENT_BEAT_BROCK
Events.draw(S, Kit, px, py)
check(S.save.flags.EVENT_BEAT_BROCK == true, "Flags row2 checkbox sets EVENT_BEAT_BROCK")
check(Ops.clearTable(S, "defeatedTrainers", "trainers") == false,
"clearTable arms on the first call")
eq(count(S.save.defeatedTrainers), 3, "an armed clear has not cleared anything")
check(S.status:match("Clear all 3") ~= nil, "the arming message counts the entries")
eq(Ops.armLabel(S, "clear-defeatedTrainers", "Clear all trainers"), "Confirm?",
"an armed clear relabels its button")
Kit.beginFrame(px + 10, listY + 22 + 10, true) -- click row 2 again to uncheck
Events.draw(S, Kit, px, py)
check(S.save.flags.EVENT_BEAT_BROCK == nil, "Unchecking a flag clears the key (not just false)")
check(Ops.clearTable(S, "defeatedTrainers", "trainers") == true,
"clearTable commits on the second call")
eq(count(S.save.defeatedTrainers), 0, "the committed clear empties the table")
check(S.dirty, "the committed clear dirties the save")
-- re-check it, then persist through SaveIO to confirm it round-trips to disk
Kit.beginFrame(px + 10, listY + 22 + 10, true)
Events.draw(S, Kit, px, py)
check(S.save.flags.EVENT_BEAT_BROCK == true, "Flags row2 re-checked")
local path = os.tmpname() .. "-task7-events.lua"
local ok, err = SaveIO.save(path, S.save)
check(ok, "SaveIO.save ok: " .. tostring(err))
local f = io.open(path, "r")
local raw = f:read("*a")
f:close()
check(raw:find("EVENT_BEAT_BROCK") ~= nil, "saved file contains EVENT_BEAT_BROCK key")
local loaded = SaveData.decode(raw)
check(loaded ~= nil and loaded.flags.EVENT_BEAT_BROCK == true,
"reloaded save confirms EVENT_BEAT_BROCK = true")
os.remove(path)
S.dirty = false
check(Ops.clearTable(S, "defeatedTrainers", "trainers") == false,
"clearing an already-empty table is a no-op")
check(S.dirty == false, "a no-op clear does not dirty the save")
check(S.status:match("already empty") ~= nil, "a no-op clear explains itself")
end
-- ===== Events: filter field + Clear filter =====
-- Dex -----------------------------------------------------------------
do
local S = State.new()
S.events = { "EVENT_ALPHA", "EVENT_BEAT_BROCK", "EVENT_ZETA" }
S.save = {
flags = {}, defeatedTrainers = {}, itemsTaken = {}, objectToggles = {},
party = {}, boxes = {},
}
S.eventFilter = "beat" -- love.keyboard.isDown always false in love_stub,
-- so setting this directly stands in for typing
local S = newState()
local dex = Ops.dex(S)
check(type(dex.seen) == "table" and type(dex.owned) == "table",
"Ops.dex creates the seen/owned tables")
local listY = py + 64 + 32
Kit.beginFrame(px + 10, listY + 10, true) -- only visible row under the filter
Events.draw(S, Kit, px, py)
check(S.save.flags.EVENT_BEAT_BROCK == true, "Filtered row1 toggles the filtered-in event")
check(S.save.flags.EVENT_ALPHA == nil, "Filter hides EVENT_ALPHA from row1's slot")
Ops.dexSeen(S, "BULBASAUR", true)
eq(dex.seen.BULBASAUR, true, "dexSeen marks seen")
eq(dex.owned.BULBASAUR, nil, "seeing alone does not own")
local clearBtnX, clearBtnY = px + 320, py + 64
Kit.beginFrame(clearBtnX + 10, clearBtnY + 10, true) -- Clear filter button
Events.draw(S, Kit, px, py)
eq(S.eventFilter, "", "Clear filter button resets eventFilter")
Ops.dexOwned(S, "CHARMANDER", true)
eq(dex.owned.CHARMANDER, true, "dexOwned marks owned")
eq(dex.seen.CHARMANDER, true, "owning implies having seen")
Ops.dexSeen(S, "CHARMANDER", false)
eq(dex.seen.CHARMANDER, nil, "un-seeing clears seen")
eq(dex.owned.CHARMANDER, nil, "un-seeing also clears owned (can't own the unseen)")
end
-- ===== Events: Flags pagination =====
do
local S = State.new()
S.events = {}
for i = 1, 25 do
S.events[i] = string.format("EVENT_%02d", i)
end
S.save = {
flags = {}, defeatedTrainers = {}, itemsTaken = {}, objectToggles = {},
party = {}, boxes = {},
}
local S = newState()
local seen, owned, total = Ops.dexCounts(S)
eq(seen, 0, "a fresh dex has seen nothing")
eq(owned, 0, "a fresh dex owns nothing")
eq(total, #S.cat.species, "dexCounts reports the catalog size")
local listY = py + 64 + 32
local pagerY = listY + 10 * 22 + 8
Ops.dexSeeAll(S)
seen, owned = Ops.dexCounts(S)
eq(seen, #S.cat.species, "dexSeeAll marks every species seen")
eq(owned, 0, "dexSeeAll does not own anything")
Kit.beginFrame(px + 100 + 10, pagerY + 10, true) -- Next button
Events.draw(S, Kit, px, py)
eq(S.eventsScroll, 10, "Next button scrolls by VISIBLE_ROWS")
Kit.beginFrame(px + 10, listY + 10, true) -- row1 now maps to EVENT_11
Events.draw(S, Kit, px, py)
check(S.save.flags.EVENT_11 == true, "Row1 after scrolling toggles the 11th event")
check(S.save.flags.EVENT_01 == nil, "First event untouched after scrolling")
Kit.beginFrame(px + 10, pagerY + 10, true) -- Prev button
Events.draw(S, Kit, px, py)
eq(S.eventsScroll, 0, "Prev button scrolls back")
Ops.dexOwnAll(S)
seen, owned = Ops.dexCounts(S)
eq(owned, #S.cat.species, "dexOwnAll marks every species owned")
eq(seen, #S.cat.species, "dexOwnAll leaves everything seen too")
end
-- ===== Events: Trainers tab =====
do
local S = State.new()
S.events = {}
S.save = {
flags = {},
defeatedTrainers = { PALLET_TOWN_obj_0 = true, ROUTE1_obj_2 = false },
itemsTaken = {}, objectToggles = {}, party = {}, boxes = {},
}
local tabsY = py + 24
local trainersTabX = px + 64 + 4 -- after the "Flags" tab (w = 8*5+24 = 64)
Kit.beginFrame(trainersTabX + 10, tabsY + 10, true)
Events.draw(S, Kit, px, py)
eq(S.eventsTab, "trainers", "Trainers tab click switches sub-tab")
local listY = py + 64 + 32
Kit.beginFrame(px + 10, listY + 22 + 10, true) -- row2: ROUTE1_obj_2 (sorted after PALLET_TOWN_obj_0)
Events.draw(S, Kit, px, py)
check(S.save.defeatedTrainers.ROUTE1_obj_2 == true, "Trainers checkbox sets known key true")
local pagerY = listY + 10 * 22 + 8
local clearAllX = px + 400
Kit.beginFrame(clearAllX + 10, pagerY + 10, true) -- Clear all trainers
Events.draw(S, Kit, px, py)
check(next(S.save.defeatedTrainers) == nil, "Clear all trainers empties the table")
end
-- ===== Events: Items taken tab =====
do
local S = State.new()
S.events = {}
S.save = {
flags = {}, defeatedTrainers = {},
itemsTaken = { PALLET_TOWN_obj_1 = false },
objectToggles = {}, party = {}, boxes = {},
}
local tabsY = py + 24
local trainersTabX = px + 64 + 4
local itemsTabX = trainersTabX + 88 + 4 -- after "Trainers" (w = 8*8+24 = 88)
Kit.beginFrame(itemsTabX + 10, tabsY + 10, true)
Events.draw(S, Kit, px, py)
eq(S.eventsTab, "items", "Items taken tab click switches sub-tab")
local listY = py + 64 + 32
Kit.beginFrame(px + 10, listY + 10, true) -- row1: PALLET_TOWN_obj_1
Events.draw(S, Kit, px, py)
check(S.save.itemsTaken.PALLET_TOWN_obj_1 == true, "Items checkbox sets known key true")
end
-- ===== Events: Object toggles tab =====
do
local S = State.new()
S.events = {}
S.save = {
flags = {}, defeatedTrainers = {}, itemsTaken = {},
objectToggles = { PALLET_TOWN = { OAK = false, SIGN = true } },
party = {}, boxes = {},
}
local tabsY = py + 24
local trainersTabX = px + 64 + 4
local itemsTabX = trainersTabX + 88 + 4
local togglesTabX = itemsTabX + 112 + 4 -- after "Items taken" (w = 8*11+24 = 112)
Kit.beginFrame(togglesTabX + 10, tabsY + 10, true)
Events.draw(S, Kit, px, py)
eq(S.eventsTab, "toggles", "Object toggles tab click switches sub-tab")
local listY = py + 64 + 32
-- row1 is the "[PALLET_TOWN]" header (not clickable); row2/3 are OAK, SIGN (sorted)
Kit.beginFrame(px + 10, listY + 22 + 10, true) -- row2: OAK (false -> true)
Events.draw(S, Kit, px, py)
check(S.save.objectToggles.PALLET_TOWN.OAK == true, "Toggle row flips OAK to true")
Kit.beginFrame(px + 10, listY + 44 + 10, true) -- row3: SIGN (true -> false)
Events.draw(S, Kit, px, py)
check(S.save.objectToggles.PALLET_TOWN.SIGN == false, "Toggle row flips SIGN to false")
-- clicking the header row (row1) must not error and must not touch data
Kit.beginFrame(px + 10, listY + 10, true)
local ok = pcall(Events.draw, S, Kit, px, py)
check(ok, "Clicking the map header row does not error")
end
-- ===== Dex panel =====
do
local S = State.new()
S.cat = { species = { "BULBASAUR", "CHARMANDER", "SQUIRTLE" } }
S.save = { party = {}, boxes = {}, pokedex = { seen = {}, owned = {} } }
Kit.beginFrame(0, 0, false)
Dex.draw(S, Kit, px, py)
local seenX, ownedX = px + 220, px + 300
local listY = py + 64 + 24
Kit.beginFrame(seenX + 10, listY + 10, true) -- row1 seen: BULBASAUR
Dex.draw(S, Kit, px, py)
check(S.save.pokedex.seen.BULBASAUR == true, "Dex row1 seen checkbox sets BULBASAUR seen")
Kit.beginFrame(ownedX + 10, listY + 10, true) -- row1 owned: BULBASAUR
Dex.draw(S, Kit, px, py)
check(S.save.pokedex.owned.BULBASAUR == true, "Dex row1 owned checkbox sets BULBASAUR owned")
Kit.beginFrame(seenX + 10, listY + 10, true) -- uncheck seen
Dex.draw(S, Kit, px, py)
check(S.save.pokedex.seen.BULBASAUR == nil, "Unchecking seen clears BULBASAUR")
check(S.save.pokedex.owned.BULBASAUR == nil, "Unchecking seen also clears owned (can't own unseen)")
S.save.party = { { species = "CHARMANDER" } }
-- stamping from the save's own mons, party and boxes both
local S = newState()
S.save.party = { { species = "PIKACHU" } }
S.save.boxes = { { { species = "SQUIRTLE" } } }
Kit.beginFrame(px + 10, py + 24 + 10, true) -- Own party+boxes
Dex.draw(S, Kit, px, py)
check(S.save.pokedex.owned.CHARMANDER == true, "Own party+boxes marks party mon owned")
check(S.save.pokedex.owned.SQUIRTLE == true, "Own party+boxes marks boxed mon owned")
Kit.beginFrame(px + 190 + 10, py + 24 + 10, true) -- See all
Dex.draw(S, Kit, px, py)
check(S.save.pokedex.seen.BULBASAUR == true, "See all marks every species seen")
Ops.dexStamp(S)
local dex = Ops.dex(S)
eq(dex.owned.PIKACHU, true, "dexStamp owns party mons")
eq(dex.owned.SQUIRTLE, true, "dexStamp owns box mons")
eq(dex.seen.PIKACHU, true, "dexStamp marks stamped mons seen")
check(S.status:match("2 more") ~= nil, "dexStamp reports how many it added")
Kit.beginFrame(px + 310 + 10, py + 24 + 10, true) -- Clear
Dex.draw(S, Kit, px, py)
check(next(S.save.pokedex.seen) == nil, "Clear empties seen")
check(next(S.save.pokedex.owned) == nil, "Clear empties owned")
S.dirty = false
check(Ops.dexStamp(S) == false, "a second dexStamp with nothing new is a no-op")
check(S.dirty == false, "a no-op dexStamp does not dirty the save")
end
-- ===== Dex pagination =====
do
local S = State.new()
S.cat = { species = {} }
for i = 1, 25 do
S.cat.species[i] = string.format("SPECIES_%02d", i)
end
S.save = { party = {}, boxes = {}, pokedex = { seen = {}, owned = {} } }
-- wiping the dex is destructive: arm, then commit
local S = newState()
Ops.dexOwnAll(S)
S.dirty = false
local seenX = px + 220
local listY = py + 64 + 24
local pagerY = listY + 12 * 22 + 8
check(Ops.dexClear(S) == false, "dexClear arms on the first call")
local _, owned = Ops.dexCounts(S)
eq(owned, #S.cat.species, "an armed dexClear has not wiped anything")
eq(Ops.armLabel(S, "dex-clear", "Wipe dex"), "Confirm?",
"an armed dexClear relabels its button")
Kit.beginFrame(px + 100 + 10, pagerY + 10, true) -- Next
Dex.draw(S, Kit, px, py)
eq(S.dexScroll, 12, "Dex Next button scrolls by VISIBLE_ROWS")
check(Ops.dexClear(S) == true, "dexClear commits on the second call")
local seen2, owned2 = Ops.dexCounts(S)
eq(seen2, 0, "the committed dexClear clears seen")
eq(owned2, 0, "the committed dexClear clears owned")
check(S.dirty, "the committed dexClear dirties the save")
end
Kit.beginFrame(seenX + 10, listY + 10, true) -- row1 -> SPECIES_13
Dex.draw(S, Kit, px, py)
check(S.save.pokedex.seen.SPECIES_13 == true, "Row1 after scrolling toggles the 13th species")
check(S.save.pokedex.seen.SPECIES_01 == nil, "First species untouched after scrolling")
Kit.beginFrame(px + 10, pagerY + 10, true) -- Prev
Dex.draw(S, Kit, px, py)
eq(S.dexScroll, 0, "Dex Prev button scrolls back")
do
-- doing anything else disarms a pending confirmation: an unrelated click
-- must never become the second half of a destructive one
local S = newState()
Ops.dexOwnAll(S)
Ops.dexClear(S)
eq(S.armed, "dex-clear", "dexClear left the button armed")
Ops.dexSeen(S, "PIKACHU", true)
eq(S.armed, nil, "an unrelated mutation disarms the pending confirmation")
local _, owned = Ops.dexCounts(S)
check(owned > 0, "the dex was not wiped by the unrelated click")
end
print(string.format("save editor task 7 tests: %d passed, %d failed", passed, failed))
+94 -198
View File
@@ -1,8 +1,10 @@
-- Headless tests for tools/save-editor/panels/MapBrowser.lua.
-- Run from repo root: lua5.4 tests/save_editor_task8_tests.lua
-- (love_stub lacks push/pop/scale/scissor; MapBrowser skips real
-- rendering under those but still runs all click/button logic, which is
-- what these tests exercise via Kit.beginFrame like the other panels.)
-- Headless tests for the save editor's map browser behaviour.
-- Run from repo root: luajit tests/save_editor_task8_tests.lua
-- (also chained from tests/run_save_editor_tests.lua so CI covers it)
--
-- The spawn-point writes and the outdoor rule live in tools/save-editor/Ops.lua
-- and are asserted there; the panel-owned bits still tested here are the ones
-- with no other home: map selection, keyboard panning and the zoom clamp.
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
@@ -32,13 +34,10 @@ Data:load()
local SaveData = require("src.core.SaveData")
local State = require("State")
local Kit = require("Kit")
local Ops = require("Ops")
local MapLoader = require("src.world.MapLoader")
local MapBrowser = require("MapBrowser")
local LIST_W, LIST_H, ROW_H = 200, 300, 20
local MAX_ROWS = math.floor(LIST_H / ROW_H)
local VIEW_W, VIEW_H = 480, 432
local function newState()
local S = State.new()
S.data = Data
@@ -47,221 +46,118 @@ local function newState()
return S
end
local px, py = 12, 80
local vx, vy = px + LIST_W + 20, py + 24
-- ---------------------------------------------------------------- list
do
local S = newState()
local ids = {}
for id in pairs(Data.maps) do table.insert(ids, id) end
table.sort(ids)
check(#ids > 200, "generated data has lots of maps")
-- click the 3rd row of the map id list -> selects that map, no crash
-- despite love_stub missing push/pop/scale/scissor
Kit.beginFrame(px + 10, py + 24 + 2 * ROW_H + 5, true)
MapBrowser.draw(S, Kit, px, py)
eq(S.mapId, ids[3], "clicking list row 3 selects the 3rd sorted map id")
check(S.mapClickCell == nil, "switching maps clears any selected cell")
end
-- outdoor detection ---------------------------------------------------
do
local S = newState()
-- Next then Prev should return to the first page
Kit.beginFrame(px + 64 + 5, py + 24 + MAX_ROWS * ROW_H + 8 + 5, true) -- Next
MapBrowser.draw(S, Kit, px, py)
eq(S.mapListScroll, MAX_ROWS, "Next advances one page")
local pallet = MapLoader.load(Data, "PALLET_TOWN")
check(Ops.isOutdoor(S, pallet), "PALLET_TOWN is outdoor (OVERWORLD tileset)")
Kit.beginFrame(px + 5, py + 24 + MAX_ROWS * ROW_H + 8 + 5, true) -- Prev
MapBrowser.draw(S, Kit, px, py)
eq(S.mapListScroll, 0, "Prev returns to page 0")
end
-- ------------------------------------------------------------- click-to-cell
do
local S = newState() -- PALLET_TOWN
-- (5,6) is a known-walkable, non-warp cell (tests/run_tests.lua uses
-- the same ground truth); zoom 2 means 32 screen px per cell.
local mx = vx + 5 * 16 * S.mapZoom + 4
local my = vy + 6 * 16 * S.mapZoom + 4
Kit.beginFrame(mx, my, true)
MapBrowser.draw(S, Kit, px, py)
check(S.mapClickCell ~= nil, "clicking inside the viewport selects a cell")
if S.mapClickCell then
eq(S.mapClickCell.cx, 5, "selected cell cx")
eq(S.mapClickCell.cy, 6, "selected cell cy")
local indoor
for id in pairs(Data.maps) do
local ok, map = pcall(MapLoader.load, Data, id)
if ok and not Ops.isOutdoor(S, map) then indoor = map break end
end
check(indoor ~= nil, "the dataset has at least one indoor map")
-- clicking outside the viewport (e.g. over the list) must not select a cell
-- a visited fly spot counts as outdoor even when the tileset does not
if indoor then
S.save.visited = S.save.visited or {}
S.save.visited[indoor.id] = true
check(Ops.isOutdoor(S, indoor),
"a visited map counts as outdoor even with an indoor tileset")
S.save.visited[indoor.id] = nil
end
end
-- spawn points --------------------------------------------------------
do
local S = newState()
S.mapId = "VIRIDIAN_CITY"
S.mapClickCell = nil
Kit.beginFrame(px + 5, py + 5, true)
MapBrowser.draw(S, Kit, px, py)
check(S.mapClickCell == nil, "clicking outside the viewport doesn't select a cell")
S.dirty = false
check(Ops.setPlayerHere(S) == false, "setPlayerHere refuses with no cell selected")
check(S.dirty == false, "a refused setPlayerHere does not dirty the save")
check(S.status:match("Click a cell first") ~= nil,
"a refused setPlayerHere explains itself")
S.mapClickCell = { cx = 7, cy = 9 }
check(Ops.setPlayerHere(S) == true, "setPlayerHere writes with a cell selected")
eq(S.save.player.map, "VIRIDIAN_CITY", "setPlayerHere moves the player's map")
eq(S.save.player.x, 7, "setPlayerHere moves the player's x")
eq(S.save.player.y, 9, "setPlayerHere moves the player's y")
check(S.dirty, "setPlayerHere dirties the save")
check(Ops.setLastHeal(S) == true, "setLastHeal writes with a cell selected")
eq(S.save.lastHeal.map, "VIRIDIAN_CITY", "setLastHeal records the map")
eq(S.save.lastHeal.x, 7, "setLastHeal records the cell")
end
-- ---------------------------------------------------------------- set player
do
-- lastOutdoor refuses a map the game would not accept as an outdoor source
local S = newState()
S.mapClickCell = { cx = 3, cy = 4 }
local by = vy + VIEW_H + 8
Kit.beginFrame(vx + 10, by + 22 + 10, true) -- Set player here
MapBrowser.draw(S, Kit, px, py)
eq(S.save.player.map, S.mapId, "Set player here updates player.map")
eq(S.save.player.x, 3, "Set player here updates player.x")
eq(S.save.player.y, 4, "Set player here updates player.y")
check(S.dirty == true, "Set player here marks dirty")
end
do
local S = newState()
local by = vy + VIEW_H + 8
Kit.beginFrame(vx + 10, by + 22 + 10, true) -- Set player here, no cell selected
MapBrowser.draw(S, Kit, px, py)
check(S.status:match("Click a cell first"), "Set player here without a selection warns")
end
-- ------------------------------------------------------------- lastOutdoor
do
local S = newState() -- PALLET_TOWN has connections -> outdoor
S.mapClickCell = { cx = 5, cy = 6 }
local by = vy + VIEW_H + 8
Kit.beginFrame(vx + 150 + 10, by + 22 + 10, true) -- Set lastOutdoor here
MapBrowser.draw(S, Kit, px, py)
check(S.save.lastOutdoor ~= nil, "Set lastOutdoor here sets lastOutdoor")
if S.save.lastOutdoor then
eq(S.save.lastOutdoor.id, "PALLET_TOWN", "lastOutdoor.id")
eq(S.save.lastOutdoor.x, 5, "lastOutdoor.x")
eq(S.save.lastOutdoor.y, 6, "lastOutdoor.y")
local indoor
for id in pairs(Data.maps) do
local ok, map = pcall(MapLoader.load, Data, id)
if ok and not Ops.isOutdoor(S, map) then indoor = map break end
end
end
check(indoor ~= nil, "found an indoor map to try")
do
-- an interior with no connections and not in save.visited -> rejected
local S = newState()
S.mapId = "REDS_HOUSE_1F"
S.mapId = indoor.id
S.mapClickCell = { cx = 1, cy = 1 }
local by = vy + VIEW_H + 8
Kit.beginFrame(vx + 150 + 10, by + 22 + 10, true)
MapBrowser.draw(S, Kit, px, py)
check(S.save.lastOutdoor == nil, "Set lastOutdoor here refuses a non-outdoor map")
check(S.status:match("outdoor"), "status explains the refusal")
end
S.dirty = false
check(Ops.setLastOutdoor(S, indoor) == false,
"setLastOutdoor refuses a non-outdoor map")
check(S.dirty == false, "a refused setLastOutdoor does not dirty the save")
check(S.status:match("outdoor") ~= nil, "a refused setLastOutdoor explains itself")
-- ---------------------------------------------------------------- lastHeal
do
local S = newState()
S.mapClickCell = { cx = 2, cy = 8 }
local by = vy + VIEW_H + 8
Kit.beginFrame(vx + 320 + 10, by + 22 + 10, true) -- Set lastHeal here
MapBrowser.draw(S, Kit, px, py)
check(S.save.lastHeal ~= nil, "Set lastHeal here sets lastHeal")
eq(S.save.lastHeal.map, S.mapId, "lastHeal.map")
eq(S.save.lastHeal.x, 2, "lastHeal.x")
eq(S.save.lastHeal.y, 8, "lastHeal.y")
end
-- --------------------------------------------------------------- warp jump
do
local S = newState()
local pallet = MapLoader.load(Data, "PALLET_TOWN")
S.mapId = "PALLET_TOWN"
local map = require("src.world.MapLoader").load(Data, "PALLET_TOWN")
check(#map.def.warps > 0, "Pallet Town has warps to test with")
local w = map.def.warps[1]
local mx = vx + w.x * 16 * S.mapZoom + 4
local my = vy + w.y * 16 * S.mapZoom + 4
Kit.beginFrame(mx, my, true)
MapBrowser.draw(S, Kit, px, py)
check(S.mapId ~= "PALLET_TOWN" or w.destMap == "PALLET_TOWN",
"clicking a warp cell jumps S.mapId to its destination")
check(S.status:match("Followed warp"), "warp click sets a status message")
check(Ops.setLastOutdoor(S, pallet) == true, "setLastOutdoor accepts an outdoor map")
eq(S.save.lastOutdoor.id, "PALLET_TOWN", "setLastOutdoor records the map id")
eq(S.save.lastOutdoor.x, 1, "setLastOutdoor records the cell")
end
do
-- LAST_MAP warp with no remembered outdoor map must not crash, and
-- must not silently move the view.
local S = newState()
S.mapId = "REDS_HOUSE_1F"
local MapLoader = require("src.world.MapLoader")
local map = MapLoader.load(Data, "REDS_HOUSE_1F")
local lastMapWarp
for _, w in ipairs(map.def.warps) do
if w.destMap == "LAST_MAP" then lastMapWarp = w end
end
if lastMapWarp then
S.save.lastOutdoor = nil
local mx = vx + lastMapWarp.x * 16 * S.mapZoom + 4
local my = vy + lastMapWarp.y * 16 * S.mapZoom + 4
Kit.beginFrame(mx, my, true)
local ok = pcall(MapBrowser.draw, S, Kit, px, py)
check(ok, "LAST_MAP warp with no lastOutdoor doesn't crash")
eq(S.mapId, "REDS_HOUSE_1F", "LAST_MAP warp with no lastOutdoor doesn't move the view")
check(S.status:match("lastOutdoor"), "status explains the skipped warp")
else
check(true, "REDS_HOUSE_1F has no LAST_MAP warp to test (skipped)")
end
end
-- panel-owned view state ----------------------------------------------
do
-- Indigo Plateau uses tileset PLATEAU -> plateau.png (not indigo.png).
-- Following its lobby door must remember lastOutdoor so the lobby's
-- LAST_MAP mats return here (same as the game's outsideTilesets).
local S = newState()
S.mapId = "INDIGO_PLATEAU"
S.save.lastOutdoor = { id = "ROUTE_22", x = 8, y = 5 }
local MapLoader = require("src.world.MapLoader")
local indigo = MapLoader.load(Data, "INDIGO_PLATEAU")
eq(indigo.tileset.image, "assets/generated/tilesets/plateau.png",
"Indigo Plateau tileset image is plateau.png")
local door = indigo.def.warps[1]
local mx = vx + door.x * 16 * S.mapZoom + 4
local my = vy + door.y * 16 * S.mapZoom + 4
Kit.beginFrame(mx, my, true)
MapBrowser.draw(S, Kit, px, py)
eq(S.mapId, "INDIGO_PLATEAU_LOBBY", "Indigo door warp jumps to the lobby")
check(S.save.lastOutdoor and S.save.lastOutdoor.id == "INDIGO_PLATEAU",
"following Indigo door remembers lastOutdoor as INDIGO_PLATEAU")
local lobby = MapLoader.load(Data, "INDIGO_PLATEAU_LOBBY")
local exitWarp
for _, w in ipairs(lobby.def.warps) do
if w.destMap == "LAST_MAP" then exitWarp = w break end
end
check(exitWarp ~= nil, "Indigo lobby has a LAST_MAP exit")
-- re-zero the camera so the exit-cell click math matches cellAtScreen
S.mapCamX, S.mapCamY = 0, 0
mx = vx + exitWarp.x * 16 * S.mapZoom + 4
my = vy + exitWarp.y * 16 * S.mapZoom + 4
Kit.beginFrame(mx, my, true)
MapBrowser.draw(S, Kit, px, py)
eq(S.mapId, "INDIGO_PLATEAU", "Indigo lobby LAST_MAP exit returns to the plateau")
end
-- --------------------------------------------------------- zoom / pan input
do
local S = newState()
local z0 = S.mapZoom
MapBrowser.wheelmoved(S, 1)
check(S.mapZoom > z0, "wheelmoved(+) zooms in")
MapBrowser.wheelmoved(S, -1)
MapBrowser.wheelmoved(S, -1)
check(S.mapZoom < z0, "wheelmoved(-) zooms out")
S.mapZoom = 1
for _ = 1, 20 do MapBrowser.wheelmoved(S, -1) end
check(S.mapZoom >= 1, "zoom clamps at a minimum")
MapBrowser.select(S, "VIRIDIAN_CITY")
eq(S.mapId, "VIRIDIAN_CITY", "select switches the viewed map")
eq(S.mapClickCell, nil, "select drops the previous cell selection")
eq(S._mapCenteredFor, nil,
"select defers centring to the next draw, which knows the viewport size")
check(S.status:match("VIRIDIAN_CITY") ~= nil, "select says what it is showing")
end
do
local S = newState()
S.mapCamX, S.mapCamY = 0, 0
MapBrowser.keypressed(S, "d")
eq(S.mapCamX, 16, "keypressed d pans camera right by one cell")
MapBrowser.keypressed(S, "right")
eq(S.mapCamX, 16, "right pans one cell east")
MapBrowser.keypressed(S, "left")
eq(S.mapCamX, 0, "left pans back")
MapBrowser.keypressed(S, "down")
eq(S.mapCamY, 16, "keypressed down pans camera down by one cell")
MapBrowser.keypressed(S, "unrelatedkey")
eq(S.mapCamX, 16, "unrelated keys don't pan the camera")
eq(S.mapCamY, 16, "down pans one cell south")
MapBrowser.keypressed(S, "w")
eq(S.mapCamY, 0, "WASD pans as well as the arrows")
local before = S.mapCamX
MapBrowser.keypressed(S, "escape")
eq(S.mapCamX, before, "a non-pan key does not move the camera")
end
do
local S = newState()
S.mapZoom = 2
MapBrowser.wheelmoved(S, 1)
check(S.mapZoom > 2, "wheel up zooms in")
for _ = 1, 40 do MapBrowser.wheelmoved(S, 1) end
eq(S.mapZoom, 4, "zoom clamps at 4x")
for _ = 1, 80 do MapBrowser.wheelmoved(S, -1) end
eq(S.mapZoom, 1, "zoom clamps at 1x")
end
print(string.format("save editor task 8 tests: %d passed, %d failed", passed, failed))
+458 -74
View File
@@ -1,7 +1,20 @@
-- Save editor app shell: boots the game's generated Data + a save file,
-- and draws a tabbed immediate-mode UI over it via Kit. Panels own their
-- own tab content; this module owns the chrome (save/reload/status/tabs)
-- and the modal MonEditor overlay.
-- Save editor app shell. Boots the game's generated Data plus a save file
-- and draws the chrome the design spec fixes (SaveEditor.dc.html): a version
-- rail, a title bar, a tab rail and a status bar, with one panel filling the
-- space between. Panels own their tab's content; this module owns everything
-- around it.
--
-- The editor is reachable two ways and behaves the same in both:
-- * `love . --editor` standalone window, Close quits
-- * Edit on a launcher save row (main.lua, embedded = true), Close returns
-- to the launcher with the slot list refreshed
--
-- Vertical rhythm (scaled by Kit's height/768 factor, everything else flexes):
-- 0 6px tri-colour version rail, identical to the launcher's
-- 6 64px title bar identity, file chip, Save / Reload / Open / Close
-- 70 66px tab rail 6 tab tiles + right-aligned validation pill
-- 136 flex content one panel per tab, 20px gutters
-- -38 38px status bar the last Ops message + the keyboard map
local Data = require("src.core.Data")
local TileRenderer = require("src.render.TileRenderer")
@@ -9,6 +22,9 @@ local SaveIO = require("SaveIO")
local Catalog = require("Catalog")
local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local Party = require("Party")
local Boxes = require("Boxes")
@@ -16,7 +32,6 @@ local Items = require("Items")
local Events = require("Events")
local MapBrowser = require("MapBrowser")
local Dex = require("Dex")
local MonEditor = require("MonEditor")
local App = {}
local S
@@ -25,13 +40,23 @@ local S
local mods
local mouseClicked = false
-- Which game's cache Data was loaded from. main.lua checks this before
-- opening the editor on a save from the other version, because the two
-- caches cannot both be mounted in one process (see CacheFs.mountVersion).
App.dataVersion = nil
local TABS = {
{ id = "party", label = "Party" },
{ id = "boxes", label = "Boxes" },
{ id = "items", label = "Items" },
{ id = "events", label = "Events" },
{ id = "map", label = "Map" },
{ id = "dex", label = "Dex" },
{ id = "party", glyph = "PT", label = "PARTY" },
{ id = "boxes", glyph = "BX", label = "BOXES" },
{ id = "items", glyph = "IT", label = "ITEMS" },
{ id = "events", glyph = "EV", label = "EVENTS" },
{ id = "map", glyph = "MP", label = "MAP" },
{ id = "dex", glyph = "DX", label = "DEX" },
}
local PANELS = {
party = Party, boxes = Boxes, items = Items,
events = Events, map = MapBrowser, dex = Dex,
}
local function fileExists(path)
@@ -75,6 +100,7 @@ local function applyLoaded(path, statusVerb)
S._quitArmed = false
S._openArmed = false
S.editingMon = nil
Ops.disarm(S)
local boxes = require("src.pokemon.Boxes").ensure(S.save)
-- Imported .sav box mons have no stat block (box_struct stops before
-- MON_STATS). The game derives them in SaveData.validate; the editor
@@ -103,17 +129,31 @@ end
-- pathOverride lets tests point App.load at a scratch file instead of the
-- real default save path (used to exercise the corrupt-save branch below).
function App.load(pathOverride)
-- opts carries what only the launcher knows: which game the save belongs to,
-- its slot id, and where Close should go back to.
function App.load(pathOverride, opts)
opts = opts or {}
S = State.new()
S.data = Data
S.version = opts.version
S.slotId = opts.slotId
S.embedded = opts.embedded or false
S.onClose = opts.onClose
-- the same mod set the game loads, merged into Data before the catalogs
-- build, so modded species/items/moves are editable and MonOps stops
-- asserting on them
if not mods then
-- One loader per editor session. A previous session leaves Data holding
-- that session's merged registries (and possibly the other game's cache),
-- and a second builtin registration over them collides -- "statuses
-- already registered: FRZ". _pristineKeys only exists once Data has been
-- loaded at least once, so it doubles as the "needs evicting" marker.
if Data._pristineKeys then Data:unloadGenerated() end
Data:load()
local ModLoader = require("src.mods.Loader")
mods = ModLoader.new()
mods:load(Data)
App.dataVersion = opts.version
end
S.mods = mods
S.cat = Catalog.build(Data)
@@ -155,7 +195,7 @@ function App.chooseAndOpen()
end
function App.filedropped(file)
if not file then return end
if not (file and S) then return end
local path = file.getFilename and file:getFilename() or nil
if not path or path == "" then
S.status = "Could not read dropped file path"
@@ -171,6 +211,77 @@ function App.getState()
return S
end
-- Tear the editor down far enough that a later App.load rebuilds from
-- scratch. main.lua calls this after Close so the next Edit -- possibly on
-- the other game's save -- re-runs Data:load against whatever cache is
-- mounted by then, instead of reusing this session's merged registries.
function App.unload()
S = nil
mods = nil
App.dataVersion = nil
end
function App.save()
if not S.allowSave then
return Ops.say(S, "Save disabled, corrupt save loaded; fix the file and Reload first")
end
local ok, err = SaveIO.save(S.path, S.save)
if ok then
S.dirty = false
S._quitArmed = false
Ops.disarm(S)
S.status = "Saved " .. S.path
return true
end
S.status = "Save failed: " .. tostring(err)
return false
end
function App.reload()
local save, err = SaveIO.load(S.path)
if save then
S.save = save
S.dirty = false
S.loadError = false
S.allowSave = true
S._quitArmed = false
S._openArmed = false
S.editingMon = nil
S.status = "Reloaded " .. S.path
require("src.pokemon.Boxes").ensure(S.save)
return true
end
S.status = "Reload failed: " .. tostring(err)
return false
end
-- Close: back to the launcher when hosted there, otherwise quit. Unsaved
-- edits arm a confirm exactly like Open does, so leaving can't lose work.
--
-- The teardown itself is DEFERRED to the end of the frame (App.draw calls
-- finishClose below). Close is dispatched from inside drawTitleBar, and the
-- host's onClose runs App.unload, which drops S -- doing that inline left the
-- rest of the frame drawing against a nil state.
function App.close()
if S.dirty and not S._quitArmed then
S._quitArmed = true
S.status = "Unsaved changes, Save first or click Close again to discard"
return false
end
S._closeRequested = true
return true
end
local function finishClose()
local embedded, onClose = S.embedded, S.onClose
S._closeRequested = false
if embedded and onClose then
onClose()
elseif love and love.event then
love.event.quit()
end
end
function App.update(dt)
-- Immediate-mode UI: nothing to simulate per-frame; input is sampled
-- directly in App.draw() via Kit.beginFrame. Tile animation (water,
@@ -182,92 +293,365 @@ function App.mousepressed(x, y, button)
if button == 1 then mouseClicked = true end
end
function App.textinput(text)
Kit.textinput(text)
end
-- ------------------------------------------------------------------ chrome
-- The file chip: the single source of truth for "which file am I editing".
-- The path truncates from the LEFT so the filename is always readable, and
-- an amber dot plus the word UNSAVED calls out dirty state from any tab.
local function drawFileChip(x, y, w, h)
local s = Kit.scale
Theme.row(x, y, w, h, 10 * s, 0.6)
local pad = 14 * s
local dot = 8 * s
local cx = x + pad
if S.dirty then
Theme.col(PAL.yellow, 1)
if love.graphics.circle then
love.graphics.circle("fill", cx + dot / 2, y + h / 2, dot / 2)
else
love.graphics.rectangle("fill", cx, y + h / 2 - dot / 2, dot, dot)
end
cx = cx + dot + 8 * s
end
local label = S.dirty and "UNSAVED" or "SAVED"
local labelW = Kit.textWidth("tiny", label)
Kit.textRight("tiny", label, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2,
S.dirty and PAL.yellow or PAL.caption)
local avail = (x + w - pad - labelW - 10 * s) - cx
local shown = Theme.ellipsizeLeft(Kit.fonts.mono, S.path or "(no file)", avail)
Kit.text("mono", shown, cx, y + (h - Kit.textHeight("mono")) / 2, PAL.detail)
end
local function drawTitleBar(x, y, w, h)
local s = Kit.scale
local pad = 22 * s
Theme.col(PAL.cardBorder, 0.22)
love.graphics.rectangle("fill", x, y + h - 1, w, 1)
local cx = x + pad
-- SE badge, the same rounded-square chip shape the launcher's tabs use
local badge = 34 * s
local by = y + (h - badge) / 2
Theme.gradRounded(cx, by, badge, badge, 9 * s, PAL.chipTop, PAL.chipBot, 1, 1)
Kit.textCenter("tab", "SE", cx, by + (badge - Kit.textHeight("tab")) / 2, badge,
{ 159, 180, 221 })
cx = cx + badge + 10 * s
local wordH = Kit.textHeight("wordmark")
local brandH = Kit.textHeight("brand")
local blockY = y + (h - (wordH + 2 * s + brandH)) / 2
love.graphics.setFont(Kit.fonts.wordmark)
Theme.col(PAL.heading, 1)
local wordW = Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s)
love.graphics.setFont(Kit.fonts.brand)
Theme.col(PAL.caption, 1)
local brandW = Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx,
blockY + wordH + 2 * s, 1 * s)
cx = cx + math.max(wordW, brandW) + 12 * s
-- version chip: which game this save belongs to (from the launcher slot,
-- or the save's own header in a standalone run)
if S.version then
local name = S.version:upper()
local c = (S.version == "blue") and PAL.blue or PAL.red
local cw = Kit.textWidth("chip", name) + 16 * s
local ch = 22 * s
local cy = y + (h - ch) / 2
Theme.col(c, 0.1)
love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s)
Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1)
Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2, cw, c)
cx = cx + cw + 12 * s
end
-- right-aligned action cluster, laid out from the right edge inward so the
-- file chip can absorb whatever is left
local btnH = 38 * s
local btnY = y + (h - btnH) / 2
local rightEdge = x + w - pad
local gap = 8 * s
local closeW = 22 * s + Kit.textWidth("button", "Close")
local openW = 22 * s + Kit.textWidth("button", "Open...")
local reloadW = 22 * s + Kit.textWidth("button", "Reload")
local saveLabel, saveKind, saveEnabled = "SAVED", "disabled", false
if not S.allowSave then
saveLabel = "SAVE LOCKED"
elseif S.dirty then
saveLabel, saveKind, saveEnabled = "SAVE", "primary", true
end
local saveW = 30 * s + Kit.textWidth("button", saveLabel)
local closeX = rightEdge - closeW
local openX = closeX - gap - openW
local reloadX = openX - gap - reloadW
local saveX = reloadX - gap - saveW
-- Save is the only green-filled control in the chrome; a corrupt load
-- renders it steel with the reason parked in the status bar rather than
-- hiding it (rule 3 of the design spec).
if Kit.button(saveX, btnY, saveW, btnH, saveLabel,
{ kind = saveKind, enabled = saveEnabled or not S.allowSave,
glow = S.dirty and S.allowSave and 0.6 or nil }) then
App.save()
end
if Kit.button(reloadX, btnY, reloadW, btnH, "Reload") then App.reload() end
if Kit.button(openX, btnY, openW, btnH, "Open...") then App.chooseAndOpen() end
if Kit.button(closeX, btnY, closeW, btnH,
S._quitArmed and "Discard?" or "Close",
{ kind = S._quitArmed and "danger" or "ghost" }) then
App.close()
end
local chipW = (saveX - 14 * s) - cx
if chipW > 80 * s then
drawFileChip(cx, y + (h - 38 * s) / 2, chipW, 38 * s)
end
end
-- Per-tab counters shown under each tile, so the rail doubles as a summary.
local function tabCount(id)
if id == "party" then
return ("%d/%d"):format(#S.save.party, require("src.pokemon.Party").MAX)
elseif id == "boxes" then
local n = 0
for _, box in ipairs(Ops.boxes(S)) do n = n + #box end
return tostring(n)
elseif id == "items" then
local Bag = require("src.inventory.Bag")
return ("%d/%d"):format(Bag.slots(S.save), Bag.CAPACITY)
elseif id == "events" then
local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end
return tostring(n)
elseif id == "map" then
-- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label
return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale)
elseif id == "dex" then
local _, owned, total = Ops.dexCounts(S)
return ("%d/%d"):format(owned, total)
end
return ""
end
-- The validation pill mirrors SaveData.validate: green when the report is
-- empty, yellow with counts when the running game would quarantine
-- something. Returns what to draw plus the tab that owns the first problem,
-- so the rail can reserve the pill's width before laying out the tiles.
local function validationPill()
local SaveData = require("src.core.SaveData")
local report = S.validation
if not report or SaveData.emptyReport(report) then
return "Save validates clean", PAL.green, nil, true
end
local parts = {}
local target
local function add(n, singular, plural, tab)
if n <= 0 then return end
parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
target = target or tab
end
add(#report.lostMons, "mon", "mons", "party")
add(#report.lostItems, "item", "items", "items")
add(#report.remappedMaps, "map", "maps", "map")
return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false
end
-- Tab tiles degrade rather than collide: at full width each tile carries its
-- glyph, label and counter; when the validation pill would overlap, the
-- counters drop first and then the labels, leaving the 2-letter glyphs. A
-- tile is always at least its own square, so every tab stays clickable.
local function railDetail(x, pillX)
local s = Kit.scale
local tile = 40 * s
local widths = { full = 0, nocount = 0, glyph = 0 }
for _, t in ipairs(TABS) do
local labelW = Theme.spacedWidth(Kit.fonts.tab, t.label, 1.5 * s)
local countW = Kit.textWidth("tiny", tabCount(t.id))
widths.full = widths.full + tile + 9 * s + labelW + 8 * s + countW + 20 * s
widths.nocount = widths.nocount + tile + 9 * s + labelW + 20 * s
widths.glyph = widths.glyph + tile + 12 * s
end
local avail = pillX - 14 * s - (x + 22 * s)
if widths.full <= avail then return "full" end
if widths.nocount <= avail then return "nocount" end
return "glyph"
end
local function drawTabRail(x, y, w, h)
local s = Kit.scale
local pad = 22 * s
Theme.col(PAL.cardBorder, 0.22)
love.graphics.rectangle("fill", x, y + h - 1, w, 1)
local label, pillColor, target, clean = validationPill()
local ph = 26 * s
local pw = Kit.textWidth("small", label) + 28 * s
local px = x + w - pad - pw
local detail = railDetail(x, px)
local tile = 40 * s
local cx = x + pad
local tileY = y + h - 12 * s - tile
for _, t in ipairs(TABS) do
local active = (S.tab == t.id)
local count = (detail == "full") and tabCount(t.id) or ""
local labelW = (detail == "glyph") and 0
or Theme.spacedWidth(Kit.fonts.tab, t.label, 1.5 * s)
local countW = Kit.textWidth("tiny", count)
local cellW = (detail == "glyph") and (tile + 12 * s)
or (tile + 9 * s + labelW + (count ~= "" and 8 * s + countW or 0) + 20 * s)
Theme.gradRounded(cx, tileY, tile, tile, 11 * s, PAL.chipTop, PAL.chipBot, 1, 1)
Kit.textCenter("tile", t.glyph, cx, tileY + (tile - Kit.textHeight("tile")) / 2,
tile, PAL.chipInk)
if not active then
-- one tile style plus a scrim, the same trick the launcher uses
Theme.col(PAL.bgBot, 0.55)
love.graphics.rectangle("fill", cx, tileY, tile, tile, 11 * s, 11 * s)
else
Theme.stroke(cx, tileY, tile, tile, 11 * s, PAL.blue, 0.75, 1.5 * s)
end
if detail ~= "glyph" then
local lx = cx + tile + 9 * s
love.graphics.setFont(Kit.fonts.tab)
Theme.col(active and PAL.heading or PAL.muted, 1)
Theme.spaced(Kit.fonts.tab, t.label, lx,
tileY + (tile - Kit.textHeight("tab")) / 2, 1.5 * s)
if count ~= "" then
Kit.text("tiny", count, lx + labelW + 8 * s,
tileY + (tile - Kit.textHeight("tiny")) / 2, PAL.faint)
end
end
if active then
Theme.col(PAL.blue, 1)
love.graphics.rectangle("fill", cx, y + h - 3 * s,
math.max(tile, cellW - 20 * s), 3 * s)
end
if Kit.press(cx - 8 * s, y, cellW, h) then
S.tab = t.id
Kit.blur()
Ops.disarm(S)
end
cx = cx + cellW
end
local py = y + h - 14 * s - ph
Theme.col(pillColor, clean and 0.08 or 0.1)
love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2)
Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1)
Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2, pw,
pillColor)
if target and Kit.press(px, py, pw, ph) then
S.tab = target
Ops.say(S, "Jumped to the tab holding the first quarantine warning")
end
end
local function drawStatusBar(x, y, w, h)
local s = Kit.scale
local pad = 22 * s
Theme.col(PAL.bgBot, 0.6)
love.graphics.rectangle("fill", x, y, w, h)
Theme.col(PAL.cardBorder, 0.22)
love.graphics.rectangle("fill", x, y, w, 1)
local ctrl = (love.system and love.system.getOS
and love.system.getOS() == "OS X") and "Cmd" or "Ctrl"
local hint = S.embedded
and (ctrl .. "+S save . " .. ctrl ..
"+R reload . Esc clear selection . Close returns to the launcher")
or (ctrl .. "+S save . " .. ctrl ..
"+R reload . Esc clear selection . arrows pan map . wheel zoom")
local hintW = Kit.textWidth("tiny", hint)
Kit.textRight("tiny", hint, x + w - pad, y + (h - Kit.textHeight("tiny")) / 2, PAL.faint)
local avail = w - 2 * pad - hintW - 14 * s
Kit.text("mono", Kit.ellipsize("mono", S.status or "", avail), x + pad,
y + (h - Kit.textHeight("mono")) / 2, PAL.detail)
end
function App.draw()
-- Closing the editor unloads it, and the host may still deliver one more
-- frame or a queued event before it re-routes; every entry point below
-- tolerates that rather than indexing a torn-down state.
if not S then return end
local width, height = love.graphics.getDimensions()
Kit.layout(width, height)
local s = Kit.scale
local mx, my = love.mouse.getPosition()
Kit.beginFrame(mx, my, mouseClicked)
mouseClicked = false
local wasDirty = S.dirty
Theme.field(width, height)
love.graphics.clear(0.08, 0.08, 0.1)
Kit.label(12, 10, "Save Editor")
local railH = 6 * s
local titleH = 64 * s
local tabH = 66 * s
local statusH = 38 * s
local saveLabel = S.dirty and "Save*" or "Save"
if S.loadError then saveLabel = saveLabel .. " (disabled)" end
if Kit.button(110, 6, 80, 28, saveLabel) then
if not S.allowSave then
S.status = "Save disabled, corrupt save loaded; fix the file and Reload first"
else
local ok, err = SaveIO.save(S.path, S.save)
if ok then
S.dirty = false
S._quitArmed = false
S.status = "Saved " .. S.path
else
S.status = "Save failed: " .. tostring(err)
end
end
Theme.versionRail(0, 0, width, railH)
drawTitleBar(0, railH, width, titleH)
drawTabRail(0, railH + titleH, width, tabH)
local contentY = railH + titleH + tabH
local contentH = height - contentY - statusH
local panel = PANELS[S.tab]
if panel then
panel.draw(S, Kit, 22 * s, contentY + 20 * s,
width - 44 * s, contentH - 38 * s)
end
if Kit.button(200, 6, 80, 28, "Reload") then
local save, err = SaveIO.load(S.path)
if save then
S.save = save
S.dirty = false
S.loadError = false
S.allowSave = true
S._quitArmed = false
S._openArmed = false
S.status = "Reloaded " .. S.path
require("src.pokemon.Boxes").ensure(S.save)
else
S.status = "Reload failed: " .. tostring(err)
end
end
drawStatusBar(0, height - statusH, width, statusH)
Kit.endFrame()
if Kit.button(290, 6, 80, 28, "Open...") then
App.chooseAndOpen()
end
Kit.label(380, 12, S.status)
local newTab = Kit.tabs(12, 44, TABS, S.tab)
if newTab then S.tab = newTab end
local panelY = 80
if S.tab == "party" then Party.draw(S, Kit, 12, panelY)
elseif S.tab == "boxes" then Boxes.draw(S, Kit, 12, panelY)
elseif S.tab == "items" then Items.draw(S, Kit, 12, panelY)
elseif S.tab == "events" then Events.draw(S, Kit, 12, panelY)
elseif S.tab == "map" then MapBrowser.draw(S, Kit, 12, panelY)
elseif S.tab == "dex" then Dex.draw(S, Kit, 12, panelY)
end
if S.editingMon then
MonEditor.draw(S, Kit, 640, panelY)
end
-- Any panel above may have just set S.dirty = true; re-arm the quit
-- confirmation so a fresh round of edits needs its own "quit again".
if not wasDirty and S.dirty then
S._quitArmed = false
end
-- Only now, with the whole frame painted, is it safe to drop the editor.
if S._closeRequested then finishClose() end
end
function App.keypressed(key)
if key == "escape" then S.editingMon = nil end
if not S then return end
-- A focused text field eats the keys it cares about (typing "s" into the
-- map filter must not trigger Save).
if Kit.keypressed(key) then return end
if Kit.focus then
if key == "escape" then Kit.blur() end
return
end
-- Save and Reload both touch the file on disk (Reload discards unsaved
-- edits), so they need a modifier. A bare letter is one stray keystroke
-- away from a write, and the editor has no undo.
local mod = love.keyboard and love.keyboard.isDown
and (love.keyboard.isDown("lgui", "rgui")
or love.keyboard.isDown("lctrl", "rctrl"))
if key == "escape" then
S.editingMon = nil
Ops.disarm(S)
Ops.say(S, "Selection cleared")
elseif key == "s" and mod then
App.save()
elseif key == "r" and mod then
App.reload()
end
if S.tab == "map" and MapBrowser.keypressed then
MapBrowser.keypressed(S, key)
end
end
function App.wheelmoved(x, y)
if not S then return end
if S.tab == "map" and MapBrowser.wheelmoved then
MapBrowser.wheelmoved(S, y)
end
end
function App.quit()
if not S then return false end
if S.dirty then
-- simple: block quit once and set status; user saves or force-quits again
if not S._quitArmed then
+60 -28
View File
@@ -17,31 +17,69 @@ function Catalog.build(data)
}
end
-- extraDirs: loaded mods' roots, so MOD_-prefixed flags defined in mod
-- scripts show up beside the vanilla EVENT_ ones
function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
listFiles = listFiles or function(dir)
local out = {}
if package.config:sub(1, 1) == "\\" then
-- cmd has no ls; dir /b prints bare names, so re-attach the directory
local p = io.popen(string.format('dir /b "%s\\*.lua" 2>nul', dir))
if p then
for line in p:lines() do
if line ~= "" then table.insert(out, dir .. "/" .. line) end
end
p:close()
end
return out
end
local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir))
-- Directory listing / file reading go through love.filesystem when it is
-- available, and only fall back to shelling out. That is what makes the
-- Events tab work in a packaged build: data/scripts is inside the .love
-- archive, data/generated is mounted from the save directory, and mods live
-- under the save directory too -- none of which io.open can reach by relative
-- path. The io.popen path stays for headless runs (tests/, plain lua) where
-- love.filesystem does not exist.
-- nil means "love.filesystem cannot see this directory", which is the signal
-- to fall through to the shell -- headless runs mount a stub filesystem that
-- knows nothing about the checkout, but their io.* can still read it.
local function loveListLua(dir)
local fs = love and love.filesystem
if not (fs and fs.getDirectoryItems and fs.getInfo) then return nil end
if not fs.getInfo(dir) then return nil end
local out = {}
for _, name in ipairs(fs.getDirectoryItems(dir)) do
if name:sub(-4) == ".lua" then out[#out + 1] = dir .. "/" .. name end
end
return out
end
local function shellListLua(dir)
local out = {}
if package.config:sub(1, 1) == "\\" then
-- cmd has no ls; dir /b prints bare names, so re-attach the directory
local p = io.popen(string.format('dir /b "%s\\*.lua" 2>nul', dir))
if p then
for line in p:lines() do
table.insert(out, line)
if line ~= "" then table.insert(out, dir .. "/" .. line) end
end
p:close()
end
return out
end
local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir))
if p then
for line in p:lines() do
table.insert(out, line)
end
p:close()
end
return out
end
local function readText(path)
local fs = love and love.filesystem
if fs and fs.read and fs.getInfo and fs.getInfo(path) then
local body = fs.read(path)
if body then return body end
end
local f = io.open(path, "r")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
-- extraDirs: loaded mods' roots, so MOD_-prefixed flags defined in mod
-- scripts show up beside the vanilla EVENT_ ones
function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
listFiles = listFiles or function(dir)
return loveListLua(dir) or shellListLua(dir)
end
local found = {}
local function eat(text)
@@ -59,20 +97,14 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
end
for _, dir in ipairs(dirs) do
for _, path in ipairs(listFiles(dir)) do
local f = io.open(path, "r")
if f then
eat(f:read("*a"))
f:close()
end
local body = readText(path)
if body then eat(body) end
end
end
if headerPath then
local f = io.open(headerPath, "r")
if f then
eat(f:read("*a"))
f:close()
end
local body = readText(headerPath)
if body then eat(body) end
end
return sortedKeys(found)
+365 -56
View File
@@ -1,84 +1,393 @@
-- Minimal immediate-mode, mouse-based UI kit for the save editor.
-- Call Kit.beginFrame(mx, my, clicked) once per love.draw() before using
-- any widget below; widgets read the frame's mouse state to decide hover
-- / click.
-- Immediate-mode widget kit for the save editor, drawn in the launcher's
-- visual language (see Theme.lua and SaveEditor.dc.html).
--
-- Call Kit.beginFrame(mx, my, clicked) once per love.draw() before any widget
-- and Kit.endFrame() after the last one; widgets read the frame's mouse state
-- to decide hover / click, and endFrame retires the text-input queue so a
-- keystroke is never applied twice.
--
-- Hit testing is a plain rect with no z-order, so panels must draw
-- overlapping controls in dispatch order and every target is >= 26px tall
-- (rule 6 of the design spec) -- that sizing is the whole accessibility story
-- here.
local Theme = require("Theme")
local PAL = Theme.PAL
local Kit = {}
Kit.mouseX, Kit.mouseY = 0, 0
Kit.mouseClicked = false -- left button pressed this frame
Kit.hotField = nil
Kit.font = nil
Kit.mouseClicked = false -- left button pressed this frame
Kit.focus = nil -- id of the text field receiving keystrokes
Kit.time = 0
Kit.fonts = {}
Kit.scale = 1
local G = love and love.graphics or nil
local edits = {} -- queued textinput / backspace since the last frame
local function canPrintf()
return G and type(G.printf) == "function"
end
function Kit.beginFrame(mx, my, clicked)
Kit.mouseX, Kit.mouseY = mx, my
Kit.mouseClicked = clicked
if love and love.timer and love.timer.getTime then
Kit.time = love.timer.getTime()
end
end
local function hit(x, y, w, h)
-- Retire this frame's keystrokes. Anything typed while no field had focus is
-- dropped here rather than replayed into the next field that gets clicked.
function Kit.endFrame()
for i = #edits, 1, -1 do edits[i] = nil end
end
-- Rebuild the font set when the window size changes. `s` matches the
-- launcher's height/768 scale so both windows step together.
function Kit.layout(width, height)
local s = Theme.clamp(height / 768, 0.7, 1.6)
local key = ("%dx%d"):format(width, height)
if Kit._fontKey ~= key then
Kit._fontKey = key
Kit.fonts = Theme.fonts(s)
Kit.scale = s
end
return s
end
-- ------------------------------------------------------------ input plumbing
-- App forwards love.textinput / love.keypressed here so Kit.textfield can be a
-- real editable field. Events arrive before draw, so they queue and the
-- focused field drains them while it renders.
function Kit.textinput(text)
if not Kit.focus then return false end
edits[#edits + 1] = text
return true
end
-- Returns true when the key was consumed by the focused field, so App can
-- leave its own shortcuts alone while the user is typing.
function Kit.keypressed(key)
if not Kit.focus then return false end
if key == "backspace" then
edits[#edits + 1] = "\b"
return true
elseif key == "return" or key == "kpenter" or key == "escape" then
edits[#edits + 1] = "\r"
return true
end
-- other keys (arrows, shortcuts) fall through to App while a field is hot
return false
end
function Kit.blur() Kit.focus = nil end
-- ------------------------------------------------------------- hit testing
function Kit.hit(x, y, w, h)
return Kit.mouseX >= x and Kit.mouseX <= x + w
and Kit.mouseY >= y and Kit.mouseY <= y + h
end
function Kit.label(x, y, text)
love.graphics.setColor(0.9, 0.9, 0.9)
love.graphics.print(text, x, y)
function Kit.hover(x, y, w, h)
return Kit.hit(x, y, w, h)
end
function Kit.button(x, y, w, h, label)
local hover = hit(x, y, w, h)
if hover then love.graphics.setColor(0.25, 0.35, 0.5)
else love.graphics.setColor(0.15, 0.15, 0.18) end
love.graphics.rectangle("fill", x, y, w, h, 4, 4)
love.graphics.setColor(1, 1, 1)
love.graphics.print(label, x + 8, y + h / 2 - 6)
return hover and Kit.mouseClicked
function Kit.press(x, y, w, h)
return Kit.mouseClicked and Kit.hit(x, y, w, h)
end
function Kit.checkbox(x, y, checked, label)
local on = Kit.button(x, y, 22, 22, checked and "X" or "")
Kit.label(x + 28, y + 4, label)
if on then return not checked, true end
-- ------------------------------------------------------------------- text
local function font(name)
return Kit.fonts[name] or Kit.fonts.small
end
function Kit.text(name, str, x, y, c, a)
if not G then return 0 end
local f = font(name)
if not f then return 0 end
G.setFont(f)
Theme.col(c or PAL.text, a or 1)
G.print(tostring(str), x, y)
return f:getWidth(tostring(str))
end
function Kit.textRight(name, str, x2, y, c, a)
local f = font(name)
if not f then return end
Kit.text(name, str, x2 - f:getWidth(tostring(str)), y, c, a)
end
function Kit.textCenter(name, str, x, y, w, c, a)
if not G then return end
local f = font(name)
if not f then return end
G.setFont(f)
Theme.col(c or PAL.text, a or 1)
if canPrintf() then
G.printf(tostring(str), x, y, w, "center")
else
G.print(tostring(str), x + (w - f:getWidth(tostring(str))) / 2, y)
end
end
function Kit.textHeight(name)
local f = font(name)
return f and f:getHeight() or 12
end
function Kit.textWidth(name, str)
local f = font(name)
return f and f:getWidth(tostring(str)) or 0
end
function Kit.ellipsize(name, str, maxW)
return Theme.ellipsize(font(name), str, maxW)
end
-- 12px / 2px-tracked uppercase section caption -- the design's one and only
-- section header. Returns the caption's height so callers can stack below.
function Kit.caption(x, y, str, c)
if not G then return Kit.textHeight("caption") end
local f = font("caption")
if not f then return 12 end
G.setFont(f)
Theme.col(c or PAL.caption, 1)
Theme.spaced(f, str, x, y, 2 * Kit.scale)
return f:getHeight()
end
function Kit.captionWidth(str)
return Theme.spacedWidth(font("caption"), str, 2 * Kit.scale)
end
-- --------------------------------------------------------------- surfaces
function Kit.card(x, y, w, h, r)
Theme.card(x, y, w, h, r or 16 * Kit.scale)
end
-- A list row. `selected` rings it in the accent colour (green for "this is
-- the thing you are editing", blue for "this is the thing you are browsing")
-- instead of filling it, so sprites and HP colours stay readable. Returns
-- true when the row was clicked this frame.
function Kit.row(x, y, w, h, selected, accent, r)
r = r or 12 * Kit.scale
if not G then return Kit.press(x, y, w, h) end
accent = accent or PAL.green
if selected then Theme.glow(x, y, w, h, r, accent, 0.45) end
Theme.row(x, y, w, h, r, 0.6)
if selected then
Theme.stroke(x, y, w, h, r, accent, 0.85, 1.5 * Kit.scale)
end
return Kit.press(x, y, w, h)
end
function Kit.meter(x, y, w, h, pct, c)
Theme.meter(x, y, w, h, pct, c)
end
-- Dashed empty-state box with a centred hint.
function Kit.emptyBox(x, y, w, h, message)
if not G then return end
Theme.col(PAL.cardBorder, 0.4)
if G.setLineWidth then G.setLineWidth(math.max(1, 1 * Kit.scale)) end
Theme.dashed(x, y, w, h, 12 * Kit.scale, 7 * Kit.scale, 5 * Kit.scale)
if G.setLineWidth then G.setLineWidth(1) end
local f = font("button")
if not f then return end
Kit.textCenter("button", message, x + 12 * Kit.scale,
y + h / 2 - f:getHeight() / 2, w - 24 * Kit.scale, PAL.muted)
end
-- --------------------------------------------------------------- buttons
-- Button kinds, straight out of the spec's colour semantics:
-- primary green gradient -- the single "commit this" control (Save)
-- ghost glassy white -- neutral verbs (Reload, Open, Add)
-- accent blue tint -- steppers, pagers, in-panel navigation
-- good green tint -- safe helpers (Full heal, max a DV)
-- danger red tint -- destructive verbs, always two-click
-- disabled steel -- never hidden, always explained in the status bar
local KINDS = {
primary = { fillTop = PAL.green, fillBot = PAL.greenDark, aTop = 1, aBot = 1,
ink = PAL.greenInk, border = nil, glow = PAL.green },
ghost = { fillTop = { 255, 255, 255 }, fillBot = { 255, 255, 255 },
aTop = 0.14, aBot = 0.03, ink = PAL.heading,
border = { 255, 255, 255 }, borderA = 0.18 },
accent = { flat = PAL.blue, flatA = 0.14, ink = PAL.blueInk,
border = PAL.cardBorder, borderA = 0.35 },
good = { flat = PAL.green, flatA = 0.1, ink = PAL.green,
border = PAL.green, borderA = 0.45 },
danger = { flat = PAL.red, flatA = 0.12, ink = PAL.redSoft,
border = PAL.red, borderA = 0.45 },
disabled = { flat = { 120, 132, 158 }, flatA = 0.22, ink = PAL.steel,
border = PAL.steel, borderA = 0.3 },
}
-- opts: { kind, font, enabled, align, radius, glow }
-- Returns true when clicked (never when disabled).
function Kit.button(x, y, w, h, label, opts)
opts = opts or {}
local enabled = opts.enabled ~= false
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
local r = opts.radius or 10 * Kit.scale
local hot = enabled and Kit.hover(x, y, w, h)
if G then
if opts.glow and enabled then
Theme.glow(x, y, w, h, r, kind.glow or PAL.green, opts.glow)
end
if kind.flat then
Theme.col(kind.flat, kind.flatA * (hot and 1.6 or 1))
G.rectangle("fill", x, y, w, h, r, r)
else
Theme.gradRounded(x, y, w, h, r, kind.fillTop, kind.fillBot,
kind.aTop * (hot and 1.4 or 1), kind.aBot * (hot and 1.6 or 1))
end
if kind.border then
Theme.stroke(x, y, w, h, r, kind.border, kind.borderA * (hot and 1.5 or 1), 1)
end
local f = font(opts.font or "button")
if f then
G.setFont(f)
Theme.col(kind.ink, 1)
local ty = y + (h - f:getHeight()) / 2
if opts.align == "left" then
G.print(label, x + 10 * Kit.scale, ty)
elseif canPrintf() then
G.printf(label, x, ty, w, "center")
else
G.print(label, x + (w - f:getWidth(label)) / 2, ty)
end
end
end
return enabled and Kit.press(x, y, w, h) or false
end
-- A small square control: the +/- steppers, the arrow cyclers, the row ✕.
function Kit.stepper(x, y, w, h, glyph, opts)
opts = opts or {}
opts.kind = opts.kind or "accent"
opts.font = opts.font or "small"
opts.radius = opts.radius or 6 * Kit.scale
return Kit.button(x, y, w, h, glyph, opts)
end
-- A pill toggle (badges, dex SEEN/OWN, event sub-tabs). `on` colours it;
-- returns true when clicked.
function Kit.chip(x, y, w, h, label, on, onColor, offColor)
local c = on and (onColor or PAL.green) or (offColor or PAL.steel)
if G then
local r = 6 * Kit.scale
Theme.col(c, on and 0.16 or 0.06)
G.rectangle("fill", x, y, w, h, r, r)
Theme.stroke(x, y, w, h, r, PAL.cardBorder, Kit.hover(x, y, w, h) and 0.5 or 0.28, 1)
Kit.textCenter("micro", label, x, y + (h - Kit.textHeight("micro")) / 2, w,
c, on and 1 or 0.75)
end
return Kit.press(x, y, w, h)
end
-- Checkbox row: a 20px box plus a mono label, the Events grid's unit.
-- Returns (newChecked, changed) so callers can write true/nil on a flip.
function Kit.checkbox(x, y, w, h, checked, label, labelColor)
local clicked = Kit.row(x, y, w, h, false, nil, 9 * Kit.scale)
local box = 20 * Kit.scale
local bx, by = x + 12 * Kit.scale, y + (h - box) / 2
if G then
Theme.col(checked and PAL.green or PAL.rowBg, checked and 1 or 0.9)
G.rectangle("fill", bx, by, box, box, 5 * Kit.scale, 5 * Kit.scale)
Theme.stroke(bx, by, box, box, 5 * Kit.scale, PAL.cardBorder, 0.4, 1)
if checked then
Kit.textCenter("small", "X", bx, by + (box - Kit.textHeight("small")) / 2,
box, PAL.greenInk)
end
local lx = bx + box + 12 * Kit.scale
Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale), lx,
y + (h - Kit.textHeight("mono")) / 2, labelColor or (checked and PAL.text or PAL.muted))
end
if clicked then return not checked, true end
return checked, false
end
function Kit.list(x, y, w, h, items, selected, rowH)
rowH = rowH or 22
love.graphics.setColor(0.1, 0.1, 0.12)
love.graphics.rectangle("fill", x, y, w, h)
local clickedIndex = nil
local maxRows = math.floor(h / rowH)
for i = 1, math.min(#items, maxRows) do
local ry = y + (i - 1) * rowH
if i == selected then
love.graphics.setColor(0.2, 0.4, 0.7)
love.graphics.rectangle("fill", x, ry, w, rowH)
end
love.graphics.setColor(1, 1, 1)
love.graphics.print(items[i], x + 6, ry + 4)
if Kit.mouseClicked and hit(x, ry, w, rowH) then
clickedIndex = i
-- --------------------------------------------------------------- text field
-- A real editable field. The Events filter used to edge-detect love.keyboard
-- state because Kit had no input widget; this replaces that hack, and App
-- routes love.textinput / love.keypressed in through Kit.textinput /
-- Kit.keypressed. Returns the (possibly edited) value; the caller stores it.
function Kit.textfield(id, x, y, w, h, value, placeholder)
value = tostring(value or "")
if Kit.press(x, y, w, h) then Kit.focus = id end
local focused = (Kit.focus == id)
if focused then
for _, e in ipairs(edits) do
if e == "\b" then
value = value:sub(1, -2)
elseif e == "\r" then
Kit.focus = nil
focused = false
else
value = value .. e
end
end
end
return clickedIndex
if G then
local r = 8 * Kit.scale
Theme.col(PAL.rowBg, 0.7)
G.rectangle("fill", x, y, w, h, r, r)
Theme.stroke(x, y, w, h, r, focused and PAL.blue or PAL.cardBorder,
focused and 0.8 or 0.3, focused and 1.5 * Kit.scale or 1)
local pad = 10 * Kit.scale
local ty = y + (h - Kit.textHeight("mono")) / 2
if value == "" and not focused then
Kit.text("mono", placeholder or "", x + pad, ty, PAL.faint)
else
local shown = Theme.ellipsizeLeft(font("mono"), value, w - 2 * pad)
local tw = Kit.text("mono", shown, x + pad, ty, PAL.heading)
-- caret: blinks only while focused, parked at the end of the text
if focused and (Kit.time % 1) < 0.55 then
Theme.col(PAL.blue, 1)
G.rectangle("fill", x + pad + tw + 2, ty, math.max(1, Kit.scale),
Kit.textHeight("mono"))
end
end
end
return value
end
-- Tabs: returns new tab id if clicked
function Kit.tabs(x, y, tabs, current)
local tx = x
for _, t in ipairs(tabs) do
local label = t.label
local w = 8 * #label + 24
local active = current == t.id
love.graphics.setColor(active and 0.3 or 0.15, active and 0.45 or 0.15, active and 0.7 or 0.18)
love.graphics.rectangle("fill", tx, y, w, 28, 4, 4)
love.graphics.setColor(1, 1, 1)
love.graphics.print(label, tx + 12, y + 7)
if Kit.mouseClicked and hit(tx, y, w, 28) then
return t.id
end
tx = tx + w + 4
-- ------------------------------------------------------------------ pager
-- Prev / Next / "1-12 of 151". Drawn even when there is a single page, so a
-- list is never silently truncated (rule 5 of the design spec). Returns the
-- new offset.
function Kit.pager(x, y, w, offset, total, perPage)
local h = 30 * Kit.scale
local bw = 74 * Kit.scale
local maxOffset = math.max(0, total - perPage)
offset = Theme.clamp(offset or 0, 0, maxOffset)
if Kit.button(x, y, bw, h, "Prev", { kind = "accent", font = "small",
enabled = offset > 0, radius = 8 * Kit.scale }) then
offset = math.max(0, offset - perPage)
end
return nil
if Kit.button(x + bw + 10 * Kit.scale, y, bw, h, "Next", { kind = "accent",
font = "small", enabled = offset < maxOffset, radius = 8 * Kit.scale }) then
offset = math.min(maxOffset, offset + perPage)
end
local shown = math.min(perPage, math.max(0, total - offset))
local label = ("%d-%d of %d"):format(total > 0 and offset + 1 or 0,
offset + shown, total)
Kit.text("mono", label, x + 2 * bw + 20 * Kit.scale,
y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
return offset, h
end
-- Clip drawing to a rect (list bodies). No-ops under the headless stub.
function Kit.pushClip(x, y, w, h)
if G and G.setScissor then
G.setScissor(math.floor(x), math.floor(y), math.ceil(w), math.ceil(h))
end
end
function Kit.popClip()
if G and G.setScissor then G.setScissor() end
end
return Kit
+575
View File
@@ -0,0 +1,575 @@
-- Every mutation the save editor can make to a loaded save, behind one
-- funnel: Ops.mark() is the ONLY thing that sets S.dirty, and it always
-- writes the status line at the same time. That is rule 2 of the design
-- spec (SaveEditor.dc.html) -- dirty state has to be visible from any tab,
-- and no branch may silently no-op. Panels above this file only lay out
-- pixels and dispatch; the rules live here, which is also what makes them
-- testable without a window (tests/save_editor_*).
--
-- 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).
local Pokemon = require("src.pokemon.Pokemon")
local PartyMod = require("src.pokemon.Party")
local BoxesMod = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local MonOps = require("MonOps")
local Ops = {}
Ops.MONEY_MAX = 999999
Ops.STACK_MAX = 99
Ops.ARM_SECONDS = 2.5
local function clamp(n, lo, hi)
if n < lo then return lo end
if n > hi then return hi end
return n
end
Ops.clamp = clamp
local function now()
if love and love.timer and love.timer.getTime then
return love.timer.getTime()
end
return nil
end
-- ------------------------------------------------------------ the funnel
-- Mark the save dirty and say what changed. Also disarms any pending
-- destructive confirmation: doing something else is an implicit "no".
function Ops.mark(S, msg)
S.dirty = true
S.status = msg or S.status
S.armed = nil
-- A fresh edit invalidates any prior "leave anyway" arming: quitting,
-- closing or opening another file has to be confirmed again, so a stale
-- confirmation from an earlier round of edits cannot discard these.
S._quitArmed = false
S._openArmed = false
return true
end
-- Status-only: used by the branches that refuse (party full, box full, no
-- cell selected). A refusal must still speak, it must just not dirty.
function Ops.say(S, msg)
S.status = msg
return false
end
-- Two-click confirm for destructive verbs. The first call arms `id` and
-- returns false; a second call with the same id inside ARM_SECONDS returns
-- true and disarms. Panels label the button through Ops.armLabel.
function Ops.arm(S, id, msg)
local t = now()
if S.armed == id then
local at = S.armedAt
if not (t and at and (t - at) > Ops.ARM_SECONDS) then
S.armed, S.armedAt = nil, nil
return true
end
end
S.armed, S.armedAt = id, t
S.status = msg
return false
end
-- The label a destructive button should carry right now.
function Ops.armLabel(S, id, label)
if S.armed ~= id then return label end
local t, at = now(), S.armedAt
if t and at and (t - at) > Ops.ARM_SECONDS then
S.armed, S.armedAt = nil, nil
return label
end
return "Confirm?"
end
function Ops.disarm(S)
S.armed, S.armedAt = nil, nil
end
-- ------------------------------------------------------------------ party
function Ops.selectParty(S, index)
local mon = S.save.party[index]
if not mon then return false end
S.selectedParty = index
S.editingMon = mon
S.status = ("Selected party slot %d (%s)"):format(index, mon.species)
return true
end
function Ops.partyAdd(S)
if #S.save.party >= PartyMod.MAX then
return Ops.say(S, ("Party is full (%d/%d)"):format(#S.save.party, PartyMod.MAX))
end
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
table.insert(S.save.party, mon)
S.selectedParty = #S.save.party
S.editingMon = mon
return Ops.mark(S, ("Added %s Lv5 to party slot %d"):format(species, #S.save.party))
end
function Ops.partyRemove(S)
local index = S.selectedParty
local mon = S.save.party[index]
if not mon then return Ops.say(S, "No party slot selected") end
if not Ops.arm(S, "party-remove",
("Remove %s from slot %d? Click again to confirm"):format(mon.species, index)) then
return false
end
table.remove(S.save.party, index)
if S.editingMon == mon then S.editingMon = nil end
S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1))
S.editingMon = S.save.party[S.selectedParty]
return Ops.mark(S, ("Removed %s from the party"):format(mon.species))
end
-- delta is -1 (up) or +1 (down); the selection follows the mon.
function Ops.partyMove(S, delta)
local i = S.selectedParty
local j = i + delta
local party = S.save.party
if not (party[i] and party[j]) then
return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon")
end
party[i], party[j] = party[j], party[i]
S.selectedParty = j
return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j))
end
-- ------------------------------------------------------- selected mon edits
-- All four of these round-trip through MonOps, which recomputes stats from
-- the Gen1 formula, so the inspector can never show illegal HP.
function Ops.setLevel(S, mon, level)
if not mon then return false end
local want = clamp(math.floor(level), 1, 100)
if want == mon.level then
return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100")
end
MonOps.setLevel(S.data, mon, want)
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
end
function Ops.stepSpecies(S, mon, delta)
if not mon then return false end
local list = S.cat.species
local idx = 1
for i, id in ipairs(list) do
if id == mon.species then idx = i break end
end
local nextId = list[((idx - 1 + delta) % #list) + 1]
MonOps.setSpecies(S.data, mon, nextId)
return Ops.mark(S, ("Species set to %s"):format(nextId))
end
function Ops.setDv(S, mon, key, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 15)
if want == mon.dvs[key] then
return Ops.say(S, ("%s DV is already %d"):format(key, want))
end
MonOps.setDv(S.data, mon, key, want)
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
end
function Ops.cycleMove(S, mon, slot)
if not mon then return false end
local moves = S.cat.moves
local current = mon.moves and mon.moves[slot] and mon.moves[slot].id
local idx = 0
if current then
for i, id in ipairs(moves) do
if id == current then idx = i break end
end
end
local nextId = moves[(idx % #moves) + 1]
MonOps.setMove(S.data, mon, slot, nextId)
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
end
function Ops.clearMove(S, mon, slot)
if not (mon and mon.moves and mon.moves[slot]) then
return Ops.say(S, ("Move slot %d is already empty"):format(slot))
end
local id = mon.moves[slot].id
mon.moves[slot] = nil
return Ops.mark(S, ("Cleared move slot %d (%s)"):format(slot, id))
end
function Ops.resetMoves(S, mon)
if not mon then return false end
local def = S.data.pokemon[mon.species]
local learned = Pokemon.movesAtLevel(def, mon.level)
mon.moves = {}
for slot, id in ipairs(learned) do
MonOps.setMove(S.data, mon, slot, id)
end
return Ops.mark(S, ("Reset %s to its Lv%d learnset (%d moves)")
:format(mon.species, mon.level, #learned))
end
function Ops.healMon(S, mon)
if not mon then return false end
if mon.hp == mon.stats.hp and not mon.status then
return Ops.say(S, ("%s is already at full HP"):format(mon.species))
end
mon.hp = mon.stats.hp
mon.status = nil
for _, mv in ipairs(mon.moves or {}) do
local def = S.data.moves[mv.id]
if def then mv.pp = def.pp + ((mv.ppUps or 0) * math.floor(def.pp / 5)) end
end
return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp))
end
-- ------------------------------------------------------------------ boxes
function Ops.boxes(S)
return BoxesMod.ensure(S.save)
end
function Ops.selectBox(S, index)
S.selectedBox = clamp(index, 1, BoxesMod.COUNT)
S.selectedBoxSlot = 1
S.save.currentBox = S.selectedBox
local box = Ops.boxes(S)[S.selectedBox]
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, BoxesMod.CAPACITY)
return true
end
function Ops.stepBox(S, delta)
local n = BoxesMod.COUNT
return Ops.selectBox(S, ((S.selectedBox - 1 + delta) % n) + 1)
end
function Ops.selectBoxSlot(S, index)
local box = Ops.boxes(S)[S.selectedBox]
S.selectedBoxSlot = clamp(index, 1, BoxesMod.CAPACITY)
local mon = box[S.selectedBoxSlot]
S.editingMon = mon
S.status = mon
and ("Selected %s Lv%d in box %d slot %d")
:format(mon.species, mon.level, S.selectedBox, S.selectedBoxSlot)
or ("Box %d slot %d is empty"):format(S.selectedBox, S.selectedBoxSlot)
return true
end
function Ops.boxAdd(S)
local box = Ops.boxes(S)[S.selectedBox]
if #box >= BoxesMod.CAPACITY then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
end
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
table.insert(box, mon)
S.selectedBoxSlot = #box
S.editingMon = mon
return Ops.mark(S, ("Added %s Lv5 to box %d slot %d")
:format(species, S.selectedBox, #box))
end
function Ops.withdraw(S)
local box = Ops.boxes(S)[S.selectedBox]
local mon = box[S.selectedBoxSlot]
if not mon then return Ops.say(S, "No box slot selected") end
if #S.save.party >= PartyMod.MAX then
return Ops.say(S, ("Party is full (%d/%d), deposit one first")
:format(#S.save.party, PartyMod.MAX))
end
table.remove(box, S.selectedBoxSlot)
table.insert(S.save.party, mon)
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
S.selectedParty = #S.save.party
return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party))
end
function Ops.release(S)
local box = Ops.boxes(S)[S.selectedBox]
local mon = box[S.selectedBoxSlot]
if not mon then return Ops.say(S, "No box slot selected") end
if not Ops.arm(S, "box-release",
("Release %s permanently? Click again to confirm"):format(mon.species)) then
return false
end
table.remove(box, S.selectedBoxSlot)
if S.editingMon == mon then S.editingMon = nil end
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
return Ops.mark(S, ("Released %s"):format(mon.species))
end
-- Follows BoxesMod.deposit: fills the current box first, then the next box
-- with room, and says where the mon actually landed.
function Ops.deposit(S)
local i = S.selectedParty
local mon = S.save.party[i]
if not mon then return Ops.say(S, "No party slot selected") end
local boxNum = BoxesMod.deposit(S.save, mon)
if not boxNum then
return Ops.say(S, "Every box is full, release something first")
end
table.remove(S.save.party, i)
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
S.selectedBox = boxNum
if S.editingMon == mon then S.editingMon = nil end
return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxNum))
end
-- ------------------------------------------------------------------ items
function Ops.addMoney(S, delta)
local want = clamp((S.save.money or 0) + delta, 0, Ops.MONEY_MAX)
if want == S.save.money then
return Ops.say(S, delta < 0 and "Money is already $0"
or ("Money is already capped at $%d"):format(Ops.MONEY_MAX))
end
S.save.money = want
return Ops.mark(S, ("Money set to $%d"):format(want))
end
function Ops.maxMoney(S)
return Ops.addMoney(S, Ops.MONEY_MAX)
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
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
:format(id, Bag.slots(S.save), Bag.CAPACITY))
end
return Ops.say(S, ("Bag is full (%d/%d slots)"):format(Bag.slots(S.save), Bag.CAPACITY))
end
function Ops.bagAdjust(S, id, delta)
if not id then return Ops.say(S, "No bag row selected") end
if delta > 0 then
local have = S.save.inventory[id] or 0
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)
else
Bag.remove(S.save, id, -delta)
if not S.save.inventory[id] then
return Ops.mark(S, ("Removed the last %s from the bag"):format(id))
end
end
return Ops.mark(S, ("%s x%d"):format(id, S.save.inventory[id] or 0))
end
function Ops.bagDrop(S, id)
if not id then return Ops.say(S, "No bag row selected") end
local qty = S.save.inventory[id] or 0
Bag.remove(S.save, id, qty)
return Ops.mark(S, ("Dropped all %d %s"):format(qty, id))
end
function Ops.pcItems(S)
S.save.pcItems = S.save.pcItems or {}
return S.save.pcItems
end
function Ops.pcOrder(S)
local ids = {}
for id in pairs(Ops.pcItems(S)) do ids[#ids + 1] = id end
table.sort(ids)
return ids
end
function Ops.addToPc(S, id)
if not id then return Ops.say(S, "Pick an item first") end
local pc = Ops.pcItems(S)
pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1)
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
end
function Ops.pcAdjust(S, id, delta)
if not id then return Ops.say(S, "No PC row selected") end
local pc = Ops.pcItems(S)
if not pc[id] then return Ops.say(S, ("%s is not in PC storage"):format(id)) end
if delta > 0 and pc[id] >= Ops.STACK_MAX then
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
end
pc[id] = clamp(pc[id] + delta, 0, Ops.STACK_MAX)
if pc[id] <= 0 then
pc[id] = nil
return Ops.mark(S, ("Removed %s from PC storage"):format(id))
end
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
end
function Ops.pcDrop(S, id)
if not id then return Ops.say(S, "No PC row selected") end
local pc = Ops.pcItems(S)
local qty = pc[id] or 0
pc[id] = nil
return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, id))
end
-- Badges are boolean inventory flags, not stackable items, which is why the
-- design gives them toggle chips instead of quantity rows.
function Ops.isBadgeId(id)
return id:find("BADGE", 1, true) ~= nil
end
function Ops.badgeIds(S)
local ids = {}
for _, id in ipairs(S.cat.items) do
if Ops.isBadgeId(id) then ids[#ids + 1] = id end
end
return ids
end
function Ops.toggleBadge(S, id)
local on = S.save.inventory[id] == true
S.save.inventory[id] = (not on) or nil
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
end
-- ----------------------------------------------------------------- events
function Ops.setFlag(S, name, on)
S.save.flags[name] = on and true or nil
return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false)))
end
function Ops.setKey(S, tableKey, key, on)
S.save[tableKey] = S.save[tableKey] or {}
S.save[tableKey][key] = on and true or nil
return Ops.mark(S, ("%s.%s = %s"):format(tableKey, key, tostring(on and true or false)))
end
function Ops.setToggle(S, mapId, name, on)
local toggles = S.save.objectToggles or {}
S.save.objectToggles = toggles
toggles[mapId] = toggles[mapId] or {}
toggles[mapId][name] = on and true or false
return Ops.mark(S, ("%s / %s = %s"):format(mapId, name, tostring(on and true or false)))
end
function Ops.clearTable(S, tableKey, label)
local count = 0
for _ in pairs(S.save[tableKey] or {}) do count = count + 1 end
if count == 0 then return Ops.say(S, ("%s is already empty"):format(label)) end
if not Ops.arm(S, "clear-" .. tableKey,
("Clear all %d %s entries? Click again to confirm"):format(count, label)) then
return false
end
S.save[tableKey] = {}
return Ops.mark(S, ("Cleared %d %s entries"):format(count, label))
end
-- -------------------------------------------------------------------- dex
function Ops.dex(S)
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
S.save.pokedex.seen = S.save.pokedex.seen or {}
S.save.pokedex.owned = S.save.pokedex.owned or {}
return S.save.pokedex
end
function Ops.dexCounts(S)
local dex = Ops.dex(S)
local seen, owned = 0, 0
for _ in pairs(dex.seen) do seen = seen + 1 end
for _ in pairs(dex.owned) do owned = owned + 1 end
return seen, owned, #S.cat.species
end
-- Owning implies having seen; un-seeing clears owned. Both directions are
-- the game's own rule, enforced here so a hand-edited dex stays legal.
function Ops.dexSeen(S, species, on)
local dex = Ops.dex(S)
dex.seen[species] = on and true or nil
if not on then dex.owned[species] = nil end
return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared"))
end
function Ops.dexOwned(S, species, on)
local dex = Ops.dex(S)
dex.owned[species] = on and true or nil
if on then dex.seen[species] = true end
return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned"))
end
function Ops.dexStamp(S)
local dex = Ops.dex(S)
local n = 0
local function stamp(mon)
if not dex.owned[mon.species] then n = n + 1 end
dex.seen[mon.species] = true
dex.owned[mon.species] = true
end
for _, m in ipairs(S.save.party) do stamp(m) end
for _, box in ipairs(S.save.boxes or {}) do
for _, m in ipairs(box) do stamp(m) end
end
if n == 0 then return Ops.say(S, "Party and boxes are already all in the dex") end
return Ops.mark(S, ("Owned %d more species from party + boxes"):format(n))
end
function Ops.dexSeeAll(S)
local dex = Ops.dex(S)
for _, species in ipairs(S.cat.species) do dex.seen[species] = true end
return Ops.mark(S, ("Marked all %d species seen"):format(#S.cat.species))
end
function Ops.dexOwnAll(S)
local dex = Ops.dex(S)
for _, species in ipairs(S.cat.species) do
dex.seen[species] = true
dex.owned[species] = true
end
return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species))
end
function Ops.dexClear(S)
if not Ops.arm(S, "dex-clear", "Wipe the whole Pokedex? Click again to confirm") then
return false
end
S.save.pokedex = { seen = {}, owned = {} }
return Ops.mark(S, "Pokedex wiped")
end
-- -------------------------------------------------------------------- map
-- Outdoor is detected the way the game treats LAST_MAP sources:
-- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save
-- has already visited.
function Ops.isOutdoor(S, map)
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
return true
end
if next(map.def.connections or {}) ~= nil then return true end
return (S.save.visited and S.save.visited[map.id]) or false
end
function Ops.setPlayerHere(S)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
S.save.player.map = S.mapId
S.save.player.x = cell.cx
S.save.player.y = cell.cy
return Ops.mark(S, ("Player set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
end
function Ops.setLastOutdoor(S, map)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
if not Ops.isOutdoor(S, map) then
return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)")
end
S.save.lastOutdoor = { id = S.mapId, x = cell.cx, y = cell.cy }
return Ops.mark(S, ("lastOutdoor set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
end
function Ops.setLastHeal(S)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
S.save.lastHeal = { map = S.mapId, x = cell.cx, y = cell.cy }
return Ops.mark(S, ("lastHeal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
end
return Ops
+50 -10
View File
@@ -1,5 +1,15 @@
# Save Editor
Ships inside every build. Two ways in:
**From the launcher.** Every SAVE SLOT row that holds a save carries an
**Edit** label next to Delete. Edit suspends the launcher and opens that
slot's file; **Close** hands the process back with the slot list re-read.
This is the path most people use, and it is wired in `main.lua`
(`openEditor` / `closeEditor`).
**Standalone**, where Close quits instead:
```bash
# from repo root, game closed
love . --editor
@@ -9,25 +19,55 @@ POKEPORT_EDITOR=1 love .
love . --editor --save "/path/to/save.lua"
```
By default loads the game's LÖVE save:
By default it loads the game's active save slot under the LÖVE save
directory (same identity as the game, deliberately: the editor edits the
game's saves and reads the game's ROM cache):
- macOS: `~/Library/Application Support/LOVE/pokemon-love2d/save.lua`
- Linux: `~/.local/share/love/pokemon-love2d/save.lua`
- Windows: `%APPDATA%\love\pokemon-love2d\save.lua`
- macOS: `~/Library/Application Support/LOVE/pokemon-love2d/`
- Linux: `~/.local/share/love/pokemon-love2d/`
- Windows: `%APPDATA%\love\pokemon-love2d\`
If the file isn't there (or you want another copy), use **Open...**, drop a
`save.lua` onto the window, or pass `--save`. Each write makes
`save.lua.bak-YYYYMMDD-HHMMSS` first.
## Layout
| file | role |
| --- | --- |
| `Theme.lua` | the launcher's palette + drawing primitives (cards, glow, dashed outlines, letterspaced captions) |
| `Kit.lua` | immediate-mode widgets built on Theme: buttons, rows, meters, chips, checkboxes, a real text field, pagers |
| `Ops.lua` | **every mutation**, behind one funnel that sets dirty + status together |
| `App.lua` | chrome (version rail, title bar, tab rail, status bar) and the panel router |
| `panels/` | one file per tab; pure layout that dispatches into Ops |
The design reference is `SaveEditor.dc.html` in the Claude Design project
that this port transcribes; its measurements are in the same pixel space
`App.lua` draws in.
Two rules the code enforces and the tests assert:
1. `Ops.mark(S, msg)` is the only thing that sets `S.dirty`, and it always
writes the status line at the same time. Refusals go through `Ops.say`,
which speaks without dirtying. No branch may silently no-op.
2. Destructive verbs go through `Ops.arm(S, id, msg)`: the first call arms
and returns false, a second within `Ops.ARM_SECONDS` commits.
`Ops.armLabel` relabels the button to `Confirm?` in between.
## Headless tests
Run from repo root (use `lua5.4` or the same Lua 5.4 binary as `tests/run_tests.lua`):
Run from repo root (`luajit`, or the same interpreter as
`tests/run_tests.lua`). All four run in CI as their own tiers in
`scripts/test.sh`:
```bash
lua5.4 tests/run_save_editor_tests.lua # core logic + Party/MonEditor (60 tests)
lua5.4 tests/save_editor_task6_tests.lua # Boxes + Items panels
lua5.4 tests/save_editor_task7_tests.lua # Events + Dex panels
lua5.4 tests/save_editor_task8_tests.lua # Map browser + set location
luajit tests/run_save_editor_tests.lua # SaveIO, App load/save/close, party + inspector, all-tab draw smoke
luajit tests/save_editor_task6_tests.lua # Boxes + Items rules
luajit tests/save_editor_task7_tests.lua # Events + Dex rules
luajit tests/save_editor_task8_tests.lua # map browser + spawn points
luajit tests/save_editor_mod_tests.lua # modded species/items stay editable
```
The task-specific suites are separate files (each defines its own harness) so they can be run independently without colliding with the main runner.
They drive `Ops.lua` rather than clicking pixel coordinates. The panels are
layout over Ops, so a redesign moves every coordinate but none of the
rules; asserting against Ops is what keeps the suites meaningful across one.
+55 -6
View File
@@ -1,26 +1,75 @@
-- The save editor's whole mutable world. Ops.lua is the only module allowed
-- to change the `save` sub-tree (and it always sets dirty + status together);
-- everything else here is view state -- which tab, which row, which page.
local State = {}
function State.new()
return {
-- loaded content
data = nil,
cat = nil,
events = nil,
cat = nil, -- sorted species / items / moves id lists (Catalog)
events = nil, -- scraped EVENT_* / MOD_* flag names (Catalog)
save = nil,
path = nil,
validation = nil, -- what the running game would quarantine, on a copy
-- file state
dirty = false,
loadError = false, -- true when the save file exists but failed to decode
allowSave = true, -- false while loadError, until a successful Reload
loadError = false, -- true when the save file exists but failed to decode
allowSave = true, -- false while loadError, until a successful Reload
_quitArmed = false,
tab = "party", -- party|boxes|items|events|map|dex
_openArmed = false,
-- Which game this save belongs to, so the title bar can show the RED /
-- BLUE chip and the launcher can hand the editor the right slot. Set by
-- App.load's opts; nil in a bare `love . --editor` run.
version = nil,
slotId = nil,
-- Hosted inside the launcher process (Edit on a save row) rather than a
-- standalone `--editor` window: Close returns to the launcher instead of
-- quitting, and App calls onClose() to do it.
embedded = false,
onClose = nil,
-- chrome
tab = "party", -- party|boxes|items|events|map|dex
status = "",
armed = nil, -- id of the destructive button awaiting confirmation
armedAt = nil, -- when it was armed (Ops.ARM_SECONDS to commit)
-- party / inspector
selectedParty = 1,
editingMon = nil, -- reference into party or a box
-- boxes
selectedBox = 1,
selectedBoxSlot = 1,
editingMon = nil, -- reference into party or box
-- items
itemQuery = "",
selectedItemId = nil,
selectedBagId = nil,
selectedPcId = nil,
bagOffset = 0,
pcOffset = 0,
-- events
eventsTab = "flags",
eventFilter = "",
eventsOffset = 0,
-- dex
dexOffset = 0,
-- map
mapId = nil,
mapQuery = "",
mapListOffset = 0,
mapCamX = 0,
mapCamY = 0,
mapZoom = 2,
mapClickCell = nil,
}
end
+327
View File
@@ -0,0 +1,327 @@
-- Shared look for the save editor: the launcher's palette and its drawing
-- primitives, lifted out so the editor and src/import/RomImporter.lua render
-- the same navy field, the same 16px translucent cards and the same neon
-- accents. The editor is reachable straight off a launcher save row (Edit),
-- so the two windows have to read as one app -- see SaveEditor.dc.html, which
-- is the design spec these literals come from.
--
-- Every colour below is 0-255 RGB; alpha is passed per draw call to col().
--
-- Everything here degrades when a love.graphics entry point is missing: the
-- headless love_stub used by tests/ has no fonts, stencil, mesh or line, so
-- each primitive checks for its dependency and falls back to a flat fill (or
-- nothing) rather than erroring. That keeps App.draw callable under the stub.
local Theme = {}
local PAL = {
-- radial background field: bright navy at top-centre -> near black
bgTop = { 22, 34, 74 }, -- #16224a
bgMid = { 12, 19, 48 }, -- #0c1330
bgBot = { 7, 11, 29 }, -- #070b1d
-- panel + row surfaces
cardTint = { 70, 150, 255 }, -- rgba(70,150,255,0.08) card top light
cardBody = { 12, 18, 40 }, -- rgba(12,18,40,0.5) card interior
cardBorder = { 120, 150, 220 }, -- rgba(120,150,220,0.28) hairline
rowBg = { 9, 14, 34 }, -- rgba(9,14,34,0.60) row interior
-- text
heading = { 255, 255, 255 },
text = { 223, 230, 245 }, -- #dfe6f5
detail = { 198, 208, 230 }, -- #c6d0e6
muted = { 159, 176, 208 }, -- #9fb0d0
caption = { 143, 163, 200 }, -- #8fa3c8 letterspaced section captions
faint = { 111, 130, 168 }, -- #6f82a8 slot indices, hints
-- semantics: green = safe/confirmed, yellow = attention, red = destructive
green = { 62, 224, 138 }, -- #3ee08a
greenDark = { 22, 163, 90 }, -- #16a35a
greenInk = { 6, 32, 18 }, -- #062012
yellow = { 255, 203, 5 }, -- #ffcb05
red = { 255, 92, 103 }, -- #ff5c67
redSoft = { 255, 143, 150 }, -- #ff8f96 destructive button ink
blue = { 70, 150, 255 }, -- #4696ff
blueInk = { 207, 224, 255 }, -- #cfe0ff ink on blue-tinted controls
steel = { 149, 161, 189 }, -- #95a1bd disabled
-- the tri-colour version rail, identical to the launcher's
railRed = { 255, 60, 72 },
railBlue = { 70, 150, 255 },
railGold = { 255, 203, 5 },
-- chip / tab tile gradient (the launcher's mod chip)
chipTop = { 61, 74, 109 }, -- #3d4a6d
chipBot = { 32, 42, 69 }, -- #202a45
chipInk = { 207, 224, 255 }, -- #cfe0ff
}
Theme.PAL = PAL
local G = love and love.graphics or nil
-- Feature probes: the headless stub implements only a handful of these.
local has = {}
local function probe(name)
if has[name] == nil then has[name] = (G and type(G[name]) == "function") or false end
return has[name]
end
function Theme.col(c, a)
if not G then return end
G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1)
end
local col = Theme.col
function Theme.clamp(n, lo, hi)
if n < lo then return lo end
if n > hi then return hi end
return n
end
local clamp = Theme.clamp
-- ---------------------------------------------------------------- gradients
-- One reusable unit-square mesh whose four corner colours are rewritten per
-- call, so a vertical gradient costs a single draw (same trick the launcher
-- uses). Nil under the stub, where every gradient degrades to a flat fill.
local gradMesh
local function setGrad(cTop, cBot, aTop, aBot)
if not probe("newMesh") then return false end
if not gradMesh then
gradMesh = G.newMesh({
{ 0, 0, 0, 0, 1, 1, 1, 1 },
{ 1, 0, 1, 0, 1, 1, 1, 1 },
{ 1, 1, 1, 1, 1, 1, 1, 1 },
{ 0, 1, 0, 1, 1, 1, 1, 1 },
}, "fan", "dynamic")
end
local t = { cTop[1] / 255, cTop[2] / 255, cTop[3] / 255, aTop }
local b = { cBot[1] / 255, cBot[2] / 255, cBot[3] / 255, aBot }
gradMesh:setVertexAttribute(1, 3, t[1], t[2], t[3], t[4])
gradMesh:setVertexAttribute(2, 3, t[1], t[2], t[3], t[4])
gradMesh:setVertexAttribute(3, 3, b[1], b[2], b[3], b[4])
gradMesh:setVertexAttribute(4, 3, b[1], b[2], b[3], b[4])
return true
end
-- Vertical gradient clipped to a rounded rect. Falls back to a flat fill of
-- the bottom colour when the stencil buffer or meshes are unavailable.
function Theme.gradRounded(x, y, w, h, r, cTop, cBot, aTop, aBot)
if not G then return end
if w <= 0 or h <= 0 then return end
if not (probe("stencil") and probe("setStencilTest") and setGrad(cTop, cBot, aTop, aBot)) then
col(cBot, aBot)
G.rectangle("fill", x, y, w, h, r, r)
return
end
G.stencil(function() G.rectangle("fill", x, y, w, h, r, r) end, "replace", 1)
G.setStencilTest("greater", 0)
G.setColor(1, 1, 1, 1)
G.draw(gradMesh, x, y, 0, w, h)
G.setStencilTest()
end
-- The design's standard content panel: a faint top-lit blue tint fading into
-- a dark interior behind a 1px cool-gray hairline. Every card in the editor
-- (and every card in the launcher) is this shape.
function Theme.card(x, y, w, h, r)
if not G then return end
r = r or 16
Theme.gradRounded(x, y, w, h, r, PAL.cardTint, PAL.cardBody, 0.08, 0.5)
Theme.stroke(x, y, w, h, r, PAL.cardBorder, 0.28, 1)
end
-- A list row / inner surface: flat dark fill, fainter hairline than a card.
function Theme.row(x, y, w, h, r, alpha)
if not G then return end
col(PAL.rowBg, alpha or 0.6)
G.rectangle("fill", x, y, w, h, r or 12, r or 12)
Theme.stroke(x, y, w, h, r or 12, PAL.cardBorder, 0.22, 1)
end
function Theme.stroke(x, y, w, h, r, c, a, lw)
if not G then return end
if probe("setLineWidth") then G.setLineWidth(math.max(1, lw or 1)) end
col(c, a or 1)
G.rectangle("line", x, y, w, h, r or 0, r or 0)
if probe("setLineWidth") then G.setLineWidth(1) end
end
-- Soft additive halo around a rounded rect (LOVE has no blur, so stack
-- progressively larger, fainter rects). Marks the selected party slot and
-- the hot Save button.
function Theme.glow(x, y, w, h, r, c, strength)
if not G or not probe("setBlendMode") then return end
strength = math.max(0, strength or 0)
if strength == 0 then return end
G.setBlendMode("add")
local layers = 7
for i = 1, layers do
local g = i * 2.2
G.setColor(c[1] / 255, c[2] / 255, c[3] / 255,
strength * 0.05 * (1 - (i - 1) / layers))
G.rectangle("fill", x - g, y - g, w + 2 * g, h + 2 * g, r + g, r + g)
end
G.setBlendMode("alpha")
end
-- Dashed rounded outline (LOVE has no dash pattern): sample the path into a
-- polyline, then walk it toggling on/off. Used for empty-state boxes and the
-- "add here" slots in the box grid. Caller sets colour + line width.
function Theme.dashed(x, y, w, h, r, dash, gap)
if not G or not probe("line") then return end
if w <= 0 or h <= 0 then return end
r = math.min(r, w / 2, h / 2)
local seg = 4
local pts = {}
local function arc(cx, cy, a0, a1)
for i = 0, seg do
local a = a0 + (a1 - a0) * (i / seg)
pts[#pts + 1] = cx + math.cos(a) * r
pts[#pts + 1] = cy + math.sin(a) * r
end
end
arc(x + w - r, y + r, -math.pi / 2, 0)
arc(x + w - r, y + h - r, 0, math.pi / 2)
arc(x + r, y + h - r, math.pi / 2, math.pi)
arc(x + r, y + r, math.pi, math.pi * 1.5)
pts[#pts + 1] = pts[1]; pts[#pts + 1] = pts[2]
local remaining, drawing = dash, true
for i = 1, #pts - 2, 2 do
local x1, y1 = pts[i], pts[i + 1]
local dx, dy = pts[i + 2] - x1, pts[i + 3] - y1
local segLen = math.sqrt(dx * dx + dy * dy)
local pos = 0
while pos < segLen do
local step = math.min(remaining, segLen - pos)
if drawing then
local t0, t1 = pos / segLen, (pos + step) / segLen
G.line(x1 + dx * t0, y1 + dy * t0, x1 + dx * t1, y1 + dy * t1)
end
pos = pos + step
remaining = remaining - step
if remaining <= 0.0001 then
drawing = not drawing
remaining = drawing and dash or gap
end
end
end
end
-- Letterspaced text: the UI font has no tracking control, so advance glyph by
-- glyph. Section captions are 12px/2px-tracked uppercase throughout.
function Theme.spaced(font, text, x, y, spacing)
if not G or not font then return 0 end
local cx = x
for i = 1, #text do
local ch = text:sub(i, i)
G.print(ch, cx, y)
cx = cx + font:getWidth(ch) + spacing
end
return math.max(0, cx - x - spacing)
end
function Theme.spacedWidth(font, text, spacing)
if not font then return 0 end
local w = 0
for i = 1, #text do w = w + font:getWidth(text:sub(i, i)) + spacing end
return math.max(0, w - spacing)
end
-- Clip text to a pixel width with a trailing ellipsis. Save paths truncate
-- from the LEFT instead (see Theme.ellipsizeLeft) so the filename survives.
function Theme.ellipsize(font, text, maxW)
text = tostring(text or "")
if not font then return text end
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
local ell = "..."
local ew = font:getWidth(ell)
while #text > 0 and font:getWidth(text) + ew > maxW do
text = text:sub(1, #text - 1)
end
return text .. ell
end
function Theme.ellipsizeLeft(font, text, maxW)
text = tostring(text or "")
if not font then return text end
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
local ell = "..."
local ew = font:getWidth(ell)
while #text > 0 and font:getWidth(text) + ew > maxW do
text = text:sub(2)
end
return ell .. text
end
-- ------------------------------------------------------------- backgrounds
-- The radial navy field, drawn as a triangle fan from the top-centre so the
-- falloff matches the CSS radial-gradient in the spec. The screen is cleared
-- to the outer colour first so the corners the fan misses match seamlessly.
function Theme.field(w, h)
if not G then return end
G.clear(PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1)
if not probe("newMesh") then return end
local cx, cy = w / 2, 0
local rx, ry = w * 1.3, h * 1.08
local n = 64
local verts = { { cx, cy, 0, 0,
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
for i = 0, n do
local a = (i / n) * math.pi * 2
verts[#verts + 1] = { cx + math.cos(a) * rx, cy + math.sin(a) * ry, 0, 0,
PAL.bgBot[1] / 255, PAL.bgBot[2] / 255, PAL.bgBot[3] / 255, 1 }
end
local mesh = G.newMesh(verts, "fan", "static")
G.setColor(1, 1, 1, 1)
G.draw(mesh)
end
-- The 6px tri-colour rail across the very top of both windows.
function Theme.versionRail(x, y, w, h)
if not G then return end
local seg = w / 3
local bars = { PAL.railRed, PAL.railBlue, PAL.railGold }
for i, c in ipairs(bars) do
col(c, 1)
G.rectangle("fill", x + (i - 1) * seg, y, seg, h)
end
end
-- A percentage meter (HP, box fill, dex completion, bag slots). pct is 0-100.
function Theme.meter(x, y, w, h, pct, c)
if not G then return end
col(PAL.cardBorder, 0.18)
G.rectangle("fill", x, y, w, h, h / 2, h / 2)
local fill = w * clamp((pct or 0) / 100, 0, 1)
if fill > 0 then
col(c or PAL.blue, 1)
G.rectangle("fill", x, y, math.max(fill, h / 2), h, h / 2, h / 2)
end
end
-- Font set, rebuilt only when the window size changes. `s` is the same
-- height/768 scale the launcher derives, so both windows step together.
-- Chrome is the default UI face; save DATA is drawn in the mono face, which
-- LOVE only ships as the default vector font -- so "mono" here means the
-- same face at a tighter size, and the distinction is carried by size and
-- colour. A stub with no newFont returns nil fonts and every draw no-ops.
function Theme.fonts(s)
if not probe("newFont") then return {} end
local function f(px) return G.newFont(math.max(8, math.floor(px + 0.5))) end
return {
scale = s,
wordmark = f(14 * s),
brand = f(11 * s),
chip = f(11 * s), -- RED / BLUE version chip
tile = f(13 * s), -- 2-letter tab glyph
tab = f(13 * s), -- tab label
button = f(14 * s),
small = f(12 * s),
tiny = f(11 * s),
micro = f(10 * s),
caption = f(12 * s), -- letterspaced section captions
mono = f(12 * s),
monoRow = f(13 * s),
monoBig = f(18 * s),
title = f(24 * s), -- inspector species name
headline = f(26 * s), -- dex completion / money
stat = f(19 * s),
}
end
return Theme
+158 -102
View File
@@ -1,123 +1,179 @@
-- Boxes panel: browse the 12 PC boxes (Boxes.ensure/deposit), move mons
-- between the active box and the party, and select a box mon for the
-- MonEditor overlay (App.lua draws that when S.editingMon is set).
-- Boxes panel: the 12 PC boxes as a real grid rather than the old 20-row
-- text list. Three columns:
-- box strip which boxes have room, so you can see where a deposit lands
-- the grid 5 x 4 = Boxes.CAPACITY, empty cells are dashed and clickable
-- party dock the deposit source and withdraw target, both in one place
--
-- Selecting a slot points S.editingMon at it, so switching to the Party tab
-- keeps inspecting the same mon.
local Boxes = require("src.pokemon.Boxes")
local BoxesMod = require("src.pokemon.Boxes")
local PartyMod = require("src.pokemon.Party")
local MonOps = require("MonOps")
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local M = {}
local ROW_H = 18
local LIST_H = Boxes.CAPACITY * ROW_H
local COLS = 5
local ROWS = math.ceil(BoxesMod.CAPACITY / COLS)
local function mark(S)
S.dirty = true
end
function M.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
local pad = 16 * s
local function clamp(n, lo, hi)
if n < lo then return lo end
if n > hi then return hi end
return n
end
local function boxLines(box)
local lines = {}
for i, mon in ipairs(box) do
lines[i] = string.format("%d. %-12s Lv%-3d HP %d/%d",
i, mon.species, mon.level, mon.hp, mon.stats.hp)
end
return lines
end
function M.draw(S, Kit, x, y)
local boxes = Boxes.ensure(S.save)
S.selectedBox = clamp(S.selectedBox or 1, 1, Boxes.COUNT)
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT)
S.save.currentBox = S.selectedBox
local boxes = Ops.boxes(S)
local box = boxes[S.selectedBox]
if Kit.button(x, y, 30, 26, "<") then
S.selectedBox = ((S.selectedBox - 2) % Boxes.COUNT) + 1
S.selectedBoxSlot = 1
end
Kit.label(x + 40, y + 5, string.format("Box %d/%d (%d/%d)",
S.selectedBox, Boxes.COUNT, #box, Boxes.CAPACITY))
if Kit.button(x + 230, y, 30, 26, ">") then
S.selectedBox = (S.selectedBox % Boxes.COUNT) + 1
S.selectedBoxSlot = 1
end
local stripW = math.max(150 * s, math.min(200 * s, w * 0.16))
local dockW = math.max(220 * s, math.min(300 * s, w * 0.22))
local gridX = x + stripW + gap
local gridW = w - stripW - dockW - 2 * gap
local listY = y + 34
S.selectedBoxSlot = clamp(S.selectedBoxSlot or 1, 1, math.max(#box, 1))
local click = Kit.list(x, listY, 360, LIST_H, boxLines(box), S.selectedBoxSlot, ROW_H)
if click then
S.selectedBoxSlot = click
S.editingMon = box[click]
end
local actionsY = listY + LIST_H + 10
if Kit.button(x, actionsY, 100, 28, "Withdraw") then
local mon = box[S.selectedBoxSlot]
if mon and #S.save.party < PartyMod.MAX then
table.remove(box, S.selectedBoxSlot)
table.insert(S.save.party, mon)
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
mark(S)
-- ------------------------------------------------------------ box strip
Kit.card(x, y, stripW, h)
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT))
local stripTop = y + pad + Kit.textHeight("caption") + 10 * s
local stripInner = stripW - 2 * pad
local bRowH = math.min(30 * s, math.max(22 * s,
(h - (stripTop - y) - pad - (BoxesMod.COUNT - 1) * 6 * s) / BoxesMod.COUNT))
for i = 1, BoxesMod.COUNT do
local ry = stripTop + (i - 1) * (bRowH + 6 * s)
if ry + bRowH > y + h - pad then break end
if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then
Ops.selectBox(S, i)
end
local fill = #boxes[i]
Kit.text("mono", ("Box %d"):format(i), x + pad + 10 * s,
ry + (bRowH - Kit.textHeight("mono")) / 2, PAL.text)
local countW = Kit.textWidth("tiny", tostring(fill))
Kit.textRight("tiny", tostring(fill), x + pad + stripInner - 10 * s,
ry + (bRowH - Kit.textHeight("tiny")) / 2, PAL.caption)
local mx = x + pad + stripInner - 10 * s - countW - 8 * s - 44 * s
Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s,
fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue)
end
if Kit.button(x + 110, actionsY, 100, 28, "Release") then
local mon = box[S.selectedBoxSlot]
-- ------------------------------------------------------------- the grid
Kit.card(gridX, y, gridW, h)
local gpad = 18 * s
local gx = gridX + gpad
local ginner = gridW - 2 * gpad
local headH = 30 * s
Kit.text("tab", ("Box %d"):format(S.selectedBox), gx,
y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading)
local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox))
Kit.text("mono", ("%d/%d"):format(#box, BoxesMod.CAPACITY),
gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption)
local navW = 34 * s
if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<",
{ radius = 8 * s }) then
Ops.stepBox(S, -1)
end
if Kit.stepper(gx + ginner - navW, y + gpad, navW, headH, ">",
{ radius = 8 * s }) then
Ops.stepBox(S, 1)
end
local actH = 34 * s
local actY = y + h - gpad - actH
local gridTop = y + gpad + headH + 14 * s
local gridH = actY - 14 * s - gridTop
local cellGap = 10 * s
local cellW = (ginner - cellGap * (COLS - 1)) / COLS
local cellH = math.min((gridH - cellGap * (ROWS - 1)) / ROWS, 110 * s)
for i = 1, BoxesMod.CAPACITY do
local cc = (i - 1) % COLS
local cr = math.floor((i - 1) / COLS)
local bx = gx + cc * (cellW + cellGap)
local by = gridTop + cr * (cellH + cellGap)
local mon = box[i]
local selected = (i == S.selectedBoxSlot) and mon ~= nil
if mon then
table.remove(box, S.selectedBoxSlot)
if S.editingMon == mon then S.editingMon = nil end
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
mark(S)
end
end
if Kit.button(x + 220, actionsY, 140, 28, "Add new mon") then
if #box < Boxes.CAPACITY then
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
table.insert(box, mon)
S.selectedBoxSlot = #box
mark(S)
end
end
local depositY = actionsY + 40
S.selectedParty = clamp(S.selectedParty or 1, 1, math.max(#S.save.party, 1))
local partyMon = S.save.party[S.selectedParty]
Kit.label(x, depositY + 5, "Deposit party slot:")
if Kit.button(x + 160, depositY, 26, 26, "<") then
if #S.save.party > 0 then
S.selectedParty = ((S.selectedParty - 2) % #S.save.party) + 1
end
end
Kit.label(x + 196, depositY + 5, partyMon
and string.format("%d. %s Lv%d", S.selectedParty, partyMon.species, partyMon.level)
or "(party empty)")
if Kit.button(x + 400, depositY, 26, 26, ">") then
if #S.save.party > 0 then
S.selectedParty = (S.selectedParty % #S.save.party) + 1
end
end
if Kit.button(x + 440, depositY, 110, 28, "Deposit") then
local i = S.selectedParty
local mon = S.save.party[i]
if mon then
local boxNum = Boxes.deposit(S.save, mon)
if boxNum then
table.remove(S.save.party, i)
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
S.selectedBox = boxNum
mark(S)
if Kit.row(bx, by, cellW, cellH, selected, PAL.green, 11 * s) then
Ops.selectBoxSlot(S, i)
end
Kit.text("micro", tostring(i), bx + 10 * s, by + 8 * s, PAL.faint)
Kit.textRight("micro", ("Lv%d"):format(mon.level), bx + cellW - 10 * s,
by + 8 * s, PAL.caption)
Kit.textCenter("mono",
Kit.ellipsize("mono", mon.species, cellW - 12 * s), bx,
by + cellH / 2 - Kit.textHeight("mono") / 2, cellW, PAL.text)
else
-- empty slots are dashed and clickable: clicking one adds a mon there
Theme.col(PAL.cardBorder, Kit.hover(bx, by, cellW, cellH) and 0.6 or 0.32)
Theme.dashed(bx, by, cellW, cellH, 11 * s, 6 * s, 5 * s)
Kit.text("micro", tostring(i), bx + 10 * s, by + 8 * s, PAL.faint)
Kit.textCenter("micro", "+", bx, by + cellH / 2 - Kit.textHeight("micro") / 2,
cellW, PAL.faint)
if Kit.press(bx, by, cellW, cellH) then
S.selectedBoxSlot = math.min(i, #box + 1)
Ops.boxAdd(S)
end
end
end
local wdW = 170 * s
if Kit.button(gx, actY, wdW, actH, "Withdraw to party",
{ font = "small", radius = 9 * s,
enabled = #S.save.party < PartyMod.MAX }) then
Ops.withdraw(S)
end
if Kit.button(gx + wdW + 10 * s, actY, 140 * s, actH, "+ Add mon here",
{ font = "small", radius = 9 * s,
enabled = #box < BoxesMod.CAPACITY }) then
Ops.boxAdd(S)
end
local relW = 110 * s
if Kit.button(gx + ginner - relW, actY, relW, actH,
Ops.armLabel(S, "box-release", "Release"),
{ kind = "danger", font = "small", radius = 9 * s }) then
Ops.release(S)
end
-- ----------------------------------------------------------- party dock
local dx = gridX + gridW + gap
Kit.card(dx, y, dockW, h)
Kit.caption(dx + pad, y + pad, "PARTY DOCK")
Kit.textRight("mono", ("%d/%d"):format(#S.save.party, PartyMod.MAX),
dx + dockW - pad, y + pad, PAL.caption)
local dTop = y + pad + Kit.textHeight("caption") + 10 * s
local dInner = dockW - 2 * pad
local dRowH = 34 * s
for i, mon in ipairs(S.save.party) do
local ry = dTop + (i - 1) * (dRowH + 7 * s)
if Kit.row(dx + pad, ry, dInner, dRowH, S.editingMon == mon, PAL.green, 9 * s) then
Ops.selectParty(S, i)
end
local lv = ("Lv%d"):format(mon.level)
local lvW = Kit.textWidth("tiny", lv)
Kit.textRight("tiny", lv, dx + pad + dInner - 10 * s,
ry + (dRowH - Kit.textHeight("tiny")) / 2, PAL.caption)
Kit.text("mono", Kit.ellipsize("mono", mon.species, dInner - 30 * s - lvW),
dx + pad + 10 * s, ry + (dRowH - Kit.textHeight("mono")) / 2, PAL.text)
end
if #S.save.party == 0 then
Kit.emptyBox(dx + pad, dTop, dInner, 70 * s, "Party is empty.")
end
local depY = dTop + math.max(#S.save.party, 2) * (dRowH + 7 * s) + 6 * s
if Kit.button(dx + pad, depY, dInner, 36 * s, "Deposit selected slot",
{ kind = "accent", font = "small", radius = 9 * s,
enabled = #S.save.party > 0 }) then
Ops.deposit(S)
end
local noteY = depY + 36 * s + 10 * s
local noteH = y + h - pad - noteY
if noteH > Kit.textHeight("tiny") * 2 then
Kit.textCenter("tiny",
"Deposit fills the current box first, then the next box with room, and " ..
"the status bar says where the mon landed.",
dx + pad, noteY, dInner, PAL.caption)
end
end
return M
+110 -92
View File
@@ -1,108 +1,126 @@
-- Pokédex panel: seen/owned checkboxes for every species in the catalog,
-- plus bulk actions to stamp the dex from the current party/boxes, mark
-- everything seen, or wipe it.
-- Pokedex panel: a completion header with seen / owned meters and the bulk
-- actions, then a four-column species grid where each row carries two
-- independent toggle chips.
--
-- The game's implications are enforced in Ops (owning implies seen, un-seeing
-- clears owned), so a hand-edited dex can never end up in a state the running
-- game would reject.
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local M = {}
local ROW_H = 22
local VISIBLE_ROWS = 12
local COLS = 4
local function mark(S)
S.dirty = true
end
local function ensureDex(S)
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
return S.save.pokedex
end
local function stampOwnedFromSave(S)
local dex = ensureDex(S)
local function markMon(mon)
dex.seen[mon.species] = true
dex.owned[mon.species] = true
end
for _, m in ipairs(S.save.party) do markMon(m) end
for _, box in ipairs(S.save.boxes or {}) do
for _, m in ipairs(box) do markMon(m) end
end
mark(S)
end
local function seeAll(S)
local dex = ensureDex(S)
for _, species in ipairs(S.cat.species) do
dex.seen[species] = true
end
mark(S)
end
local function clearDex(S)
S.save.pokedex = { seen = {}, owned = {} }
mark(S)
end
local function clampScroll(S, total)
S.dexScroll = S.dexScroll or 0
local maxScroll = math.max(0, total - VISIBLE_ROWS)
if S.dexScroll > maxScroll then S.dexScroll = maxScroll end
if S.dexScroll < 0 then S.dexScroll = 0 end
return S.dexScroll
end
function M.draw(S, Kit, x, y)
local dex = ensureDex(S)
function M.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local pad = 20 * s
local dex = Ops.dex(S)
local species = S.cat.species
local seen, owned, total = Ops.dexCounts(S)
Kit.label(x, y, string.format("Pokedex (%d species)", #species))
Kit.card(x, y, w, h)
local cx = x + pad
local inner = w - 2 * pad
if Kit.button(x, y + 24, 180, 28, "Own party+boxes") then
stampOwnedFromSave(S)
-- ------------------------------------------------------------- header
Kit.caption(cx, y + pad, "POKEDEX")
Kit.text("headline", ("%d / %d owned"):format(owned, total), cx,
y + pad + Kit.textHeight("caption") + 4 * s, PAL.heading)
local headH = Kit.textHeight("caption") + 4 * s + Kit.textHeight("headline")
local headW = math.max(Kit.captionWidth("POKEDEX"),
Kit.textWidth("headline", ("%d / %d owned"):format(owned, total)))
-- bulk actions, laid out from the right edge inward
local actH = 34 * s
local actY = y + pad + (headH - actH) / 2
local buttons = {
{ label = "Own party + boxes", kind = "ghost", fn = Ops.dexStamp },
{ label = "See all", kind = "accent", fn = Ops.dexSeeAll },
{ label = "Own all", kind = "good", fn = Ops.dexOwnAll },
}
local rightEdge = cx + inner
local clearLabel = Ops.armLabel(S, "dex-clear", "Wipe dex")
local clearW = Kit.textWidth("small", clearLabel) + 32 * s
rightEdge = rightEdge - clearW
if Kit.button(rightEdge, actY, clearW, actH, clearLabel,
{ kind = "danger", font = "small", radius = 9 * s }) then
Ops.dexClear(S)
end
if Kit.button(x + 190, y + 24, 110, 28, "See all") then
seeAll(S)
end
if Kit.button(x + 310, y + 24, 110, 28, "Clear") then
clearDex(S)
end
local headerY = y + 64
Kit.label(x + 220, headerY, "Seen")
Kit.label(x + 300, headerY, "Owned")
local listY = headerY + 24
local scroll = clampScroll(S, #species)
for i = 1, math.min(VISIBLE_ROWS, #species - scroll) do
local mon = species[scroll + i]
local ry = listY + (i - 1) * ROW_H
Kit.label(x, ry + 4, mon)
local seen, seenChanged = Kit.checkbox(x + 220, ry, dex.seen[mon] == true, "")
if seenChanged then
dex.seen[mon] = seen or nil
if not seen then dex.owned[mon] = nil end -- can't own what you haven't seen
mark(S)
end
local owned, ownedChanged = Kit.checkbox(x + 300, ry, dex.owned[mon] == true, "")
if ownedChanged then
dex.owned[mon] = owned or nil
if owned then dex.seen[mon] = true end -- owning implies having seen it
mark(S)
for i = #buttons, 1, -1 do
local b = buttons[i]
local bw = Kit.textWidth("small", b.label) + 32 * s
rightEdge = rightEdge - 10 * s - bw
if Kit.button(rightEdge, actY, bw, actH, b.label,
{ kind = b.kind, font = "small", radius = 9 * s }) then
b.fn(S)
end
end
local pagerY = listY + VISIBLE_ROWS * ROW_H + 8
local maxScroll = math.max(0, #species - VISIBLE_ROWS)
if Kit.button(x, pagerY, 90, 26, "Prev") then
S.dexScroll = math.max(0, scroll - VISIBLE_ROWS)
-- the two completion meters fill whatever the header leaves between the
-- headline and the button cluster
local meterX = cx + headW + 24 * s
local meterW = rightEdge - 24 * s - meterX
if meterW > 120 * s then
local my = y + pad
Kit.text("tiny", "SEEN", meterX, my, PAL.caption)
Kit.textRight("tiny", ("%d/%d"):format(seen, total), meterX + meterW, my, PAL.caption)
Kit.meter(meterX, my + Kit.textHeight("tiny") + 4 * s, meterW, 7 * s,
seen / math.max(total, 1) * 100, PAL.blue)
local my2 = my + Kit.textHeight("tiny") + 4 * s + 7 * s + 10 * s
Kit.text("tiny", "OWNED", meterX, my2, PAL.caption)
Kit.textRight("tiny", ("%d/%d"):format(owned, total), meterX + meterW, my2, PAL.caption)
Kit.meter(meterX, my2 + Kit.textHeight("tiny") + 4 * s, meterW, 7 * s,
owned / math.max(total, 1) * 100, PAL.green)
end
if Kit.button(x + 100, pagerY, 90, 26, "Next") then
S.dexScroll = math.min(maxScroll, scroll + VISIBLE_ROWS)
-- --------------------------------------------------------- species grid
local pagerH = 30 * s
local pagerY = y + h - pad - pagerH
local gridTop = y + pad + headH + 18 * s
local rowH = 38 * s
local rowGap = 8 * s
local colGap = 16 * s
local colW = (inner - colGap * (COLS - 1)) / COLS
local perCol = math.max(1, math.floor((pagerY - 12 * s - gridTop) / (rowH + rowGap)))
local perPage = perCol * COLS
S.dexOffset = Ops.clamp(S.dexOffset or 0, 0, math.max(0, #species - perPage))
local chipW = 46 * s
local chipH = 22 * s
for i = 1, math.min(perPage, #species - S.dexOffset) do
local id = species[S.dexOffset + i]
local ci = (i - 1) % COLS
local ri = math.floor((i - 1) / COLS)
local rx = cx + ci * (colW + colGap)
local ry = gridTop + ri * (rowH + rowGap)
local isSeen = dex.seen[id] == true
local isOwned = dex.owned[id] == true
Theme.row(rx, ry, colW, rowH, 9 * s, 0.6)
local def = S.data.pokemon[id]
Kit.text("micro", ("%03d"):format(def and def.dex or 0), rx + 10 * s,
ry + (rowH - Kit.textHeight("micro")) / 2, PAL.faint)
local nameX = rx + 44 * s
local nameW = colW - 10 * s - 2 * (chipW + 6 * s) - (nameX - rx)
Kit.text("mono", Kit.ellipsize("mono", id, nameW), nameX,
ry + (rowH - Kit.textHeight("mono")) / 2,
isOwned and PAL.text or (isSeen and PAL.muted or PAL.faint))
local sx = rx + colW - 10 * s - 2 * chipW - 6 * s
if Kit.chip(sx, ry + (rowH - chipH) / 2, chipW, chipH, "SEEN", isSeen,
PAL.blue, PAL.steel) then
Ops.dexSeen(S, id, not isSeen)
end
if Kit.chip(sx + chipW + 6 * s, ry + (rowH - chipH) / 2, chipW, chipH, "OWN",
isOwned, PAL.green, PAL.steel) then
Ops.dexOwned(S, id, not isOwned)
end
end
local shown = math.min(VISIBLE_ROWS, math.max(0, #species - scroll))
Kit.label(x + 210, pagerY + 5, string.format("%d-%d of %d",
#species > 0 and scroll + 1 or 0, scroll + shown, #species))
S.dexOffset = Kit.pager(cx, pagerY, inner, S.dexOffset, #species, perPage)
end
return M
+161 -185
View File
@@ -1,30 +1,35 @@
-- Events panel: flags, defeated trainers, taken items, and per-map object
-- visibility toggles. All four sections read/write directly into S.save so
-- edits show up immediately on the next Save.
-- visibility toggles. All four sections read/write through Ops so a flip is
-- always dirty + narrated.
--
-- Layout is a fixed grid (sub-tabs -> info row -> paged checkbox list ->
-- pagination/actions) so it stays predictable for both mouse hit-testing
-- and headless tests.
-- Sub-tabs are pills; the filter is a real Kit.textfield (the old panel
-- edge-detected love.keyboard state every frame because Kit had no input
-- widget, which swallowed every keystroke the rest of the app wanted); and
-- the rows are a two-column grid so twenty fit per page instead of ten.
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local M = {}
local ROW_H = 22
local VISIBLE_ROWS = 10
local SUB_TABS = {
{ id = "flags", label = "Flags" },
{ id = "flags", label = "Flags" },
{ id = "trainers", label = "Trainers" },
{ id = "items", label = "Items taken" },
{ id = "toggles", label = "Object toggles" },
{ id = "items", label = "Items taken" },
{ id = "toggles", label = "Object toggles" },
}
local function mark(S)
S.dirty = true
end
local HINTS = {
flags = "Story flags scraped from data/scripts and the trainer headers, plus any MOD_ flags a loaded mod defines.",
trainers = "Keys look like MAP_obj_N (save.defeatedTrainers): checked means that trainer stays beaten.",
items = "Keys look like MAP_obj_N (save.itemsTaken): checked means that ground item is gone.",
toggles = "Per-map object visibility overrides (save.objectToggles), grouped by map.",
}
local function sortedKeys(t)
local keys = {}
for k in pairs(t) do table.insert(keys, k) end
for k in pairs(t) do keys[#keys + 1] = k end
table.sort(keys)
return keys
end
@@ -34,187 +39,158 @@ local function contains(haystack, needle)
return haystack:lower():find(needle:lower(), 1, true) ~= nil
end
-- Simple free-text capture for the flags filter field. Kit has no text
-- widget, so we edge-detect a-z/0-9/backspace against love.keyboard each
-- frame this panel is drawn (only while the Flags sub-tab is active).
local FILTER_KEYS = {}
for c = string.byte("a"), string.byte("z") do
local ch = string.char(c)
FILTER_KEYS[ch] = ch
end
for c = string.byte("0"), string.byte("9") do
local ch = string.char(c)
FILTER_KEYS[ch] = ch
end
FILTER_KEYS["-"] = "_"
local prevDown = {}
local function pollFilterInput(S)
S.eventFilter = S.eventFilter or ""
for key, ch in pairs(FILTER_KEYS) do
local down = love.keyboard.isDown(key)
if down and not prevDown[key] then
S.eventFilter = S.eventFilter .. ch
-- Each sub-tab reduces to the same shape: a list of rows, where a row knows
-- how to read its checked state, render a label, and write a flip back.
local function buildRows(S)
local tab = S.eventsTab
local filter = S.eventFilter or ""
local rows = {}
if tab == "flags" then
for _, name in ipairs(S.events or {}) do
if contains(name, filter) then
rows[#rows + 1] = {
label = name,
checked = S.save.flags[name] == true,
set = function(on) Ops.setFlag(S, name, on) end,
}
end
end
prevDown[key] = down
end
local backspaceDown = love.keyboard.isDown("backspace")
if backspaceDown and not prevDown.backspace then
S.eventFilter = S.eventFilter:sub(1, -2)
end
prevDown.backspace = backspaceDown
end
local function clampScroll(S, total)
S.eventsScroll = S.eventsScroll or 0
local maxScroll = math.max(0, total - VISIBLE_ROWS)
if S.eventsScroll > maxScroll then S.eventsScroll = maxScroll end
if S.eventsScroll < 0 then S.eventsScroll = 0 end
return S.eventsScroll
end
-- Draws up to VISIBLE_ROWS checkbox rows starting at rows[scroll+1], calling
-- onToggle(row, newChecked) when a row's box is clicked. `checkedOf(row)`
-- and `labelOf(row)` extract display state from whatever row shape the
-- caller uses (plain strings for Flags/Trainers/Items, tables for Toggles).
local function drawRows(S, Kit, x, listY, rows, scroll, checkedOf, labelOf, onToggle)
for i = 1, math.min(VISIBLE_ROWS, #rows - scroll) do
local row = rows[scroll + i]
local ry = listY + (i - 1) * ROW_H
local checked = checkedOf(row)
if checked == nil then
Kit.label(x + 28, ry + 4, labelOf(row))
else
local newChecked, changed = Kit.checkbox(x, ry, checked, labelOf(row))
if changed then
onToggle(row, newChecked)
elseif tab == "trainers" or tab == "items" then
local key = (tab == "trainers") and "defeatedTrainers" or "itemsTaken"
S.save[key] = S.save[key] or {}
local t = S.save[key]
for _, k in ipairs(sortedKeys(t)) do
if contains(k, filter) then
rows[#rows + 1] = {
label = k,
checked = t[k] == true,
set = function(on) Ops.setKey(S, key, k, on) end,
}
end
end
else
S.save.objectToggles = S.save.objectToggles or {}
local toggles = S.save.objectToggles
for _, mapId in ipairs(sortedKeys(toggles)) do
local mapRows = {}
for _, name in ipairs(sortedKeys(toggles[mapId])) do
if contains(name, filter) or contains(mapId, filter) then
mapRows[#mapRows + 1] = {
label = name,
checked = toggles[mapId][name] == true,
set = function(on) Ops.setToggle(S, mapId, name, on) end,
}
end
end
if #mapRows > 0 then
rows[#rows + 1] = { header = true, label = "[" .. mapId .. "]" }
for _, r in ipairs(mapRows) do rows[#rows + 1] = r end
end
end
end
return rows
end
local function drawPager(S, Kit, x, y, total, scroll)
local maxScroll = math.max(0, total - VISIBLE_ROWS)
if Kit.button(x, y, 90, 26, "Prev") then
S.eventsScroll = math.max(0, scroll - VISIBLE_ROWS)
end
if Kit.button(x + 100, y, 90, 26, "Next") then
S.eventsScroll = math.min(maxScroll, scroll + VISIBLE_ROWS)
end
local shown = math.min(VISIBLE_ROWS, math.max(0, total - scroll))
Kit.label(x + 210, y + 5, string.format("%d-%d of %d",
total > 0 and scroll + 1 or 0, scroll + shown, total))
end
local function drawFlagsTab(S, Kit, x, y)
pollFilterInput(S)
Kit.label(x, y + 4, "Filter: " .. S.eventFilter .. "_")
if Kit.button(x + 320, y, 110, 26, "Clear filter") then
S.eventFilter = ""
end
local filtered = {}
for _, name in ipairs(S.events or {}) do
if contains(name, S.eventFilter) then
table.insert(filtered, name)
end
end
local listY = y + 32
local scroll = clampScroll(S, #filtered)
drawRows(S, Kit, x, listY, filtered, scroll,
function(name) return S.save.flags[name] == true end,
function(name) return name end,
function(name, newChecked)
S.save.flags[name] = newChecked and true or nil
mark(S)
end)
drawPager(S, Kit, x, listY + VISIBLE_ROWS * ROW_H + 8, #filtered, scroll)
end
local function drawKeyToggleTab(S, Kit, x, y, note, tableKey, clearLabel)
Kit.label(x, y + 4, note)
S.save[tableKey] = S.save[tableKey] or {}
local t = S.save[tableKey]
local keys = sortedKeys(t)
local listY = y + 32
local scroll = clampScroll(S, #keys)
drawRows(S, Kit, x, listY, keys, scroll,
function(k) return t[k] == true end,
function(k) return k end,
function(k, newChecked)
t[k] = newChecked
mark(S)
end)
local pagerY = listY + VISIBLE_ROWS * ROW_H + 8
drawPager(S, Kit, x, pagerY, #keys, scroll)
if Kit.button(x + 400, pagerY, 190, 26, clearLabel) then
S.save[tableKey] = {}
mark(S)
end
end
local function drawTogglesTab(S, Kit, x, y)
Kit.label(x, y + 4, "Per-map object visibility overrides")
S.save.objectToggles = S.save.objectToggles or {}
local toggles = S.save.objectToggles
local rows = {}
for _, mapId in ipairs(sortedKeys(toggles)) do
table.insert(rows, { header = true, mapId = mapId })
for _, objName in ipairs(sortedKeys(toggles[mapId])) do
table.insert(rows, { header = false, mapId = mapId, name = objName })
end
end
local listY = y + 32
local scroll = clampScroll(S, #rows)
drawRows(S, Kit, x, listY, rows, scroll,
function(row)
if row.header then return nil end
return toggles[row.mapId][row.name] == true
end,
function(row) return row.header and ("[" .. row.mapId .. "]") or row.name end,
function(row, newChecked)
toggles[row.mapId][row.name] = newChecked
mark(S)
end)
drawPager(S, Kit, x, listY + VISIBLE_ROWS * ROW_H + 8, #rows, scroll)
end
function M.draw(S, Kit, x, y)
S.eventFilter = S.eventFilter or ""
function M.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local pad = 20 * s
S.eventsTab = S.eventsTab or "flags"
S.eventFilter = S.eventFilter or ""
Kit.label(x, y, "Events")
Kit.card(x, y, w, h)
local cx = x + pad
local inner = w - 2 * pad
local newTab = Kit.tabs(x, y + 24, SUB_TABS, S.eventsTab)
if newTab then
S.eventsTab = newTab
S.eventsScroll = 0
-- ------------------------------------------------------------ sub-tabs
local pillH = 32 * s
local px = cx
for _, t in ipairs(SUB_TABS) do
local pw = Kit.textWidth("small", t.label) + 32 * s
local active = (S.eventsTab == t.id)
Theme.col(PAL.rowBg, 0.6)
love.graphics.rectangle("fill", px, y + pad, pw, pillH, pillH / 2, pillH / 2)
Theme.stroke(px, y + pad, pw, pillH, pillH / 2,
active and PAL.blue or PAL.cardBorder, active and 0.8 or 0.24,
active and 1.5 * s or 1)
Kit.textCenter("small", t.label, px, y + pad + (pillH - Kit.textHeight("small")) / 2,
pw, active and PAL.heading or PAL.muted)
if Kit.press(px, y + pad, pw, pillH) then
S.eventsTab = t.id
S.eventsOffset = 0
Ops.disarm(S)
Ops.say(S, HINTS[t.id])
end
px = px + pw + 10 * s
end
local contentY = y + 64
if S.eventsTab == "flags" then
drawFlagsTab(S, Kit, x, contentY)
elseif S.eventsTab == "trainers" then
drawKeyToggleTab(S, Kit, x, contentY,
"Keys look like MAP_obj_N (defeatedTrainers)",
"defeatedTrainers", "Clear all trainers")
elseif S.eventsTab == "items" then
drawKeyToggleTab(S, Kit, x, contentY,
"Keys look like MAP_obj_N (itemsTaken)",
"itemsTaken", "Clear all items taken")
elseif S.eventsTab == "toggles" then
drawTogglesTab(S, Kit, x, contentY)
local clearW = 74 * s
local fieldW = math.min(280 * s, math.max(140 * s, cx + inner - clearW - 10 * s - px - 10 * s))
local fieldX = cx + inner - clearW - 10 * s - fieldW
S.eventFilter = Kit.textfield("event-filter", fieldX, y + pad, fieldW, pillH,
S.eventFilter, "filter keys...")
if Kit.button(cx + inner - clearW, y + pad, clearW, pillH, "Clear",
{ kind = "accent", font = "small", radius = 8 * s,
enabled = S.eventFilter ~= "" }) then
S.eventFilter = ""
Kit.blur()
Ops.say(S, "Filter cleared")
end
local hintY = y + pad + pillH + 10 * s
Kit.text("small", HINTS[S.eventsTab] or "", cx, hintY, PAL.caption)
-- ---------------------------------------------------------- row grid
local rows = buildRows(S)
local pagerH = 30 * s
local pagerY = y + h - pad - pagerH
local gridTop = hintY + Kit.textHeight("small") + 14 * s
local rowH = 34 * s
local rowGap = 8 * s
local colGap = 20 * s
local colW = (inner - colGap) / 2
local perCol = math.max(1, math.floor((pagerY - 12 * s - gridTop) / (rowH + rowGap)))
local perPage = perCol * 2
S.eventsOffset = Ops.clamp(S.eventsOffset or 0, 0, math.max(0, #rows - perPage))
if #rows == 0 then
Kit.emptyBox(cx, gridTop, inner, 80 * s,
S.eventFilter ~= "" and "No key matches that filter."
or "Nothing recorded here yet.")
end
for i = 1, math.min(perPage, #rows - S.eventsOffset) do
local row = rows[S.eventsOffset + i]
local ci = (i - 1) % 2
local ri = math.floor((i - 1) / 2)
local rx = cx + ci * (colW + colGap)
local ry = gridTop + ri * (rowH + rowGap)
if row.header then
-- a map heading inside the toggles list: not a checkbox, so it must
-- not look clickable
Kit.text("mono", Kit.ellipsize("mono", row.label, colW),
rx + 4 * s, ry + (rowH - Kit.textHeight("mono")) / 2, PAL.caption)
else
local newChecked, changed = Kit.checkbox(rx, ry, colW, rowH,
row.checked, row.label)
if changed then row.set(newChecked) end
end
end
S.eventsOffset = Kit.pager(cx, pagerY, inner, S.eventsOffset, #rows, perPage)
-- "Clear all" only makes sense for the two key tables the editor owns
-- wholesale; flags and object toggles are cleared one row at a time.
local clearKey = (S.eventsTab == "trainers" and "defeatedTrainers")
or (S.eventsTab == "items" and "itemsTaken") or nil
if clearKey then
local label = (S.eventsTab == "trainers") and "Clear all trainers"
or "Clear all items taken"
local bw = Kit.textWidth("small", label) + 32 * s
if Kit.button(cx + inner - bw, pagerY, bw, pagerH,
Ops.armLabel(S, "clear-" .. clearKey, label),
{ kind = "danger", font = "small", radius = 8 * s }) then
Ops.clearTable(S, clearKey, label:gsub("^Clear all ", ""))
end
end
end
+215 -200
View File
@@ -1,225 +1,240 @@
-- Items panel: money, the 20-slot bag (Bag.add/remove, ordered by
-- Bag.order), gym badges (boolean flags on inventory), and PC Item
-- storage (a plain S.save.pcItems dict, created on first use).
-- Items panel: money, the shared item picker, badges, the 20-slot bag
-- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain
-- S.save.pcItems dict with no slot cap).
--
-- The picker is a searchable list rather than the old pair of arrows that
-- cycled one id at a time through ~250 items, which was the single worst
-- interaction in the editor. Badges sit in the wallet column as toggle
-- chips because they are boolean inventory flags, not stackable items, and
-- must not look like quantity rows.
local Bag = require("src.inventory.Bag")
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local M = {}
local ROW_H = 20
local LIST_H = 200
local VISIBLE_ROWS = math.floor(LIST_H / ROW_H)
local MONEY_STEPS = { -1000, -100, 100, 1000 }
local function mark(S)
S.dirty = true
local function matches(id, query)
if query == "" then return true end
return id:lower():find(query:lower(), 1, true) ~= nil
end
-- Prev/Next pagination (mirrors Events/Dex panels) so bag/PC lists longer
-- than VISIBLE_ROWS stay fully reachable instead of truncating silently.
local function clampScroll(scroll, total)
local maxScroll = math.max(0, total - VISIBLE_ROWS)
scroll = scroll or 0
if scroll > maxScroll then scroll = maxScroll end
if scroll < 0 then scroll = 0 end
return scroll
-- One quantity row shape, shared by the bag and the PC list: id, qty, then
-- the -/+/drop cluster. Returns true when the row body was clicked.
local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlus, onDrop)
local s = Kit.scale
local clicked = Kit.row(x, y, w, h, selected, PAL.blue, 9 * s)
local btn = 24 * s
local bx = x + w - 10 * s - 3 * btn - 2 * (6 * s)
if Kit.stepper(bx, y + (h - btn) / 2, btn, btn, "-", { font = "small" }) then
onMinus()
end
if Kit.stepper(bx + btn + 6 * s, y + (h - btn) / 2, btn, btn, "+",
{ font = "small" }) then
onPlus()
end
if Kit.button(bx + 2 * (btn + 6 * s), y + (h - btn) / 2, btn, btn, "x",
{ kind = "danger", font = "tiny", radius = 6 * s }) then
onDrop()
end
local qtyText = ("x%d"):format(qty)
local qtyW = Kit.textWidth("monoRow", qtyText)
Kit.textRight("monoRow", qtyText, bx - 10 * s,
y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading)
Kit.text("mono", Kit.ellipsize("mono", id, bx - qtyW - 30 * s - (x + 10 * s)),
x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text)
return clicked
end
local function pageSlice(items, scroll)
local page = {}
for i = 1, math.min(VISIBLE_ROWS, #items - scroll) do
page[i] = items[scroll + i]
function M.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
local pad = 16 * s
Ops.pcItems(S)
local leftW = math.max(260 * s, math.min(320 * s, w * 0.26))
local listW = (w - leftW - 2 * gap) / 2
local bagX = x + leftW + gap
local pcX = bagX + listW + gap
-- ------------------------------------------------------------- money
-- Money and badges are fixed-height so the picker gets every pixel left
-- over: cycling through ~250 item ids in a two-row list was the thing that
-- made the old panel unusable.
local moneyH = pad * 2 + Kit.textHeight("caption") + 8 * s
+ Kit.textHeight("headline") + 10 * s + 30 * s
Kit.card(x, y, leftW, moneyH)
Kit.caption(x + pad, y + pad, "MONEY")
local maxW = 74 * s
if Kit.button(x + leftW - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out",
{ kind = "accent", font = "tiny", radius = 7 * s,
enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then
Ops.maxMoney(S)
end
return page
end
local function drawPager(Kit, x, y, total, scroll, onPrev, onNext)
if Kit.button(x, y, 90, 26, "Prev") then onPrev() end
if Kit.button(x + 100, y, 90, 26, "Next") then onNext() end
local shown = math.min(VISIBLE_ROWS, math.max(0, total - scroll))
Kit.label(x + 210, y + 5, string.format("%d-%d of %d",
total > 0 and scroll + 1 or 0, scroll + shown, total))
end
local function clamp(n, lo, hi)
if n < lo then return lo end
if n > hi then return hi end
return n
end
local function isBadgeId(id)
return id:find("BADGE", 1, true) ~= nil
end
local function badgeIds(cat)
local ids = {}
for _, id in ipairs(cat.items) do
if isBadgeId(id) then table.insert(ids, id) end
end
return ids
end
local function bagLines(save)
local order = Bag.order(save)
local lines = {}
for i, id in ipairs(order) do
lines[i] = string.format("%-16s x%d", id, save.inventory[id] or 0)
end
return lines, order
end
local function pcItemOrder(pcItems)
local ids = {}
for id in pairs(pcItems) do table.insert(ids, id) end
table.sort(ids)
return ids
end
local function pcLines(pcItems, order)
local lines = {}
for i, id in ipairs(order) do
lines[i] = string.format("%-16s x%d", id, pcItems[id] or 0)
end
return lines
end
local function pcAdd(save, id, qty)
save.pcItems = save.pcItems or {}
local pc = save.pcItems
pc[id] = math.min(99, (pc[id] or 0) + (qty or 1))
end
local function pcRemove(save, id, qty)
local pc = save.pcItems
if not pc or not pc[id] then return end
pc[id] = pc[id] - (qty or 1)
if pc[id] <= 0 then pc[id] = nil end
end
function M.draw(S, Kit, x, y)
S.save.pcItems = S.save.pcItems or {}
-- Money
Kit.label(x, y, "Money: $" .. tostring(S.save.money))
local moneyBtnY = y + 22
if Kit.button(x, moneyBtnY, 60, 26, "-100") then
S.save.money = clamp(S.save.money - 100, 0, 999999); mark(S)
end
if Kit.button(x + 66, moneyBtnY, 60, 26, "-10") then
S.save.money = clamp(S.save.money - 10, 0, 999999); mark(S)
end
if Kit.button(x + 132, moneyBtnY, 60, 26, "+10") then
S.save.money = clamp(S.save.money + 10, 0, 999999); mark(S)
end
if Kit.button(x + 198, moneyBtnY, 60, 26, "+100") then
S.save.money = clamp(S.save.money + 100, 0, 999999); mark(S)
end
-- Item picker (species-like picker over S.cat.items) shared by bag/PC add
local pickerY = moneyBtnY + 40
S.itemPickerIdx = clamp(S.itemPickerIdx or 1, 1, #S.cat.items)
local pickId = S.cat.items[S.itemPickerIdx]
Kit.label(x, pickerY + 5, "Item:")
if Kit.button(x + 46, pickerY, 26, 26, "<") then
S.itemPickerIdx = ((S.itemPickerIdx - 2) % #S.cat.items) + 1
end
Kit.label(x + 82, pickerY + 5, pickId)
if Kit.button(x + 280, pickerY, 26, 26, ">") then
S.itemPickerIdx = (S.itemPickerIdx % #S.cat.items) + 1
end
if Kit.button(x + 320, pickerY, 110, 26, "Add to Bag") then
if Bag.add(S.save, pickId, 1) then mark(S) end
end
if Kit.button(x + 440, pickerY, 110, 26, "Add to PC") then
pcAdd(S.save, pickId, 1); mark(S)
end
-- Bag list
local bagLabelY = pickerY + 40
Kit.label(x, bagLabelY, string.format("Bag (%d/%d slots)", Bag.slots(S.save), Bag.CAPACITY))
local bagListY = bagLabelY + 20
local lines, order = bagLines(S.save)
S.selectedBagIdx = clamp(S.selectedBagIdx or 1, 1, math.max(#order, 1))
S.bagScroll = clampScroll(S.bagScroll, #order)
local bagClick = Kit.list(x, bagListY, 300, LIST_H, pageSlice(lines, S.bagScroll),
S.selectedBagIdx - S.bagScroll, ROW_H)
if bagClick then S.selectedBagIdx = S.bagScroll + bagClick end
local bagPagerY = bagListY + LIST_H + 8
drawPager(Kit, x, bagPagerY, #order, S.bagScroll,
function() S.bagScroll = clampScroll(S.bagScroll - VISIBLE_ROWS, #order) end,
function() S.bagScroll = clampScroll(S.bagScroll + VISIBLE_ROWS, #order) end)
local bagActionsY = bagPagerY + 34
if Kit.button(x, bagActionsY, 100, 28, "Remove 1") then
local id = order[S.selectedBagIdx]
if id then
Bag.remove(S.save, id, 1)
S.selectedBagIdx = clamp(S.selectedBagIdx, 1, math.max(#Bag.order(S.save), 1))
mark(S)
end
end
if Kit.button(x + 110, bagActionsY, 100, 28, "Remove all") then
local id = order[S.selectedBagIdx]
if id then
Bag.remove(S.save, id, S.save.inventory[id])
S.selectedBagIdx = clamp(S.selectedBagIdx, 1, math.max(#Bag.order(S.save), 1))
mark(S)
Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad,
y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow)
local mbY = y + moneyH - pad - 30 * s
local mbW = (leftW - 2 * pad - 3 * 8 * s) / 4
for i, delta in ipairs(MONEY_STEPS) do
local label = (delta > 0 and "+" or "") .. tostring(delta)
if Kit.button(x + pad + (i - 1) * (mbW + 8 * s), mbY, mbW, 30 * s, label,
{ kind = "accent", font = "tiny", radius = 8 * s }) then
Ops.addMoney(S, delta)
end
end
-- PC items list (mirrors bag UX; plain dict, no slot cap)
local pcLabelY = bagActionsY + 40
local pcOrder = pcItemOrder(S.save.pcItems)
Kit.label(x, pcLabelY, string.format("PC Items (%d kinds)", #pcOrder))
local pcListY = pcLabelY + 20
S.selectedPcIdx = clamp(S.selectedPcIdx or 1, 1, math.max(#pcOrder, 1))
S.pcScroll = clampScroll(S.pcScroll, #pcOrder)
local pcClick = Kit.list(x, pcListY, 300, LIST_H,
pageSlice(pcLines(S.save.pcItems, pcOrder), S.pcScroll),
S.selectedPcIdx - S.pcScroll, ROW_H)
if pcClick then S.selectedPcIdx = S.pcScroll + pcClick end
-- ------------------------------------------------------------ picker
local badgeIds = Ops.badgeIds(S)
local badgeCols = 4
local badgeRows = math.ceil(#badgeIds / badgeCols)
local badgeH = pad * 2 + Kit.textHeight("caption") + 10 * s
+ badgeRows * (28 * s + 7 * s) - 7 * s
local pickY = y + moneyH + gap
local pickH = h - moneyH - badgeH - 2 * gap
Kit.card(x, pickY, leftW, pickH)
Kit.caption(x + pad, pickY + pad, "ADD ITEM")
local qy = pickY + pad + Kit.textHeight("caption") + 8 * s
S.itemQuery = Kit.textfield("item-query", x + pad, qy, leftW - 2 * pad, 32 * s,
S.itemQuery or "", "search item ids...")
local pcPagerY = pcListY + LIST_H + 8
drawPager(Kit, x, pcPagerY, #pcOrder, S.pcScroll,
function() S.pcScroll = clampScroll(S.pcScroll - VISIBLE_ROWS, #pcOrder) end,
function() S.pcScroll = clampScroll(S.pcScroll + VISIBLE_ROWS, #pcOrder) end)
local pcActionsY = pcPagerY + 34
if Kit.button(x, pcActionsY, 100, 28, "Remove 1") then
local id = pcOrder[S.selectedPcIdx]
if id then
pcRemove(S.save, id, 1)
pcOrder = pcItemOrder(S.save.pcItems)
S.selectedPcIdx = clamp(S.selectedPcIdx, 1, math.max(#pcOrder, 1))
mark(S)
local choices = {}
for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) and matches(id, S.itemQuery) then
choices[#choices + 1] = id
end
end
if Kit.button(x + 110, pcActionsY, 100, 28, "Remove all") then
local id = pcOrder[S.selectedPcIdx]
if id then
pcRemove(S.save, id, S.save.pcItems[id])
pcOrder = pcItemOrder(S.save.pcItems)
S.selectedPcIdx = clamp(S.selectedPcIdx, 1, math.max(#pcOrder, 1))
mark(S)
end
if not S.selectedItemId or not matches(S.selectedItemId, S.itemQuery) then
S.selectedItemId = choices[1]
end
-- Badges: boolean flags directly on inventory, toggled by click
local badgeLabelY = pcActionsY + 40
Kit.label(x, badgeLabelY, "Badges (click to toggle)")
local badgeY = badgeLabelY + 20
local ids = badgeIds(S.cat)
for i, id in ipairs(ids) do
local col = (i - 1) % 4
local row = math.floor((i - 1) / 4)
local bx = x + col * 150
local by = badgeY + row * 30
local addH = 32 * s
local addY = pickY + pickH - pad - addH
local listTop = qy + 32 * s + 10 * s
local listBottom = addY - 10 * s
local cRowH = 28 * s
local cGap = 5 * s
local visible = math.max(1, math.floor((listBottom - listTop) / (cRowH + cGap)))
Kit.pushClip(x + pad, listTop, leftW - 2 * pad, listBottom - listTop)
for i = 1, math.min(visible, #choices) do
local id = choices[i]
local ry = listTop + (i - 1) * (cRowH + cGap)
if Kit.row(x + pad, ry, leftW - 2 * pad, cRowH, id == S.selectedItemId,
PAL.green, 8 * s) then
S.selectedItemId = id
Ops.say(S, "Picked " .. id)
end
Kit.text("mono", Kit.ellipsize("mono", id, leftW - 2 * pad - 20 * s),
x + pad + 10 * s, ry + (cRowH - Kit.textHeight("mono")) / 2, PAL.text)
end
Kit.popClip()
-- the overflow count rides the caption line, where it can never collide
-- with the list body or the two add buttons below it
if #choices > visible then
Kit.textRight("micro", ("+%d more"):format(#choices - visible),
x + leftW - pad, pickY + pad, PAL.faint)
elseif #choices == 0 then
Kit.text("mono", "no item matches", x + pad + 10 * s, listTop + 8 * s, PAL.faint)
end
local halfW = (leftW - 2 * pad - 8 * s) / 2
if Kit.button(x + pad, addY, halfW, addH, "-> Bag",
{ font = "small", radius = 8 * s, enabled = S.selectedItemId ~= nil }) then
Ops.addToBag(S, S.selectedItemId)
end
if Kit.button(x + pad + halfW + 8 * s, addY, halfW, addH, "-> PC",
{ font = "small", radius = 8 * s, enabled = S.selectedItemId ~= nil }) then
Ops.addToPc(S, S.selectedItemId)
end
-- ------------------------------------------------------------ badges
local badgeY = y + h - badgeH
Kit.card(x, badgeY, leftW, badgeH)
local earned = 0
for _, id in ipairs(badgeIds) do
if S.save.inventory[id] == true then earned = earned + 1 end
end
Kit.caption(x + pad, badgeY + pad, "BADGES")
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + leftW - pad,
badgeY + pad, PAL.caption)
local bTop = badgeY + pad + Kit.textHeight("caption") + 10 * s
local bW = (leftW - 2 * pad - (badgeCols - 1) * 7 * s) / badgeCols
for i, id in ipairs(badgeIds) do
local bc = (i - 1) % badgeCols
local br = math.floor((i - 1) / badgeCols)
local on = S.save.inventory[id] == true
if Kit.button(bx, by, 144, 26, id .. (on and " [X]" or "")) then
if on then S.save.inventory[id] = nil else S.save.inventory[id] = true end
mark(S)
local short = id:gsub("BADGE$", "")
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
PAL.green, PAL.steel) then
Ops.toggleBadge(S, id)
end
end
-- --------------------------------------------------------------- bag
local order = Bag.order(S.save)
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),
bagX + listW - pad, y + pad, PAL.caption)
local barY = y + pad + Kit.textHeight("caption") + 8 * s
local slotFrac = Bag.slots(S.save) / Bag.CAPACITY
Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100,
slotFrac >= 1 and PAL.yellow or PAL.blue)
local pagerH = 30 * s
local pagerY = y + h - pad - pagerH
local rowsTop = barY + 5 * s + 12 * s
local rowH = 36 * s
local rowGap = 6 * s
local perPage = math.max(1, math.floor((pagerY - 12 * s - rowsTop) / (rowH + rowGap)))
S.bagOffset = Ops.clamp(S.bagOffset or 0, 0, math.max(0, #order - perPage))
if #order == 0 then
Kit.emptyBox(bagX + pad, rowsTop, listW - 2 * pad, 70 * s, "Bag is empty.")
end
for i = 1, math.min(perPage, #order - S.bagOffset) do
local id = order[S.bagOffset + i]
local ry = rowsTop + (i - 1) * (rowH + rowGap)
if quantityRow(S, Kit, bagX + pad, ry, listW - 2 * pad, rowH, id,
S.save.inventory[id] or 0, id == S.selectedBagId,
function() Ops.bagAdjust(S, id, -1) end,
function() Ops.bagAdjust(S, id, 1) end,
function() Ops.bagDrop(S, id) end) then
S.selectedBagId = id
Ops.say(S, ("Selected %s in the bag"):format(id))
end
end
S.bagOffset = Kit.pager(bagX + pad, pagerY, listW - 2 * pad, S.bagOffset,
#order, perPage)
-- -------------------------------------------------------- pc storage
local pcOrder = Ops.pcOrder(S)
Kit.card(pcX, y, listW, h)
Kit.caption(pcX + pad, y + pad, "PC STORAGE")
Kit.textRight("mono", ("%d kinds"):format(#pcOrder), pcX + listW - pad,
y + pad, PAL.caption)
S.pcOffset = Ops.clamp(S.pcOffset or 0, 0, math.max(0, #pcOrder - perPage))
if #pcOrder == 0 then
Kit.emptyBox(pcX + pad, rowsTop, listW - 2 * pad, 70 * s,
"PC storage is empty. Items sent here have no slot cap.")
end
for i = 1, math.min(perPage, #pcOrder - S.pcOffset) do
local id = pcOrder[S.pcOffset + i]
local ry = rowsTop + (i - 1) * (rowH + rowGap)
if quantityRow(S, Kit, pcX + pad, ry, listW - 2 * pad, rowH, id,
S.save.pcItems[id] or 0, id == S.selectedPcId,
function() Ops.pcAdjust(S, id, -1) end,
function() Ops.pcAdjust(S, id, 1) end,
function() Ops.pcDrop(S, id) end) then
S.selectedPcId = id
Ops.say(S, ("Selected %s in PC storage"):format(id))
end
end
S.pcOffset = Kit.pager(pcX + pad, pagerY, listW - 2 * pad, S.pcOffset,
#pcOrder, perPage)
end
return M
+265 -146
View File
@@ -1,16 +1,24 @@
-- Map browser: view any map, follow its warps, and set the save's spawn
-- point / remembered outdoor or heal spot by clicking cells on the
-- rendered map. Reuses the game's own MapLoader/TileRenderer/Warp so the
-- editor's view matches what the player would actually see.
-- Map browser: view any map, follow its warps, and set the save's spawn point
-- / remembered outdoor or heal spot by clicking cells on the rendered map.
-- Reuses the game's own MapLoader/TileRenderer/Warp so the editor's view
-- matches what the player would actually see.
--
-- Three columns: a searchable map list, the viewport, and the spawn
-- inspector. Overlays are drawn in this order so the selection always wins:
-- cyan hollow warp cell (clicking follows the warp)
-- red filled the save's player position
-- green / amber lastHeal / lastOutdoor
-- yellow hollow the current click selection
local MapLoader = require("src.world.MapLoader")
local Warp = require("src.world.Warp")
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local MapBrowser = {}
local LIST_W, LIST_H, ROW_H = 200, 300, 20
local MAX_ROWS = math.floor(LIST_H / ROW_H)
local VIEW_W, VIEW_H = 480, 432
local CELL = 16 -- the walk grid; a cell is 16px of map art
local function clampZoom(z)
if z < 1 then return 1 end
@@ -18,34 +26,28 @@ local function clampZoom(z)
return z
end
-- Point the camera so (cx,cy) lands in the middle of the viewport.
-- Point the camera so (cx,cy) lands in the middle of the viewport. The
-- viewport size is only known while drawing, so it is stashed on S.
local function centerOn(S, cx, cy)
S.mapCamX = cx * 16 - VIEW_W / (2 * S.mapZoom)
S.mapCamY = cy * 16 - VIEW_H / (2 * S.mapZoom)
end
-- Detect outdoor the same way the game treats LAST_MAP sources:
-- OVERWORLD/PLATEAU tilesets, or maps with connections / visited fly spots.
local function isOutdoor(S, map)
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
return true
end
if next(map.def.connections or {}) ~= nil then return true end
return (S.save.visited and S.save.visited[map.id]) or false
local vw = S._mapViewW or 480
local vh = S._mapViewH or 432
S.mapCamX = cx * CELL - vw / (2 * S.mapZoom)
S.mapCamY = cy * CELL - vh / (2 * S.mapZoom)
end
MapBrowser.centerOn = centerOn
local function sortedMapIds(data)
local ids = {}
for id in pairs(data.maps) do table.insert(ids, id) end
for id in pairs(data.maps) do ids[#ids + 1] = id end
table.sort(ids)
return ids
end
-- LAST_MAP warps resolve against the remembered outdoor spot; skip with
-- a status message if the save has none (fresh games, or old saves).
-- Mirrors the game: leaving an OVERWORLD/PLATEAU map via a warp updates
-- lastOutdoor so building exits (Indigo lobby, Route 22 Gate, ) return
-- to the map you entered from.
-- LAST_MAP warps resolve against the remembered outdoor spot; skip with a
-- status message if the save has none (fresh games, or old saves). Mirrors
-- the game: leaving an OVERWORLD/PLATEAU map via a warp updates lastOutdoor
-- so building exits (Indigo lobby, Route 22 Gate, ...) return to the map you
-- entered from.
local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true }
local function goToWarp(S, warp)
@@ -67,19 +69,22 @@ local function goToWarp(S, warp)
S.mapId = destMap
S.mapClickCell = nil
centerOn(S, dx, dy)
-- claim the lazy first-draw centering below, so it does not immediately
-- re-centre the destination map and lose the warp's landing cell
S._mapCenteredFor = destMap
S.status = "Followed warp to " .. destMap
end
-- Screen-space point inside the viewport -> map cell, or nil if the
-- point is outside the viewport or off the edge of the map.
local function cellAtScreen(S, map, Kit, vx, vy)
if Kit.mouseX < vx or Kit.mouseX >= vx + VIEW_W
or Kit.mouseY < vy or Kit.mouseY >= vy + VIEW_H then
-- Screen-space point inside the viewport -> map cell, or nil if the point is
-- outside the viewport or off the edge of the map.
local function cellAtScreen(S, map, Kit, vx, vy, vw, vh)
if Kit.mouseX < vx or Kit.mouseX >= vx + vw
or Kit.mouseY < vy or Kit.mouseY >= vy + vh then
return nil
end
local wx = (Kit.mouseX - vx) / S.mapZoom + S.mapCamX
local wy = (Kit.mouseY - vy) / S.mapZoom + S.mapCamY
local cx, cy = math.floor(wx / 16), math.floor(wy / 16)
local cx, cy = math.floor(wx / CELL), math.floor(wy / CELL)
if not map:inBounds(cx, cy) then return nil end
return cx, cy
end
@@ -90,10 +95,10 @@ function MapBrowser.wheelmoved(S, dy)
end
local PAN_KEYS = {
up = { 0, -16 }, w = { 0, -16 },
down = { 0, 16 }, s = { 0, 16 },
left = { -16, 0 }, a = { -16, 0 },
right = { 16, 0 }, d = { 16, 0 },
up = { 0, -CELL }, w = { 0, -CELL },
down = { 0, CELL }, s = { 0, CELL },
left = { -CELL, 0 }, a = { -CELL, 0 },
right = { CELL, 0 }, d = { CELL, 0 },
}
-- Wired from App.keypressed while the Map tab is active.
@@ -104,90 +109,192 @@ function MapBrowser.keypressed(S, key)
S.mapCamY = (S.mapCamY or 0) + d[2]
end
function MapBrowser.draw(S, Kit, x, y)
Kit.label(x, y, "Map: " .. tostring(S.mapId))
-- Select a map by id. The camera is left to the first-draw centering in
-- draw(), which knows the viewport size and so can actually centre.
function MapBrowser.select(S, id)
S.mapId = id
S.mapClickCell = nil
S._mapCenteredFor = nil
S.status = "Viewing " .. id
end
-- ---- map id list (paginated; ~220 maps is too many for one page) ----
local ids = sortedMapIds(S.data)
S.mapListScroll = S.mapListScroll or 0
local pageIds, selectedIdx = {}, nil
for i = 1, MAX_ROWS do
local id = ids[S.mapListScroll + i]
if id then
pageIds[#pageIds + 1] = id
if id == S.mapId then selectedIdx = #pageIds end
end
-- Called inside the viewport's translate+scale transform, so every rect is
-- in map space: a cell is CELL units wide whatever the zoom is.
local function drawOverlays(S, map)
local function cellRect(cx, cy)
return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL
end
local clickIdx = Kit.list(x, y + 24, LIST_W, MAX_ROWS * ROW_H, pageIds, selectedIdx, ROW_H)
if clickIdx and pageIds[clickIdx] then
S.mapId = pageIds[clickIdx]
S.mapClickCell = nil
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
else
S.mapCamX, S.mapCamY = 0, 0
love.graphics.setColor(0.27, 0.59, 1, 0.55)
for _, wdef in ipairs(map.def.warps) do
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
end
if S.save.player.map == S.mapId then
love.graphics.setColor(1, 0.36, 0.4, 0.9)
love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y))
end
local heal = S.save.lastHeal
if heal and heal.map == S.mapId then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
end
local out = S.save.lastOutdoor
if out and out.id == S.mapId then
love.graphics.setColor(1, 0.8, 0.02, 0.9)
love.graphics.rectangle("line", cellRect(out.x, out.y))
end
if S.mapClickCell then
love.graphics.setColor(1, 1, 0.35, 0.95)
love.graphics.rectangle("line", cellRect(S.mapClickCell.cx, S.mapClickCell.cy))
end
love.graphics.setColor(1, 1, 1, 1)
end
function MapBrowser.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
local pad = 16 * s
S.mapQuery = S.mapQuery or ""
S.mapZoom = clampZoom(S.mapZoom or 2)
local listW = math.max(200 * s, math.min(260 * s, w * 0.2))
local sideW = math.max(230 * s, math.min(300 * s, w * 0.22))
local viewX = x + listW + gap
local viewW = w - listW - sideW - 2 * gap
-- --------------------------------------------------------- the map list
Kit.card(x, y, listW, h)
Kit.caption(x + pad, y + pad, "MAPS")
local qy = y + pad + Kit.textHeight("caption") + 8 * s
S.mapQuery = Kit.textfield("map-query", x + pad, qy, listW - 2 * pad, 32 * s,
S.mapQuery, "search maps...")
local ids = {}
for _, id in ipairs(sortedMapIds(S.data)) do
if S.mapQuery == "" or id:lower():find(S.mapQuery:lower(), 1, true) then
ids[#ids + 1] = id
end
end
local listBottom = y + 24 + MAX_ROWS * ROW_H + 8
if Kit.button(x, listBottom, 60, 24, "Prev") then
S.mapListScroll = math.max(0, S.mapListScroll - MAX_ROWS)
end
if Kit.button(x + 64, listBottom, 60, 24, "Next") then
if S.mapListScroll + MAX_ROWS < #ids then
S.mapListScroll = S.mapListScroll + MAX_ROWS
local gotoH = 34 * s
local gotoY = y + h - pad - gotoH
local pagerH = 30 * s
local pagerY = gotoY - 10 * s - pagerH
local listTop = qy + 32 * s + 10 * s
local mRowH = 26 * s
local mGap = 4 * s
local perPage = math.max(1, math.floor((pagerY - 10 * s - listTop) / (mRowH + mGap)))
S.mapListOffset = Ops.clamp(S.mapListOffset or 0, 0, math.max(0, #ids - perPage))
for i = 1, math.min(perPage, #ids - S.mapListOffset) do
local id = ids[S.mapListOffset + i]
local ry = listTop + (i - 1) * (mRowH + mGap)
if Kit.row(x + pad, ry, listW - 2 * pad, mRowH, id == S.mapId, PAL.blue, 7 * s) then
MapBrowser.select(S, id)
end
Kit.text("tiny", Kit.ellipsize("tiny", id, listW - 2 * pad - 18 * s),
x + pad + 9 * s, ry + (mRowH - Kit.textHeight("tiny")) / 2,
id == S.mapId and PAL.heading or PAL.muted)
end
if Kit.button(x, listBottom + 30, LIST_W, 24, "Go to save location") then
S.mapId = S.save.player.map
S.mapClickCell = nil
centerOn(S, S.save.player.x, S.save.player.y)
if #ids == 0 then
Kit.text("mono", "no map matches", x + pad + 9 * s, listTop + 8 * s, PAL.faint)
end
S.mapListOffset = Kit.pager(x + pad, pagerY, listW - 2 * pad, S.mapListOffset,
#ids, perPage)
if Kit.button(x + pad, gotoY, listW - 2 * pad, gotoH, "Go to save location",
{ font = "small", radius = 9 * s }) then
MapBrowser.select(S, S.save.player.map)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map,
S.save.player.x, S.save.player.y))
end
-- ---- map viewport ----
local vx, vy = x + LIST_W + 20, y + 24
-- ---------------------------------------------------------- the viewport
Kit.card(viewX, y, viewW, h)
local vpad = 18 * s
local vx0 = viewX + vpad
local vinner = viewW - 2 * vpad
local headH = 28 * s
Kit.text("monoBig", tostring(S.mapId), vx0,
y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading)
local ok, map = pcall(MapLoader.load, S.data, S.mapId)
if not ok then
Kit.label(vx, vy, "Failed to load map: " .. tostring(map))
Kit.text("mono", "Failed to load map: " .. tostring(map), vx0,
y + vpad + headH + 20 * s, PAL.red)
return
end
-- love_stub (headless tests) lacks push/pop/scale/scissor; skip the
-- actual render there but keep all click/button logic below running.
local outdoor = Ops.isOutdoor(S, map)
local oLabel = outdoor and "OUTDOOR" or "INDOOR"
local oW = Kit.textWidth("tiny", oLabel) + 16 * s
local oX = vx0 + Kit.textWidth("monoBig", tostring(S.mapId)) + 14 * s
Theme.stroke(oX, y + vpad + (headH - 20 * s) / 2, oW, 20 * s, 6 * s,
PAL.cardBorder, 0.3, 1)
Kit.textCenter("tiny", oLabel, oX,
y + vpad + (headH - 20 * s) / 2 + (20 * s - Kit.textHeight("tiny")) / 2, oW,
outdoor and PAL.green or PAL.muted)
-- zoom cluster, right-aligned in the viewport header
local centerW = 130 * s
local zBtn = 32 * s
local rightEdge = vx0 + vinner
if Kit.button(rightEdge - centerW, y + vpad, centerW, headH, "Center on player",
{ kind = "accent", font = "small", radius = 7 * s }) then
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
Ops.say(S, "Centred on the player")
else
Ops.say(S, "Player isn't on this map")
end
end
local zx = rightEdge - centerW - 10 * s - (2 * zBtn + 56 * s + 12 * s)
if Kit.stepper(zx, y + vpad, zBtn, headH, "-", { radius = 7 * s }) then
S.mapZoom = clampZoom(S.mapZoom - 0.5)
end
Kit.textCenter("mono", ("%.2fx"):format(S.mapZoom), zx + zBtn + 6 * s,
y + vpad + (headH - Kit.textHeight("mono")) / 2, 56 * s, PAL.muted)
if Kit.stepper(zx + zBtn + 62 * s, y + vpad, zBtn, headH, "+", { radius = 7 * s }) then
S.mapZoom = clampZoom(S.mapZoom + 0.5)
end
local legendH = 22 * s
local vy0 = y + vpad + headH + 12 * s
local vh0 = (y + h - vpad - legendH - 10 * s) - vy0
S._mapViewW, S._mapViewH = vinner, vh0
-- First draw of a map: park the camera somewhere meaningful rather than at
-- (0,0), which leaves a small map wedged in the top-left corner. Deferred
-- to here because centerOn needs the viewport size, which only exists once
-- the panel has laid itself out.
if S._mapCenteredFor ~= S.mapId then
S._mapCenteredFor = S.mapId
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
else
centerOn(S, map.widthCells / 2, map.heightCells / 2)
end
end
Theme.col(PAL.bgBot, 1)
love.graphics.rectangle("fill", vx0, vy0, vinner, vh0, 12 * s, 12 * s)
Theme.stroke(vx0, vy0, vinner, vh0, 12 * s, PAL.cardBorder, 0.28, 1)
-- love_stub (headless tests) lacks push/pop/scale/scissor; skip the actual
-- render there but keep all click/button logic below running.
if love.graphics.push then
love.graphics.setScissor(vx, vy, VIEW_W, VIEW_H)
love.graphics.setScissor(math.floor(vx0), math.floor(vy0),
math.ceil(vinner), math.ceil(vh0))
love.graphics.push()
love.graphics.translate(vx, vy)
love.graphics.translate(vx0, vy0)
love.graphics.scale(S.mapZoom, S.mapZoom)
map.renderer:draw(S.mapCamX, S.mapCamY)
if S.save.player.map == S.mapId then
love.graphics.setColor(1, 0.2, 0.2)
love.graphics.rectangle("fill",
S.save.player.x * 16 - S.mapCamX,
S.save.player.y * 16 - S.mapCamY, 16, 16)
end
love.graphics.setColor(0.2, 0.8, 1, 0.5)
for _, w in ipairs(map.def.warps) do
love.graphics.rectangle("line",
w.x * 16 - S.mapCamX, w.y * 16 - S.mapCamY, 16, 16)
end
if S.mapClickCell then
love.graphics.setColor(1, 1, 0.2, 0.9)
love.graphics.rectangle("line",
S.mapClickCell.cx * 16 - S.mapCamX, S.mapClickCell.cy * 16 - S.mapCamY, 16, 16)
end
love.graphics.setColor(1, 1, 1, 1)
drawOverlays(S, map)
love.graphics.pop()
love.graphics.setScissor()
end
-- ---- click handling: warp cells jump the view, others select ----
-- click handling: warp cells jump the view, everything else selects
if Kit.mouseClicked then
local cx, cy = cellAtScreen(S, map, Kit, vx, vy)
local cx, cy = cellAtScreen(S, map, Kit, vx0, vy0, vinner, vh0)
if cx then
local warp = map:warpAtCell(cx, cy)
if warp then
@@ -199,59 +306,71 @@ function MapBrowser.draw(S, Kit, x, y)
end
end
-- ---- info + set-location buttons ----
local by = vy + VIEW_H + 8
Kit.label(vx, by, S.mapClickCell
and string.format("Selected: (%d,%d) zoom %.2fx", S.mapClickCell.cx, S.mapClickCell.cy, S.mapZoom)
or string.format("Click a cell to select it zoom %.2fx", S.mapZoom))
if Kit.button(vx, by + 22, 140, 26, "Set player here") then
if S.mapClickCell then
S.save.player.map = S.mapId
S.save.player.x = S.mapClickCell.cx
S.save.player.y = S.mapClickCell.cy
S.dirty = true
S.status = string.format("Player set to %s (%d,%d)", S.mapId, S.mapClickCell.cx, S.mapClickCell.cy)
-- legend + the current selection readout
local ly = y + h - vpad - legendH + 4 * s
local lx = vx0
local legend = {
{ PAL.blue, "warp", false },
{ PAL.red, "player", true },
{ PAL.green, "lastHeal", false },
{ PAL.yellow, "lastOutdoor", false },
}
for _, item in ipairs(legend) do
local box = 10 * s
if item[3] then
Theme.col(item[1], 1)
love.graphics.rectangle("fill", lx, ly + 2 * s, box, box)
else
S.status = "Click a cell first"
Theme.stroke(lx, ly + 2 * s, box, box, 0, item[1], 1, 1.5 * s)
end
Kit.text("tiny", item[2], lx + box + 6 * s, ly, PAL.muted)
lx = lx + box + 6 * s + Kit.textWidth("tiny", item[2]) + 16 * s
end
Kit.textRight("mono", S.mapClickCell
and ("selected (%d,%d)"):format(S.mapClickCell.cx, S.mapClickCell.cy)
or "click a cell to select it",
vx0 + vinner, ly, PAL.caption)
-- ------------------------------------------------------ spawn inspector
local sx0 = viewX + viewW + gap
Kit.card(sx0, y, sideW, h)
Kit.caption(sx0 + pad, y + pad, "SPAWN POINTS")
local sTop = y + pad + Kit.textHeight("caption") + 12 * s
local sInner = sideW - 2 * pad
local player = S.save.player
local out = S.save.lastOutdoor
local heal = S.save.lastHeal
local spawns = {
{ key = "PLAYER", color = PAL.red,
value = ("%s (%d,%d)"):format(player.map, player.x, player.y),
set = function() Ops.setPlayerHere(S) end },
{ key = "LAST HEAL", color = PAL.green,
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
set = function() Ops.setLastHeal(S) end },
{ key = "LAST OUTDOOR", color = PAL.yellow,
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
set = function() Ops.setLastOutdoor(S, map) end },
}
local spawnH = 62 * s
for i, sp in ipairs(spawns) do
local ry = sTop + (i - 1) * (spawnH + 8 * s)
Theme.row(sx0 + pad, ry, sInner, spawnH, 10 * s, 0.6)
Kit.text("tiny", sp.key, sx0 + pad + 12 * s, ry + 11 * s, sp.color)
local setW = 70 * s
if Kit.button(sx0 + pad + sInner - 12 * s - setW, ry + 8 * s, setW, 26 * s,
"Set here", { kind = "accent", font = "tiny", radius = 7 * s,
enabled = S.mapClickCell ~= nil }) then
sp.set()
end
Kit.text("mono", Kit.ellipsize("mono", sp.value, sInner - 24 * s),
sx0 + pad + 12 * s, ry + spawnH - 10 * s - Kit.textHeight("mono"), PAL.muted)
end
if Kit.button(vx + 150, by + 22, 160, 26, "Set lastOutdoor here") then
if not S.mapClickCell then
S.status = "Click a cell first"
elseif not isOutdoor(S, map) then
S.status = S.mapId .. " doesn't look outdoor (no connections, not visited)"
else
S.save.lastOutdoor = { id = S.mapId, x = S.mapClickCell.cx, y = S.mapClickCell.cy }
S.dirty = true
S.status = "lastOutdoor set to " .. S.mapId
end
end
if Kit.button(vx + 320, by + 22, 140, 26, "Set lastHeal here") then
if S.mapClickCell then
S.save.lastHeal = { map = S.mapId, x = S.mapClickCell.cx, y = S.mapClickCell.cy }
S.dirty = true
S.status = "lastHeal set to " .. S.mapId
else
S.status = "Click a cell first"
end
end
if Kit.button(vx, by + 56, 120, 24, "Center on player") then
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
else
S.status = "Player isn't on this map"
end
end
if Kit.button(vx + 130, by + 56, 60, 24, "Zoom -") then
S.mapZoom = clampZoom((S.mapZoom or 2) - 0.5)
end
if Kit.button(vx + 196, by + 56, 60, 24, "Zoom +") then
S.mapZoom = clampZoom((S.mapZoom or 2) + 0.5)
end
local noteY = sTop + 3 * (spawnH + 8 * s) + 6 * s
Kit.textCenter("tiny",
"Click a cell first. Warp cells follow the warp instead of selecting. " ..
"Arrow keys / WASD pan, the wheel zooms.",
sx0 + pad, noteY, sInner, PAL.caption)
end
return MapBrowser
+203 -79
View File
@@ -1,111 +1,235 @@
-- Modal-ish inspector for S.editingMon (set by Party/Boxes): level, DVs,
-- moves and species, all recalculated through MonOps so stats stay in sync
-- with the Gen1 formulas.
-- The mon inspector: species, level, DVs and moves for whatever S.editingMon
-- points at (a party slot or a box slot), all recalculated through MonOps so
-- stats stay in sync with the Gen1 formulas.
--
-- This used to be a modal overlay floating over the party list, which hid the
-- roster you were comparing against. It is now a permanent right-hand column
-- the Party and Boxes panels dock into (rule 1 of the design spec): the list
-- stays visible while you edit, and Escape clears the selection rather than
-- "closing a window".
local Pokemon = require("src.pokemon.Pokemon")
local MonOps = require("MonOps")
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local MonEditor = {}
local DV_KEYS = { "attack", "defense", "speed", "special" }
local STAT_KEYS = {
{ key = "HP", field = "hp" },
{ key = "ATK", field = "attack" },
{ key = "DEF", field = "defense" },
{ key = "SPD", field = "speed" },
{ key = "SPC", field = "special" },
}
local function mark(S)
S.dirty = true
end
local function findIndex(list, value)
for i, v in ipairs(list) do
if v == value then return i end
-- Front sprites are read straight off the generated cache. One image per
-- species, cached for the process: the old panel called newImage every frame,
-- which re-decoded a PNG sixty times a second.
local spriteCache = {}
function MonEditor.sprite(S, species)
if spriteCache[species] ~= nil then return spriteCache[species] or nil end
local def = S.data.pokemon[species]
local path = def and def.spriteFront
if not path or not love.graphics.newImage then
spriteCache[species] = false
return nil
end
return nil
local ok, img = pcall(love.graphics.newImage, path)
spriteCache[species] = ok and img or false
return ok and img or nil
end
local function setLevel(S, mon, level)
MonOps.setLevel(S.data, mon, level)
mark(S)
-- Draw a species sprite fitted into a box, or a dashed placeholder when the
-- cache has no art for it (a modded species, or a headless run).
function MonEditor.drawSprite(S, Kit, species, x, y, size)
local img = MonEditor.sprite(S, species)
if img and love.graphics.draw and img.getDimensions then
local iw, ih = img:getDimensions()
if iw > 0 and ih > 0 then
local scale = math.min(size / iw, size / ih)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, x + (size - iw * scale) / 2,
y + (size - ih * scale) / 2, 0, scale, scale)
return
end
end
Theme.col(PAL.blue, 0.1)
love.graphics.rectangle("fill", x, y, size, size, 8 * Kit.scale, 8 * Kit.scale)
Theme.col(PAL.cardBorder, 0.35)
Theme.dashed(x, y, size, size, 8 * Kit.scale, 5 * Kit.scale, 4 * Kit.scale)
Kit.textCenter("micro", (species or "?"):sub(1, 3), x,
y + size / 2 - Kit.textHeight("micro") / 2, size, PAL.muted)
end
local function adjustDv(S, mon, key, delta)
MonOps.setDv(S.data, mon, key, mon.dvs[key] + delta)
mark(S)
end
-- Bars are scaled against 400 so a Lv100 legend fills roughly three quarters
-- and the differences between mons stay legible.
local STAT_SCALE = 400
local function cycleMove(S, mon, slot)
local moves = S.cat.moves
local current = mon.moves[slot] and mon.moves[slot].id
local idx = (current and findIndex(moves, current)) or 0
local nextId = moves[(idx % #moves) + 1]
MonOps.setMove(S.data, mon, slot, nextId)
mark(S)
end
function MonEditor.draw(S, Kit, x, y)
function MonEditor.draw(S, Kit, x, y, w, h)
local s = Kit.scale
Kit.card(x, y, w, h)
local mon = S.editingMon
if not mon then return end
local pad = 18 * s
if not mon then
-- The inspector column is always drawn, so it explains itself rather
-- than collapsing and reflowing the panel underneath it.
local tw = math.min(w - 40 * s, 340 * s)
Kit.textCenter("button",
"Pick a slot on the left to inspect it. Every change here re-runs the " ..
"Gen1 stat formulas, so HP and stats stay legal.",
x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted)
return
end
local def = S.data.pokemon[mon.species]
local cx, cy = x + pad, y + pad
local inner = w - 2 * pad
love.graphics.setColor(0.05, 0.05, 0.07)
love.graphics.rectangle("fill", x - 8, y - 8, 604, 700)
-- ---------------------------------------------------------- header row
local sprite = 96 * s
MonEditor.drawSprite(S, Kit, mon.species, cx, cy, sprite)
local hx = cx + sprite + 18 * s
local hw = inner - sprite - 18 * s
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
if ok then
love.graphics.setColor(1, 1, 1)
love.graphics.draw(img, x, y, 0, 2, 2)
Kit.text("title", mon.species, hx, cy, PAL.heading)
local nameW = Kit.textWidth("title", mon.species)
Kit.text("tiny", ("#%03d"):format(def and def.dex or 0), hx + nameW + 12 * s,
cy + Kit.textHeight("title") - Kit.textHeight("tiny") - 2 * s, PAL.caption)
local stepW, stepH = 28 * s, 26 * s
local sx = hx + hw - 2 * stepW - 6 * s
local sy = cy + (Kit.textHeight("title") - stepH) / 2
if Kit.stepper(sx, sy, stepW, stepH, "<", { radius = 7 * s }) then
Ops.stepSpecies(S, mon, -1)
end
if Kit.stepper(sx + stepW + 6 * s, sy, stepW, stepH, ">", { radius = 7 * s }) then
Ops.stepSpecies(S, mon, 1)
end
Kit.textRight("tiny", "species", sx - 8 * s,
sy + (stepH - Kit.textHeight("tiny")) / 2, PAL.caption)
-- level stepper: -5 -1 [Lv] +1 +5, matching MonOps.setLevel's 1..100 clamp
local ly = cy + Kit.textHeight("title") + 14 * s
local lh = 28 * s
Kit.caption(hx, ly + (lh - Kit.textHeight("caption")) / 2, "LEVEL")
local lx = hx + 52 * s
local bw = 40 * s
for _, d in ipairs({ { "-5", -5 }, { "-1", -1 } }) do
if Kit.stepper(lx, ly, bw, lh, d[1], { font = "small", radius = 7 * s }) then
Ops.setLevel(S, mon, mon.level + d[2])
end
lx = lx + bw + 8 * s
end
Kit.textCenter("monoBig", tostring(mon.level), lx,
ly + (lh - Kit.textHeight("monoBig")) / 2, 58 * s, PAL.heading)
lx = lx + 58 * s + 8 * s
for _, d in ipairs({ { "+1", 1 }, { "+5", 5 } }) do
if Kit.stepper(lx, ly, bw, lh, d[1], { font = "small", radius = 7 * s }) then
Ops.setLevel(S, mon, mon.level + d[2])
end
lx = lx + bw + 8 * s
end
Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s,
ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted)
-- ------------------------------------------------------- derived stats
local statsY = cy + sprite + 18 * s
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
statsY = statsY + Kit.textHeight("caption") + 10 * s
local gap = 12 * s
local cellW = (inner - gap * 4) / 5
local cellH = 68 * s
for i, st in ipairs(STAT_KEYS) do
local bx = cx + (i - 1) * (cellW + gap)
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
local value = (mon.stats and mon.stats[st.field]) or 0
Kit.text("micro", st.key, bx + 12 * s, statsY + 10 * s, PAL.caption)
Kit.text("stat", tostring(value), bx + 12 * s,
statsY + 10 * s + Kit.textHeight("micro") + 4 * s, PAL.heading)
Kit.meter(bx + 12 * s, statsY + cellH - 14 * s, cellW - 24 * s, 5 * s,
value / STAT_SCALE * 100, PAL.blue)
end
Kit.label(x + 140, y, mon.species)
Kit.label(x + 140, y + 18, string.format("Lv %d Exp %d", mon.level, mon.exp))
Kit.label(x + 140, y + 36, string.format("HP %d/%d", mon.hp, mon.stats.hp))
Kit.label(x + 140, y + 54, string.format("Atk %d Def %d Spd %d Spc %d",
mon.stats.attack, mon.stats.defense, mon.stats.speed, mon.stats.special))
-- --------------------------------------------------- DVs | moves split
local colY = statsY + cellH + 18 * s
local colGap = 18 * s
local colW = (inner - colGap) / 2
local rightX = cx + colW + colGap
Kit.label(x, y + 90, "Level")
if Kit.button(x + 60, y + 84, 40, 26, "-5") then setLevel(S, mon, mon.level - 5) end
if Kit.button(x + 104, y + 84, 40, 26, "-1") then setLevel(S, mon, mon.level - 1) end
if Kit.button(x + 148, y + 84, 40, 26, "+1") then setLevel(S, mon, mon.level + 1) end
if Kit.button(x + 192, y + 84, 40, 26, "+5") then setLevel(S, mon, mon.level + 5) end
Kit.caption(cx, colY, "DVs")
Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0),
cx + colW, colY, PAL.caption)
Kit.caption(rightX, colY, "MOVES")
Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption)
if Kit.button(x + 250, y + 84, 130, 26, "Next species") then
local idx = findIndex(S.cat.species, mon.species) or 0
MonOps.setSpecies(S.data, mon, S.cat.species[(idx % #S.cat.species) + 1])
mark(S)
end
local rowY = colY + Kit.textHeight("caption") + 10 * s
local rowH = 34 * s
local rowGap = 8 * s
Kit.label(x, y + 130, "DVs (HP DV auto-derived)")
local dvY = y + 154
for i, key in ipairs(DV_KEYS) do
local ry = dvY + (i - 1) * 30
Kit.label(x, ry + 5, key .. ": " .. mon.dvs[key])
if Kit.button(x + 130, ry, 26, 26, "-") then adjustDv(S, mon, key, -1) end
if Kit.button(x + 160, ry, 26, 26, "+") then adjustDv(S, mon, key, 1) end
local ry = rowY + (i - 1) * (rowH + rowGap)
Theme.row(cx, ry, colW, rowH, 10 * s, 0.6)
local v = mon.dvs[key] or 0
Kit.text("tiny", key:upper(), cx + 10 * s,
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.muted)
local btn = 26 * s
local btnX = cx + colW - 10 * s - 3 * btn - 18 * s
local meterX = cx + 66 * s
local meterW = math.max(20 * s, btnX - meterX - 34 * s)
Kit.meter(meterX, ry + (rowH - 8 * s) / 2, meterW, 8 * s, v / 15 * 100,
v >= 15 and PAL.green or (v >= 10 and PAL.blue or PAL.steel))
Kit.textRight("monoRow", tostring(v), meterX + meterW + 28 * s,
ry + (rowH - Kit.textHeight("monoRow")) / 2, PAL.heading)
if Kit.stepper(btnX, ry + (rowH - btn) / 2, btn, btn, "-") then
Ops.setDv(S, mon, key, v - 1)
end
if Kit.stepper(btnX + btn + 6 * s, ry + (rowH - btn) / 2, btn, btn, "+") then
Ops.setDv(S, mon, key, v + 1)
end
if Kit.button(btnX + 2 * btn + 12 * s, ry + (rowH - btn) / 2, btn, btn,
"15", { kind = "good", font = "micro", radius = 6 * s }) then
Ops.setDv(S, mon, key, 15)
end
end
local hpDvY = dvY + #DV_KEYS * 30 + 6
Kit.label(x, hpDvY, "hp: " .. mon.dvs.hp)
local movesY = hpDvY + 34
Kit.label(x, movesY, "Moves (click a slot to cycle)")
for slot = 1, 4 do
local ry = movesY + 24 + (slot - 1) * 30
local mv = mon.moves[slot]
local text = mv and string.format("%d. %s PP %d", slot, mv.id, mv.pp)
or (slot .. ". --")
if Kit.button(x, ry, 320, 26, text) then
cycleMove(S, mon, slot)
local ry = rowY + (slot - 1) * (rowH + rowGap)
Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6)
local mv = mon.moves and mon.moves[slot]
local clear = 24 * s
local clearX = rightX + colW - 10 * s - clear
local ppText = mv and ("PP %d"):format(mv.pp or 0) or ""
local ppW = Kit.textWidth("tiny", ppText)
Kit.text("mono", tostring(slot), rightX + 10 * s,
ry + (rowH - Kit.textHeight("mono")) / 2, PAL.faint)
local nameX = rightX + 28 * s
local nameW2 = math.max(20 * s, clearX - 12 * s - ppW - nameX)
Kit.text("monoRow", Kit.ellipsize("monoRow", mv and mv.id or "-- --", nameW2),
nameX, ry + (rowH - Kit.textHeight("monoRow")) / 2,
mv and PAL.text or PAL.faint)
Kit.textRight("tiny", ppText, clearX - 10 * s,
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption)
-- the row body cycles, the x empties: two targets, no modal picker
if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then
Ops.cycleMove(S, mon, slot)
end
if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x",
{ kind = "danger", font = "tiny", radius = 6 * s }) then
Ops.clearMove(S, mon, slot)
end
end
local actionsY = movesY + 24 + 4 * 30 + 10
if Kit.button(x, actionsY, 220, 28, "Reset moves to learnset") then
local learned = Pokemon.movesAtLevel(def, mon.level)
mon.moves = {}
for slot, id in ipairs(learned) do
MonOps.setMove(S.data, mon, slot, id)
end
mark(S)
local actY = rowY + 4 * (rowH + rowGap) + 4 * s
local actH = 34 * s
local actW = (colW - 10 * s) / 2
if Kit.button(rightX, actY, actW, actH, "Reset to learnset",
{ font = "small", radius = 9 * s }) then
Ops.resetMoves(S, mon)
end
if Kit.button(x, actionsY + 38, 100, 28, "Close") then
S.editingMon = nil
if Kit.button(rightX + actW + 10 * s, actY, actW, actH, "Full heal",
{ kind = "good", font = "small", radius = 9 * s }) then
Ops.healMon(S, mon)
end
end
+105 -53
View File
@@ -1,70 +1,122 @@
-- Party panel: lists the active party with add/remove/reorder controls and
-- selects a mon for the MonEditor overlay (App.lua draws that when
-- S.editingMon is set).
-- Party panel: the roster on the left, the mon inspector permanently docked
-- on the right, so the party stays visible while you edit one of its members.
--
-- Reorder lives on the row itself (the up/down pair appears on the selected
-- row) rather than in a bottom button strip, which leaves Add / Remove as the
-- only two panel-level verbs.
local MonOps = require("MonOps")
local PartyMod = require("src.pokemon.Party")
local Theme = require("Theme")
local Ops = require("Ops")
local MonEditor = require("MonEditor")
local PAL = Theme.PAL
local Party = {}
local function mark(S)
S.dirty = true
-- Roster column width: the design's 460px at the reference size, but it gives
-- ground to the inspector on a narrow window so neither column collapses.
local function rosterWidth(w, s)
return math.max(300 * s, math.min(460 * s, w * 0.36))
end
function Party.draw(S, Kit, x, y)
local lines = {}
for i, mon in ipairs(S.save.party) do
lines[i] = string.format("%d. %-12s Lv%-3d HP %d/%d",
i, mon.species, mon.level, mon.hp, mon.stats.hp)
end
-- HP colour follows the game's own health bar thresholds.
local function hpColor(frac)
if frac <= 0.2 then return PAL.red end
if frac <= 0.5 then return PAL.yellow end
return PAL.green
end
Kit.label(x, y, string.format("Party (%d/%d)", #S.save.party, PartyMod.MAX))
local click = Kit.list(x, y + 24, 360, 160, lines, S.selectedParty)
if click then
S.selectedParty = click
S.editingMon = S.save.party[click]
end
function Party.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
local listW = rosterWidth(w, s)
if Kit.button(x, y + 200, 100, 28, "Add") then
if #S.save.party < PartyMod.MAX then
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
table.insert(S.save.party, mon)
S.selectedParty = #S.save.party
mark(S)
Kit.card(x, y, listW, h)
local pad = 18 * s
local cx = x + pad
local innerW = listW - 2 * pad
Kit.caption(cx, y + pad, "PARTY")
Kit.textRight("mono", ("%d/%d"):format(#S.save.party, PartyMod.MAX),
cx + innerW, y + pad, PAL.caption)
-- "+ Add mon" / "Remove" pinned to the card bottom; the roster fills above.
local actH = 34 * s
local actY = y + h - pad - actH
local listTop = y + pad + Kit.textHeight("caption") + 12 * s
local listH = actY - 12 * s - listTop
if #S.save.party == 0 then
Kit.emptyBox(cx, listTop, innerW, listH,
"Party is empty - Add creates a Lv5 mon owned by the save's player.")
else
local rowH = 64 * s
local rowGap = 8 * s
S.selectedParty = Ops.clamp(S.selectedParty or 1, 1, #S.save.party)
for i, mon in ipairs(S.save.party) do
local ry = listTop + (i - 1) * (rowH + rowGap)
if ry + rowH > listTop + listH then break end
local selected = (S.editingMon == mon)
if Kit.row(cx, ry, innerW, rowH, selected, PAL.green) then
Ops.selectParty(S, i)
end
local rpad = 12 * s
local icon = 44 * s
MonEditor.drawSprite(S, Kit, mon.species, cx + rpad,
ry + (rowH - icon) / 2, icon)
-- right cluster first, so the name knows how much room it has left
local rightW = 52 * s
local chipH = 20 * s
local lvText = ("Lv%d"):format(mon.level)
local chipW = Kit.textWidth("tiny", lvText) + 14 * s
local chipX = cx + innerW - rpad - chipW
Theme.stroke(chipX, ry + 10 * s, chipW, chipH, 6 * s, PAL.cardBorder, 0.3, 1)
Kit.textCenter("tiny", lvText, chipX,
ry + 10 * s + (chipH - Kit.textHeight("tiny")) / 2, chipW, PAL.blueInk)
if selected then
local ab = 22 * s
local aw = 24 * s
local ax = cx + innerW - rpad - 2 * aw - 4 * s
local ay = ry + rowH - 10 * s - ab
if Kit.stepper(ax, ay, aw, ab, "^", { font = "tiny" }) then
Ops.partyMove(S, -1)
end
if Kit.stepper(ax + aw + 4 * s, ay, aw, ab, "v", { font = "tiny" }) then
Ops.partyMove(S, 1)
end
rightW = 2 * aw + 4 * s + rpad
end
local tx = cx + rpad + icon + 12 * s
local tw = math.max(40 * s, (cx + innerW - rightW - 10 * s) - tx)
local name = Kit.ellipsize("monoRow", mon.species, tw - 34 * s)
Kit.text("monoRow", name, tx, ry + 10 * s, PAL.heading)
Kit.text("tiny", ("#%d"):format(i),
tx + Kit.textWidth("monoRow", name) + 8 * s, ry + 12 * s, PAL.caption)
local maxHp = (mon.stats and mon.stats.hp) or 1
local frac = Ops.clamp((mon.hp or 0) / math.max(maxHp, 1), 0, 1)
Kit.meter(tx, ry + rowH / 2 + 2 * s, tw, 6 * s, frac * 100, hpColor(frac))
Kit.text("tiny", ("HP %d/%d"):format(mon.hp or 0, maxHp), tx,
ry + rowH - 10 * s - Kit.textHeight("tiny"), PAL.muted)
end
end
if Kit.button(x + 110, y + 200, 100, 28, "Remove") then
local mon = S.save.party[S.selectedParty]
if mon then
table.remove(S.save.party, S.selectedParty)
if S.editingMon == mon then S.editingMon = nil end
S.selectedParty = math.min(S.selectedParty, #S.save.party)
if S.selectedParty < 1 then S.selectedParty = 1 end
mark(S)
end
local halfW = (innerW - 10 * s) / 2
if Kit.button(cx, actY, halfW, actH, "+ Add mon",
{ font = "small", radius = 9 * s,
enabled = #S.save.party < PartyMod.MAX }) then
Ops.partyAdd(S)
end
if Kit.button(cx + halfW + 10 * s, actY, halfW, actH,
Ops.armLabel(S, "party-remove", "Remove"),
{ kind = "danger", font = "small", radius = 9 * s }) then
Ops.partyRemove(S)
end
if Kit.button(x + 220, y + 200, 90, 28, "Move Up") then
local i = S.selectedParty
if i and i > 1 and S.save.party[i] then
S.save.party[i], S.save.party[i - 1] = S.save.party[i - 1], S.save.party[i]
S.selectedParty = i - 1
mark(S)
end
end
if Kit.button(x + 320, y + 200, 100, 28, "Move Down") then
local i = S.selectedParty
if i and S.save.party[i] and S.save.party[i + 1] then
S.save.party[i], S.save.party[i + 1] = S.save.party[i + 1], S.save.party[i]
S.selectedParty = i + 1
mark(S)
end
end
MonEditor.draw(S, Kit, x + listW + gap, y, w - listW - gap, h)
end
return Party