mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
launcher editor and widescreen battle
This commit is contained in:
Executable
+308
@@ -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/"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+93
@@ -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."
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user