From 8539a6b26882f688a1bb0a74493d699079a6fc99 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 28 Jul 2026 12:00:15 -0400 Subject: [PATCH] launcher editor and widescreen battle --- build-rg34xxsp.sh | 308 +++++++++++++ conf.lua | 7 +- docs/architecture.md | 2 +- docs/new-features.md | 76 ++++ install-rg34xxsp.sh | 93 ++++ main.lua | 123 ++++- scripts/build.sh | 14 +- scripts/build_android.sh | 7 +- scripts/build_ios.sh | 8 +- scripts/test.sh | 6 + src/battle/BattleState.lua | 78 +++- src/battle/WideBattle.lua | 379 ++++++++++++++++ src/core/Data.lua | 20 +- src/core/Game.lua | 10 + src/core/SaveData.lua | 22 + src/import/CacheFs.lua | 62 +++ src/import/RomImporter.lua | 87 +++- src/render/HudTiles.lua | 19 +- src/render/Renderer.lua | 43 +- src/save_convert/SaveConvert.lua | 1 + src/ui/OptionsMenu.lua | 11 + tests/drivers/wide_battle_test.lua | 76 ++++ tests/engine/wide_battle_layout.lua | 56 +++ tests/love_stub.lua | 18 +- tests/mod_battle_tests.lua | 4 +- tests/mod_ui_tests.lua | 46 +- tests/run_save_editor_tests.lua | 260 ++++++++--- tests/run_tests.lua | 41 +- tests/save_editor_task6_tests.lua | 348 +++++++------- tests/save_editor_task7_tests.lua | 378 ++++++---------- tests/save_editor_task8_tests.lua | 292 ++++-------- tools/save-editor/App.lua | 532 +++++++++++++++++++--- tools/save-editor/Catalog.lua | 88 ++-- tools/save-editor/Kit.lua | 421 ++++++++++++++--- tools/save-editor/Ops.lua | 575 ++++++++++++++++++++++++ tools/save-editor/README.md | 60 ++- tools/save-editor/State.lua | 61 ++- tools/save-editor/Theme.lua | 327 ++++++++++++++ tools/save-editor/panels/Boxes.lua | 260 ++++++----- tools/save-editor/panels/Dex.lua | 202 +++++---- tools/save-editor/panels/Events.lua | 346 +++++++------- tools/save-editor/panels/Items.lua | 415 ++++++++--------- tools/save-editor/panels/MapBrowser.lua | 411 +++++++++++------ tools/save-editor/panels/MonEditor.lua | 282 ++++++++---- tools/save-editor/panels/Party.lua | 158 ++++--- 45 files changed, 5236 insertions(+), 1797 deletions(-) create mode 100755 build-rg34xxsp.sh create mode 100755 install-rg34xxsp.sh create mode 100644 src/battle/WideBattle.lua create mode 100644 tests/drivers/wide_battle_test.lua create mode 100644 tests/engine/wide_battle_layout.lua create mode 100644 tools/save-editor/Ops.lua create mode 100644 tools/save-editor/Theme.lua diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh new file mode 100755 index 00000000..80d2efac --- /dev/null +++ b/build-rg34xxsp.sh @@ -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" < "$PORT_ROOT/gameinfo.xml" < + + + ./$LAUNCHER_NAME + gen1recomp + Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM. + 20250101T000000 + the bois club + the bois club + RPG + + +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/" diff --git a/conf.lua b/conf.lua index 56ec8792..04dd2997 100644 --- a/conf.lua +++ b/conf.lua @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index e0fcc029..a1d58df6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/new-features.md b/docs/new-features.md index 73948f99..e07b9805 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -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 ` 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. diff --git a/install-rg34xxsp.sh b/install-rg34xxsp.sh new file mode 100755 index 00000000..dd1574c0 --- /dev/null +++ b/install-rg34xxsp.sh @@ -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." diff --git a/main.lua b/main.lua index 007fe074..897ade18 100644 --- a/main.lua +++ b/main.lua @@ -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) diff --git a/scripts/build.sh b/scripts/build.sh index 61b61a7c..fd38c1dd 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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 diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 39af4826..38a83863 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -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 diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 9733fd81..c81d470a 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -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" } diff --git a/scripts/test.sh b/scripts/test.sh index b36997ad..62a73349 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e85faff0..55910f23 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua new file mode 100644 index 00000000..ff346f8e --- /dev/null +++ b/src/battle/WideBattle.lua @@ -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) -- '' + 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 diff --git a/src/core/Data.lua b/src/core/Data.lua index 701aa832..ed9e4db4 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -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 diff --git a/src/core/Game.lua b/src/core/Game.lua index cb55c1c3..1bad3b9f 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 6ad836c1..875db848 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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 diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 89166851..c85ed8d0 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index c79e2eb9..c6166796 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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() diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index e813930b..8f148039 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -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 diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 1b96385a..099f4b56 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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. diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 3136f389..e908abea 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -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 = {}, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 4a614750..94e064d1 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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) diff --git a/tests/drivers/wide_battle_test.lua b/tests/drivers/wide_battle_test.lua new file mode 100644 index 00000000..e1debd27 --- /dev/null +++ b/tests/drivers/wide_battle_test.lua @@ -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 diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua new file mode 100644 index 00000000..9a349e77 --- /dev/null +++ b/tests/engine/wide_battle_layout.lua @@ -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") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 77dc8b12..00dc65e0 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -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 diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index de123b62..1b5b6242 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -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) diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0c387c34..2e36e956 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -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") diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index 30bd3b31..c8ea0b6f 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -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" diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 3a2d0486..274789d1 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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) diff --git a/tests/save_editor_task6_tests.lua b/tests/save_editor_task6_tests.lua index 12650ca9..011315b9 100644 --- a/tests/save_editor_task6_tests.lua +++ b/tests/save_editor_task6_tests.lua @@ -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)) diff --git a/tests/save_editor_task7_tests.lua b/tests/save_editor_task7_tests.lua index d7a29bf7..b45179fc 100644 --- a/tests/save_editor_task7_tests.lua +++ b/tests/save_editor_task7_tests.lua @@ -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)) diff --git a/tests/save_editor_task8_tests.lua b/tests/save_editor_task8_tests.lua index 04013664..a84be695 100644 --- a/tests/save_editor_task8_tests.lua +++ b/tests/save_editor_task8_tests.lua @@ -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)) diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 8d6caccd..0e005f60 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -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 diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 8efc15bc..b2e61149 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -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) diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 50ba4421..4fb079af 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -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 diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua new file mode 100644 index 00000000..4cfb760f --- /dev/null +++ b/tools/save-editor/Ops.lua @@ -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 diff --git a/tools/save-editor/README.md b/tools/save-editor/README.md index 9cecadb3..89143106 100644 --- a/tools/save-editor/README.md +++ b/tools/save-editor/README.md @@ -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. diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index a3031301..74572f33 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -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 diff --git a/tools/save-editor/Theme.lua b/tools/save-editor/Theme.lua new file mode 100644 index 00000000..762d641f --- /dev/null +++ b/tools/save-editor/Theme.lua @@ -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 diff --git a/tools/save-editor/panels/Boxes.lua b/tools/save-editor/panels/Boxes.lua index 03582ee0..f003d2ab 100644 --- a/tools/save-editor/panels/Boxes.lua +++ b/tools/save-editor/panels/Boxes.lua @@ -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 diff --git a/tools/save-editor/panels/Dex.lua b/tools/save-editor/panels/Dex.lua index 85f466ca..f9988dc4 100644 --- a/tools/save-editor/panels/Dex.lua +++ b/tools/save-editor/panels/Dex.lua @@ -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 diff --git a/tools/save-editor/panels/Events.lua b/tools/save-editor/panels/Events.lua index cf2cc325..a27777ee 100644 --- a/tools/save-editor/panels/Events.lua +++ b/tools/save-editor/panels/Events.lua @@ -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 diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 8802aebd..affda449 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -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 diff --git a/tools/save-editor/panels/MapBrowser.lua b/tools/save-editor/panels/MapBrowser.lua index 59068c45..d901e402 100644 --- a/tools/save-editor/panels/MapBrowser.lua +++ b/tools/save-editor/panels/MapBrowser.lua @@ -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 diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 817bee0e..28121f9a 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -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 diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index 75ec2f0d..037f7c54 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -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