Compare commits

..

17 Commits

Author SHA1 Message Date
bryanthaboi 4bd28390ea Merge pull request #680 from bryanthaboi/dev 2026-08-02 13:13:50 -04:00
bryanthaboi fa886899fc Merge pull request #653 from jherediagu/fix/oak-intro-name-confirmation 2026-08-02 13:11:51 -04:00
bryanthaboi a81126a03a Merge pull request #667 from castdrian/metal 2026-08-02 13:11:27 -04:00
bryanthaboi 164c555bb4 Merge pull request #669 from jherediagu/fix/battle-messages-use-rom-text 2026-08-02 13:10:15 -04:00
bryanthaboi e5926893a9 Merge pull request #670 from ShaneMcGovernIE/fix/oak-starter-jingle-668 2026-08-02 13:07:38 -04:00
bryanthaboi dbcaf705c2 Merge pull request #672 from ShaneMcGovernIE/fix/faint-animation-671 2026-08-02 13:05:23 -04:00
bryanthaboi f6809be81d Merge pull request #678 from spiritsnails/feat/ui-layout-option 2026-08-02 13:03:29 -04:00
spiritsnails 3ddf70888e test: ui_layout_option runs ROM-free
The row assertions called Data:load(), which needs data/generated/. The
T1/T2 tier runs without a ROM in CI, so the suite died on the import
rather than failing an assertion. Use T.fixtures.load() like the other
engine suites do.

Verified by moving data/generated aside and re-running: 20/20 with no
imported data present.
2026-08-02 10:54:04 -06:00
spiritsnails fe7dcf33ec feat: UI LAYOUT option, centered by default
Edge docking and zoom-linked UI scaling shipped as unconditional
behaviour. Both are departures from how the port composed the screen, so
they become a setting instead: UI LAYOUT = CENTERED (the default) or
DYNAMIC.

CENTERED is a fixed letterbox. Elements stay where they were drawn in the
160x144 canvas, and the UI does not follow the survey zoom, so screen
furniture neither moves nor resizes under the player. That is what the
pre-anchoring builds did. DYNAMIC is the current behaviour, unchanged.

Both halves matter together: gating only the anchoring would stop the
dialogue box moving but leave it resizing with the zoom, which is the same
complaint in a different form.

Gated at Renderer:setUIAnchor and Renderer:uiScale rather than at each
caller, so one switch covers the dialogue box, its YES/NO, the START menu
and anything anchored later, and no caller knows the option exists.
Game.dynamicUI answers true only for an explicit "dynamic", so a save
written before this keeps the layout it already had.

Independent of it, deliberately: BATTLE SIZE still works under either mode
(uiFill overrides the scale later, in endFrame), and a battle still holds
its own prompts inside its screen under DYNAMIC.

Also includes the Oak intro fix (previously #674): the speech fills white
over the UI canvas while its dialogue box docks to the window edge, so
under DYNAMIC black showed between the two. letterboxWhite closes it, and
the shrink beat's replica box rides the same anchor as the real box it
stands in for.
2026-08-02 10:47:27 -06:00
Shane McGovern 733450bf86 Fix faint slide starting partway down (#671)
The faint slide was shortened from 30 to Timing.FAINT_SLIDE (14) frames
in the timing-parity pass, but fxFaintOffset still computed the offset
with a stale (30 - frames) * 2.  With frames starting at 14 the sprite
teleported 32px down on the first frame and only slid the remaining
28px, cutting the animation short.

SlideDownFaintedMonPic drops the pic one 8px row per 2-frame step, so
the offset advances Timing.FAINT_SLIDE_STEP (4px) per frame at 1x and
covers the full 56px PIC_HEIGHT over the 14-frame budget.
2026-08-02 16:34:02 +01:00
Shane McGovern 1dae9622e1 Play the jingle when Oak hands over the starter (#668)
The starter balls' scripts showed the received-mon text but never played
the sound_get_key_item fanfare that the text carries in the original
(scripts/OaksLab.asm OaksLabReceivedMonText / OaksLabRivalReceivedMonText).
Add play_sound Get_Key_Item before each received text, mirroring the
Yellow starter port.
2026-08-02 16:21:49 +01:00
Juan Heredia da0fa5c9ad Use the ROM's own battle text instead of paraphrasing it 2026-08-02 17:09:08 +02:00
Adrian Castro e576dea676 fix(ios): silence remaining build warnings and refresh artifact comments 2026-08-02 17:07:13 +02:00
github-actions 4cac51a831 chore(ios): update app-repo.json [skip ci] 2026-08-02 08:55:51 -04:00
bryanthaboi fef844e220 Merge pull request #663 from bryanthaboi/dev 2026-08-02 08:49:26 -04:00
bryanthaboi 0f45bb5792 ci: push iOS app-repo.json to main via release deploy key
The release workflow's direct commit of mobile/ios/app-repo.json was
rejected once main went PR-only (GH006). main's protection now lives in
a ruleset whose only bypass actor is deploy keys, so the step commits
with git and pushes over SSH using the RELEASE_DEPLOY_KEY secret
instead of EndBug/add-and-commit with GITHUB_TOKEN.

Also syncs the iOS app repo steps and app-repo.json into dev; they
previously existed only on main.
2026-08-02 08:45:35 -04:00
Juan Heredia b6a397460e fix: Play Oak's name confirmation lines 2026-08-02 08:18:33 +02:00
20 changed files with 679 additions and 97 deletions
@@ -7,6 +7,7 @@ on:
permissions: permissions:
contents: read contents: read
issues: write
pull-requests: write pull-requests: write
jobs: jobs:
@@ -28,6 +29,13 @@ jobs:
[ -n "$pr_number" ] || exit 0 [ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT" echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
- name: Delete existing comment
if: steps.artifact.outputs.pr_number != ''
uses: izhangzhihao/delete-comment@master
with:
github_token: ${{ github.token }}
delete_user_name: github-actions[bot]
issue_number: ${{ steps.artifact.outputs.pr_number }}
- name: Get build info - name: Get build info
id: build-info id: build-info
env: env:
+30 -6
View File
@@ -397,12 +397,18 @@ jobs:
date="$(date -u +"%Y-%m-%d")" date="$(date -u +"%Y-%m-%d")"
size="$(wc -c < "$ipa" | tr -d '[:space:]')" size="$(wc -c < "$ipa" | tr -d '[:space:]')"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa" download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
if [ -n "$release_notes" ]; then
localized_description="$release_notes"
fi
entry="$(jq -n \ entry="$(jq -n \
--arg version "$v" \ --arg version "$v" \
--arg date "$date" \ --arg date "$date" \
--arg download_url "$download_url" \ --arg download_url "$download_url" \
--arg localized_description "$localized_description" \
--argjson size "$size" \ --argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"}')" '{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
if jq -e --arg version "$v" \ if jq -e --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \ 'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
@@ -417,15 +423,33 @@ jobs:
fi fi
mv "$app_repo.tmp" "$app_repo" mv "$app_repo.tmp" "$app_repo"
# main is PR-only for everyone except deploy keys (the "main protection"
# ruleset's bypass actor), so this push must authenticate with the
# RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN
# would be rejected by the branch protection.
- name: Commit iOS app repository - name: Commit iOS app repository
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
uses: EndBug/add-and-commit@v10 env:
with: DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
add: mobile/ios/app-repo.json run: |
default_author: github_actions set -euo pipefail
message: "chore(ios): update app-repo.json [skip ci]" git add mobile/ios/app-repo.json
if git diff --cached --quiet; then
echo "app-repo.json unchanged; nothing to push"
exit 0
fi
key="$RUNNER_TEMP/release-deploy-key"
printf '%s\n' "$DEPLOY_KEY" > "$key"
chmod 600 "$key"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "chore(ios): update app-repo.json [skip ci]"
git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
rm -f "$key"
- name: Clean up signing keychain - name: Clean up signing keychain
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }} if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: | run: |
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/release-deploy-key"
+23 -18
View File
@@ -22,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall) rivalBallX, rivalBall)
return { return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1 { "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 20 }, -- 2 { "jump_if_true", 22 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating) -- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 23 }, -- 4 { "jump_if_false", 25 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef -- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily -- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text; -- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -37,36 +37,41 @@ local function starterBall(askText, species, choseFlag, ownBall,
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives -- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the -- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1. -- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8 -- The received text carries sound_get_key_item (OaksLab.asm
{ "give_pokemon", species, 5 }, -- 9 -- OaksLabReceivedMonText); the jingle plays as the box opens
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10 -- (same beat as the Yellow port's starter, #668).
{ "set_flag", choseFlag }, -- 11 { "play_sound", "Get_Key_Item" }, -- 8
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
{ "give_pokemon", species, 5 }, -- 10
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
{ "set_flag", choseFlag }, -- 12
-- POKé BALLs are not handed out here in the original -- Oak gives -- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the -- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "hide_object", "OAKS_LAB", ownBall }, -- 12 { "hide_object", "OAKS_LAB", ownBall }, -- 13
-- the rival walks to the countering ball (around the furniture) -- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13 { "move_npc_to", 1, rivalBallX, 4 }, -- 14
{ "face_object", 1, "up" }, -- 14 { "face_object", 1, "up" }, -- 15
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16 { "hide_object", "OAKS_LAB", rivalBall }, -- 17
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText", { "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 17 or "BULBASAUR" } }, -- 19
{ "jump", "end" }, -- 18 { "jump", "end" }, -- 20
{ "jump", "end" }, -- 19 (spacer) { "jump", "end" }, -- 21 (spacer)
-- a leftover ball after the player's pick: Oak turns to face the -- a leftover ball after the player's pick: Oak turns to face the
-- player and reads the last-mon line instead of re-offering the -- player and reads the last-mon line instead of re-offering the
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript -> -- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled -- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
-- out as Pokémon here. -- out as Pokémon here.
{ "face_object", 5, "down" }, -- 20 { "face_object", 5, "down" }, -- 22
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21 { "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall -- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
-- through into the pre-pick line below (#601 remnant, reported on #600) -- through into the pre-pick line below (#601 remnant, reported on #600)
{ "jump", "end" }, -- 22 { "jump", "end" }, -- 24
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 23 { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
} }
end end
+9 -1
View File
@@ -11,7 +11,15 @@
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke", "localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [] "versions": [
{
"version": "0.1.58",
"date": "2026-08-02",
"size": 8331163,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.58/gen1recomp-0.1.58-ios.ipa",
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
}
]
} }
] ]
} }
+83 -12
View File
@@ -343,23 +343,12 @@ pack_game_love() {
done done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" 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
# stamps the release version the same way build.sh and build_android.sh do:
# patch a copy of Version.lua (engine set to $VERSION) under a throwaway
# staging dir and replace the entry inside the archive in place -- never the
# source tree. Stamping the Info.plist alone is not enough: the mod loader
# reads Version.engine out of game.love (src/mods/Loader.lua game_version
# gate), so an unstamped archive reports "0.0.0-dev" and every mod with a
# version floor is rejected on iOS while it loads on desktop (#613).
# VERSION is already validated as X.Y.Z above; when it is empty the packaged
# game keeps the "0.0.0-dev" default so a dev build cannot pass for a
# release. The stamp is read back out and the build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love" say "stamping engine version $VERSION into game.love"
local stamp_dir local stamp_dir
stamp_dir="$(mktemp -d)" stamp_dir="$(mktemp -d)"
mkdir -p "$stamp_dir/src/core" mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \ sed -E "s/(engine[[:space:]]*=[[:space:]]*\")([^\"]*)(\")/\1$VERSION\3/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua" "$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua) (cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
local version_re local version_re
@@ -464,6 +453,86 @@ print("patched project.pbxproj")
PY PY
} }
suppress_love_dependency_warnings() {
local liblove_pbx="$XCODE_DIR/liblove.xcodeproj/project.pbxproj"
local love_pbx="$XCODE_DIR/love.xcodeproj/project.pbxproj"
[ -f "$liblove_pbx" ] || fail "missing $liblove_pbx"
[ -f "$love_pbx" ] || fail "missing $love_pbx"
python3 - "$liblove_pbx" "$love_pbx" <<'PY'
import pathlib
import sys
def patch_configs(path, config_ids, settings):
text = path.read_text()
for config_id in config_ids:
marker = f"\t\t{config_id}"
start = text.find(marker)
if start < 0:
raise SystemExit(f"missing configuration {config_id}")
settings_start = text.find("\t\t\tbuildSettings = {\n", start)
block_end = text.find("\n\t\t};", settings_start)
if settings_start < 0 or block_end < 0:
raise SystemExit(f"invalid configuration {config_id}")
block = text[settings_start:block_end]
lines = block.splitlines(keepends=True)
for setting in settings:
key = setting.split(" = ", 1)[0].strip()
prefix = f"{key} ="
replaced = False
normalized = []
for line in lines:
if line.startswith(f"\t\t\t\t{prefix}"):
if not replaced:
normalized.append(setting)
replaced = True
else:
normalized.append(line)
if not replaced:
normalized.insert(1, setting)
lines = normalized
normalized_block = "".join(lines)
if normalized_block != block:
text = text[:settings_start] + normalized_block + text[block_end:]
path.write_text(text)
patch_configs(
pathlib.Path(sys.argv[1]),
(
"FA0B78EF1A958B90000E1D17",
"FA0B78F01A958B90000E1D17",
"FA0B78F11A958B90000E1D17",
),
(
"\t\t\t\tCLANG_WARN_UNINITIALIZED_AUTOS = NO;\n",
"\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = NO;\n",
"\t\t\t\tCLANG_WARN_UNUSED_PARAMETER = NO;\n",
"\t\t\t\tGCC_WARN_CHECK_SWITCH_STATEMENTS = NO;\n",
"\t\t\t\tGCC_WARN_SIGN_COMPARE = NO;\n",
"\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = NO;\n",
"\t\t\t\tGCC_WARN_UNUSED_FUNCTION = NO;\n",
"\t\t\t\tGCC_WARN_UNUSED_PARAMETER = NO;\n",
"\t\t\t\tGCC_WARN_UNUSED_VARIABLE = NO;\n",
"\t\t\t\tOTHER_CFLAGS = \"$(inherited) -Wno-sign-compare -Wno-strict-prototypes -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-parameter -Wno-unused-variable\";\n",
"\t\t\t\tOTHER_CPLUSPLUSFLAGS = \"$(inherited) -Wno-deprecated-declarations -Wno-non-c-typedef-for-linkage -Wno-sign-compare -Wno-switch -Wno-unguarded-availability-new -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-parameter -Wno-unused-private-field -Wno-unused-variable\";\n",
),
)
patch_configs(
pathlib.Path(sys.argv[2]),
(
"FA0B7F261A95AAF4000E1D17",
"FA0B7F271A95AAF4000E1D17",
"FA0B7F281A95AAF4000E1D17",
),
(
"\t\t\t\tCLANG_WARN_UNDECLARED_SELECTOR = NO;\n",
"\t\t\t\tCLANG_WARN_UNUSED_PARAMETER = NO;\n",
"\t\t\t\tOTHER_CFLAGS = \"$(inherited) -Wno-undeclared-selector -Wno-unused-parameter\";\n",
),
)
PY
}
# --------------------------------------------------------------- xcodebuild # --------------------------------------------------------------- xcodebuild
# love.system.pickFile and createFile are a native bridge compiled in by # love.system.pickFile and createFile are a native bridge compiled in by
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the # mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
@@ -543,6 +612,7 @@ run_xcodebuild() {
MARKETING_VERSION="$marketing_version" MARKETING_VERSION="$marketing_version"
CURRENT_PROJECT_VERSION="$project_version" CURRENT_PROJECT_VERSION="$project_version"
ONLY_ACTIVE_ARCH=NO ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
) )
if ! $DEVICE; then if ! $DEVICE; then
@@ -702,6 +772,7 @@ python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests ensure_manifests
pack_game_love pack_game_love
ensure_game_love_in_xcode ensure_game_love_in_xcode
suppress_love_dependency_warnings
if $PACKAGE_ONLY; then if $PACKAGE_ONLY; then
say "package-only: skipping xcodebuild (game.love + plist ready under mobile/ios/love-src/)" say "package-only: skipping xcodebuild (game.love + plist ready under mobile/ios/love-src/)"
+112 -49
View File
@@ -33,6 +33,65 @@ local WideBattle = require("src.battle.WideBattle")
local BattleState = {} local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true BattleState.isOpaque = true
-- pokered prints the battle lines itself (engine/battle/core.asm and the
-- move-effect banks), and the importer extracts every one of them, so the
-- port paraphrasing them in Lua meant the screen showed a near-miss of the
-- game's own wording while the cache held the real line -- and on a
-- localized import it showed English over translated data.
--
-- fromRom prefers the extracted text and keeps the literal as the catalog
-- fallback, for a cache built before the label and for the pure-module
-- tests that run without a dataset. The battle text's slots ({USER},
-- {TARGET}, the {RAM:...} buffers) are NOT in the token registry that
-- TextBox.substitute serves -- it only resolves {PLAYER}, {RIVAL} and
-- three string buffers -- so they are spliced here, in argument order,
-- before the box ever sees the string. {PLAYER}/{RIVAL} are left alone
-- for that later pass.
-- {PLAYER}/{RIVAL} are the two slots TextBox.substitute can fill on its
-- own, so they are only consumed here when the caller clearly supplies
-- them: an argument count matching every slot. Matching just the other
-- slots leaves those two for the later pass. Anything else means the
-- extracted line cannot carry what the call has to say -- a few labels
-- stop at a dynamic marker the decoder does not follow, e.g.
-- _EnemysWeakText extracts as "The enemy's weak!\nGet'm! " with nowhere
-- to put the name -- so the engine's own wording stands in rather than
-- printing a sentence with a hole in it.
local function fromRom(data, label, fallback, ...)
local text = data and data.text and data.text[label]
if not text then return Strings(fallback, ...) end
local args = { ... }
if #args == 0 then return text end
local slots, named = 0, 0
for token in text:gmatch("%b{}") do
slots = slots + 1
if token == "{PLAYER}" or token == "{RIVAL}" then named = named + 1 end
end
local fillNamed
if #args == slots then
fillNamed = true
elseif #args == slots - named then
fillNamed = false
else
return Strings(fallback, ...)
end
local index = 0
return (text:gsub("%b{}", function(token)
if not fillNamed and (token == "{PLAYER}" or token == "{RIVAL}") then
return token
end
index = index + 1
local value = args[index]
if value == nil then return token end
return tostring(value)
end))
end
function BattleState:romText(label, fallback, ...)
return fromRom(self.data, label, fallback, ...)
end
-- Letterbox voids around the 160x144 battle canvas fill white so the -- Letterbox voids around the 160x144 battle canvas fill white so the
-- window reads as one continuous battle screen (no black bars). -- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true BattleState.letterboxWhite = true
@@ -569,9 +628,9 @@ function BattleState.newWild(game, species, level, opts)
self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false) self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false)
markSeen(game, species) markSeen(game, species)
if opts and opts.hooked then if opts and opts.hooked then
self.introText = Strings("The hooked\n%s\nattacked!", self.enemy.name) self.introText = self:romText("_HookedMonAttackedText", "The hooked\n%s\nattacked!", self.enemy.name)
else else
self.introText = Strings("Wild %s\nappeared!", self.enemy.name) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name)
end end
return self return self
end end
@@ -753,7 +812,7 @@ function BattleState:queueScopeReveal()
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!")) or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end) self:act(function() self.ghostReveal = { t = 0 } end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES }) table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(Strings("Wild %s\nappeared!", self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name)) self.ghostReal and self.ghostReal.name or self.enemy.name))
end end
@@ -1292,7 +1351,7 @@ function BattleState:sendOutText(name)
if pct >= 70 then return Strings("Go! %s!", name) end if pct >= 70 then return Strings("Go! %s!", name) end
if pct >= 40 then return Strings("Do it! %s!", name) end if pct >= 40 then return Strings("Do it! %s!", name) end
if pct >= 10 then return Strings("Get'm! %s!", name) end if pct >= 10 then return Strings("Get'm! %s!", name) end
return Strings("The enemy's weak!\nGet'm! %s!", name) return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
end end
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the -- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
@@ -1866,11 +1925,11 @@ function BattleState:update(dt)
end end
local mv = moves[self.moveIndex] local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then if self.player.disabledSlot == self.moveIndex then
self:say(Strings("The move is\ndisabled!")) self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages" self.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
elseif mv.pp <= 0 then elseif mv.pp <= 0 then
self:say(Strings("No PP left for\nthis move!")) self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages" self.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
else else
@@ -1920,7 +1979,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
table.insert(self.queue, self.nextInsert, { wait = 50 }) table.insert(self.queue, self.nextInsert, { wait = 50 })
if target.invulnerable if target.invulnerable
or not self:accuracyRoll(move, user, target) then or not self:accuracyRoll(move, user, target) then
self:sayNext(Strings("But, it failed!")) self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return return
end end
local slots = {} local slots = {}
@@ -1930,7 +1989,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
if #slots == 0 then if #slots == 0 then
-- .getRandomMove rerolls empty slots forever; a moveless target -- .getRandomMove rerolls empty slots forever; a moveless target
-- can't happen in practice, so just fail instead of hanging -- can't happen in practice, so just fail instead of hanging
self:sayNext(Strings("But, it failed!")) self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return return
end end
if user.isPlayer and self.kind ~= "link" then if user.isPlayer and self.kind ~= "link" then
@@ -1991,7 +2050,7 @@ function BattleState:applyMimic(user, target, moveInst, slot)
entry.mimic = true entry.mimic = true
self:animNext("MIMIC", user.isPlayer) self:animNext("MIMIC", user.isPlayer)
-- _MimicLearnedMoveText: "<USER> / learned / MOVE!" -- _MimicLearnedMoveText: "<USER> / learned / MOVE!"
self:sayNext(Strings("%s\nlearned\n%s!", displayName(user), self:sayNext(self:romText("_MimicLearnedMoveText", "%s\nlearned\n%s!", displayName(user),
self.data.moves[src.id].name)) self.data.moves[src.id].name))
end end
@@ -3053,7 +3112,7 @@ function BattleState:executeAction(user, target, action)
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil user.mustRecharge = nil
self:sayNext(Strings("%s\nmust recharge!", displayName(user))) self:sayNext(self:romText("_MustRechargeText", "%s\nmust recharge!", displayName(user)))
return return
end end
if action.special == "bound" then if action.special == "bound" then
@@ -3098,8 +3157,8 @@ function BattleState:statusOnomatopoeia(user, kind)
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM" anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end end
local text = kind == "sleep" local text = kind == "sleep"
and Strings("%s\nis fast asleep!", displayName(user)) and self:romText("_FastAsleepText", "%s\nis fast asleep!", displayName(user))
or Strings("%s\nis confused!", displayName(user)) or self:romText("_IsConfusedText", "%s\nis confused!", displayName(user))
if kind == "sleep" and isPlayer then if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer) self:animNext(anim, isPlayer)
self:sayNext(text) self:sayNext(text)
@@ -3138,18 +3197,18 @@ function BattleState:preRechargeChecks(user, target)
user.sleepTurns = (user.sleepTurns or 1) - 1 user.sleepTurns = (user.sleepTurns or 1) - 1
if user.sleepTurns <= 0 then if user.sleepTurns <= 0 then
mon.status = nil mon.status = nil
self:sayNext(Strings("%s\nwoke up!", displayName(user))) self:sayNext(self:romText("_WokeUpText", "%s\nwoke up!", displayName(user)))
else else
self:statusOnomatopoeia(user, "sleep") self:statusOnomatopoeia(user, "sleep")
end end
return true return true
end end
if mon.status == "FRZ" then if mon.status == "FRZ" then
self:sayNext(Strings("%s\nis frozen solid!", displayName(user))) self:sayNext(self:romText("_IsFrozenText", "%s\nis frozen solid!", displayName(user)))
return true return true
end end
if target.trappingTurns then if target.trappingTurns then
self:sayNext(Strings("%s\ncan't move!", displayName(user))) self:sayNext(self:romText("_CantMoveText", "%s\ncan't move!", displayName(user)))
return true return true
end end
if user.flinched then if user.flinched then
@@ -3157,7 +3216,7 @@ function BattleState:preRechargeChecks(user, target)
-- player recharges, so the flinch eats the recharge turn and the -- player recharges, so the flinch eats the recharge turn and the
-- flag survives (the Hyper Beam flinch glitch) -- flag survives (the Hyper Beam flinch glitch)
user.flinched = false user.flinched = false
self:sayNext(Strings("%s\nflinched!", displayName(user))) self:sayNext(self:romText("_FlinchedText", "%s\nflinched!", displayName(user)))
return true return true
end end
return false return false
@@ -3178,7 +3237,7 @@ function BattleState:statusInterrupt(user, target)
{ id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 },
{ rng = self.rng, forceCrit = false, typeless = true, { rng = self.rng, forceCrit = false, typeless = true,
screens = target }) screens = target })
self:sayNext(Strings("It hurt itself in\nits confusion!")) self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
self:clearVolatiles(user, true) self:clearVolatiles(user, true)
self:applyDamage(user, dmg) self:applyDamage(user, dmg)
if user.mon.hp <= 0 then self:onFaint(user) end if user.mon.hp <= 0 then self:onFaint(user) end
@@ -3266,7 +3325,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self.moveAnimRow = nil self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
self:sayNext(Strings("%s\nused %s!", displayName(user), move.name)) self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
-- the move's animation plays right after the announcement; the -- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the -- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the -- blink follows the animation (pokered's order). Mimic is the
@@ -3351,7 +3410,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
-- after the effect lands; a miss skips it -- after the effect lands; a miss skips it
self:cancelMoveAnim() self:cancelMoveAnim()
self:sayNext(Strings("%s's\nattack missed!", displayName(user))) self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
return return
end end
local msgs = record.run(ctx) local msgs = record.run(ctx)
@@ -3371,7 +3430,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if move.power == 0 and not (record and record.kind == "full") then if move.power == 0 and not (record and record.kind == "full") then
MoveEffects.warnUnknown(move.effect) MoveEffects.warnUnknown(move.effect)
self:cancelMoveAnim() self:cancelMoveAnim()
self:sayNext(Strings("But, it failed!")) self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return return
end end
@@ -3380,7 +3439,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end end
function BattleState:continueTrapping(user, target) function BattleState:continueTrapping(user, target)
self:sayNext(Strings("%s's\nattack continues!", displayName(user))) self:sayNext(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user)))
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
-- then jumps to GetPlayerAnimationType, so the trapping move's full -- then jumps to GetPlayerAnimationType, so the trapping move's full
-- animation replays each locked turn (same damage, animation shown). -- animation replays each locked turn (same damage, animation shown).
@@ -3406,12 +3465,12 @@ function BattleState:continueBide(user, target)
self:sayNext(Strings("%s\nis storing energy!", displayName(user))) self:sayNext(Strings("%s\nis storing energy!", displayName(user)))
return return
end end
self:sayNext(Strings("%s\nunleashed energy!", displayName(user))) self:sayNext(self:romText("_UnleashedEnergyText", "%s\nunleashed energy!", displayName(user)))
local dmg = (user.bideDamage or 0) * 2 local dmg = (user.bideDamage or 0) * 2
user.bideTurns, user.bideDamage = nil, nil user.bideTurns, user.bideDamage = nil, nil
if dmg <= 0 then if dmg <= 0 then
self:cancelMoveAnim() self:cancelMoveAnim()
self:sayNext(Strings("But, it failed!")) self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
return return
end end
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE -- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
@@ -3434,9 +3493,9 @@ function BattleState:applyDamage(target, dmg)
target.substituteHP = target.substituteHP - dmg target.substituteHP = target.substituteHP - dmg
if target.substituteHP <= 0 then if target.substituteHP <= 0 then
target.substituteHP = nil target.substituteHP = nil
self:sayNext(Strings("%s's\nSUBSTITUTE broke!", displayName(target))) self:sayNext(self:romText("_SubstituteBrokeText", "%s's\nSUBSTITUTE broke!", displayName(target)))
else else
self:sayNext(Strings("The SUBSTITUTE\ntook damage for\n%s!", displayName(target))) self:sayNext(self:romText("_SubstituteTookDamageText", "The SUBSTITUTE\ntook damage for\n%s!", displayName(target)))
end end
return dmg return dmg
end end
@@ -3448,7 +3507,7 @@ function BattleState:applyDamage(target, dmg)
end end
if target.rageMove and dealt > 0 then if target.rageMove and dealt > 0 then
target.stages.attack = math.min(6, (target.stages.attack or 0) + 1) target.stages.attack = math.min(6, (target.stages.attack or 0) + 1)
self:sayNext(Strings("%s's\nRAGE is building!", displayName(target))) self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target)))
end end
return dealt return dealt
end end
@@ -3776,7 +3835,7 @@ function BattleState:enemyMonFainted()
-- scripted battles that print their own follow-up leave it nil. -- scripted battles that print their own follow-up leave it nil.
self:actNext(function() self:playVictoryMusic() end) self:actNext(function() self:playVictoryMusic() end)
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!" -- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name, self:sayNext(self:romText("_TrainerDefeatedText", "%s defeated\n%s!", self.game.save.player.name,
self.trainer.name)) self.trainer.name))
self:actNext(function() self:actNext(function()
self.showEnemyTrainer = self.trainerPic ~= nil self.showEnemyTrainer = self.trainerPic ~= nil
@@ -3803,7 +3862,7 @@ function BattleState:enemyMonFainted()
end end
end end
end end
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize)) self:sayNext(self:romText("_MoneyForWinningText", "%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
end end
self.result = "win" self.result = "win"
self.afterQueue = "finish" self.afterQueue = "finish"
@@ -3819,7 +3878,7 @@ function BattleState:learnMove(mon, moveId)
if #mon.moves < 4 then if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
self:sayNext(Strings("%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name, self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name)) mdef.name))
return return
end end
@@ -3901,11 +3960,11 @@ function BattleState:playerMonFainted()
local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0
if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
self:say(Strings("Got away safely!")) self:say(self:romText("_GotAwayText", "Got away safely!"))
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
else else
self:say(Strings("Can't escape!")) self:say(self:romText("_CantEscapeText", "Can't escape!"))
end end
end) end)
end) end)
@@ -3925,7 +3984,7 @@ function BattleState:openReplacementMenu()
forceSwitch = true, forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon.hp <= 0 then if mon.hp <= 0 then
self:say(Strings("There's no will\nto fight!")) self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
return -- the menu-phase guard reopens the menu return -- the menu-phase guard reopens the menu
end end
self:restoreMimicked(self.player) self:restoreMimicked(self.player)
@@ -3969,7 +4028,7 @@ function BattleState:safariAction(choice)
if choice == "run" then if choice == "run" then
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
self:say(Strings("Got away safely!")) self:say(self:romText("_GotAwayText", "Got away safely!"))
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
return return
@@ -4006,12 +4065,12 @@ function BattleState:safariAction(choice)
end end
if choice == "bait" then if choice == "bait" then
self:say(Strings("%s threw some\nBAIT.", playerName)) self:say(self:romText("_ThrewBaitText", "%s threw some\nBAIT.", playerName))
self.safariCatchRate = math.floor(self.safariCatchRate / 2) self.safariCatchRate = math.floor(self.safariCatchRate / 2)
self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5)) self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5))
self.escapeFactor = 0 self.escapeFactor = 0
else -- rock else -- rock
self:say(Strings("%s threw a\nROCK.", playerName)) self:say(self:romText("_ThrewRockText", "%s threw a\nROCK.", playerName))
self.safariCatchRate = math.min(255, self.safariCatchRate * 2) self.safariCatchRate = math.min(255, self.safariCatchRate * 2)
self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5)) self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5))
self.baitFactor = 0 self.baitFactor = 0
@@ -4027,13 +4086,13 @@ end
function BattleState:safariEnemyTurn() function BattleState:safariEnemyTurn()
if self.baitFactor > 0 then if self.baitFactor > 0 then
self.baitFactor = self.baitFactor - 1 self.baitFactor = self.baitFactor - 1
self:sayNext(Strings("Wild %s\nis eating!", self.enemy.name)) self:sayNext(self:romText("_SafariZoneEatingText", "Wild %s\nis eating!", self.enemy.name))
elseif self.escapeFactor > 0 then elseif self.escapeFactor > 0 then
self.escapeFactor = self.escapeFactor - 1 self.escapeFactor = self.escapeFactor - 1
if self.escapeFactor == 0 then if self.escapeFactor == 0 then
self.safariCatchRate = self.enemy.def.catchRate self.safariCatchRate = self.enemy.def.catchRate
end end
self:sayNext(Strings("Wild %s\nis angry!", self.enemy.name)) self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name))
end end
self:act(function() self:act(function()
local speed = self.enemy.curStats.speed % 256 local speed = self.enemy.curStats.speed % 256
@@ -4049,7 +4108,7 @@ function BattleState:safariEnemyTurn()
fled = self.rng(0, 255) < b fled = self.rng(0, 255) < b
end end
if fled then if fled then
self:sayNext(Strings("Wild %s\nran!", self.enemy.name)) self:sayNext(self:romText("_WildRanText", "Wild %s\nran!", self.enemy.name))
self:actNext(function() self:actNext(function()
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
startPicKind(self:picFxFor(self.enemy), "slideOff") startPicKind(self:picFxFor(self.enemy), "slideOff")
@@ -4116,11 +4175,11 @@ function BattleState:tryRun()
TurnOrder.effectiveSpeed(self.enemy)) TurnOrder.effectiveSpeed(self.enemy))
if escaped then if escaped then
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
self:say(Strings("Got away safely!")) self:say(self:romText("_GotAwayText", "Got away safely!"))
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
else else
self:say(Strings("Can't escape!")) self:say(self:romText("_CantEscapeText", "Can't escape!"))
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) end)
@@ -4157,9 +4216,9 @@ function BattleState:ballMissMessage(shakes)
elseif shakes == 1 then elseif shakes == 1 then
return t._ItemUseBallText02 or Strings("Darn! The POKéMON\nbroke free!") return t._ItemUseBallText02 or Strings("Darn! The POKéMON\nbroke free!")
elseif shakes == 2 then elseif shakes == 2 then
return (t._ItemUseBallText03 or Strings("Aww! It appeared\nto be caught!")):gsub("%s+$", "") return (t._ItemUseBallText03 or self:romText("_ItemUseBallText03", "Aww! It appeared\nto be caught!")):gsub("%s+$", "")
end end
return t._ItemUseBallText04 or Strings("Shoot! It was so\nclose too!") return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
end end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank, -- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
@@ -4170,7 +4229,7 @@ function BattleState:askNicknameUI(mon, displayName)
self.lockedBall = nil self.lockedBall = nil
self.blankForAskName = true self.blankForAskName = true
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local text = Strings("Do you want to\ngive a nickname\nto %s?", displayName) local text = self:romText("_DoYouWantToNicknameText", "Do you want to\ngive a nickname\nto %s?", displayName)
local label = game.data.text and game.data.text._DoYouWantToNicknameText local label = game.data.text and game.data.text._DoYouWantToNicknameText
if label then if label then
-- extractor CONT is \t; TextBox scrolls on \n/\v -- extractor CONT is \t; TextBox scrolls on \n/\v
@@ -4299,7 +4358,7 @@ function BattleState:throwBall(ball)
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are -- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild". -- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then if self.kind == "wild" then
self:say(Strings("%s used\n%s!", self.game.save.player.name, self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name)) self.data.items[ball].name))
end end
self:act(function() self:act(function()
@@ -4321,9 +4380,9 @@ function BattleState:throwBall(ball)
end) end)
self:animNext("BLOCKBALL_ANIM", true) self:animNext("BLOCKBALL_ANIM", true)
self:sayNext(t._ThrowBallAtTrainerMonText1 self:sayNext(t._ThrowBallAtTrainerMonText1
or Strings("The trainer\nblocked the BALL!")) or self:romText("_ThrowBallAtTrainerMonText1", "The trainer\nblocked the BALL!"))
self:sayNext(t._ThrowBallAtTrainerMonText2 self:sayNext(t._ThrowBallAtTrainerMonText2
or Strings("Don't be a thief!")) or self:romText("_ThrowBallAtTrainerMonText2", "Don't be a thief!"))
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) end)
@@ -4386,7 +4445,7 @@ function BattleState:openParty()
if mon == self.player.mon then if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name)) self:say(Strings("%s is\nalready out!", self.player.name))
elseif mon.hp <= 0 then elseif mon.hp <= 0 then
self:say(Strings("There's no will\nto fight!")) self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
else else
self:resolveSwitch(mon) self:resolveSwitch(mon)
end end
@@ -4412,7 +4471,7 @@ end
function BattleState:finish() function BattleState:finish()
if self.payDay and self.result == "win" then if self.payDay and self.result == "win" then
self.game.save.money = self.game.save.money + self.payDay self.game.save.money = self.game.save.money + self.payDay
self:say(Strings("%s picked up\n¥%d!", self.game.save.player.name, self.payDay)) self:say(self:romText("_PickUpPayDayMoneyText", "%s picked up\n¥%d!", self.game.save.player.name, self.payDay))
self.payDay = nil self.payDay = nil
self.afterQueue = "finish" self.afterQueue = "finish"
self.phase = "messages" self.phase = "messages"
@@ -4542,11 +4601,15 @@ end
-- pixels, so it scales with the pic's draw scale (the player's default 2x -- pixels, so it scales with the pic's draw scale (the player's default 2x
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes -- sinks 2x as fast to sink at the same visual rate); a mod scale composes
-- the same way. scale defaults to the vanilla side scale when unknown. -- the same way. scale defaults to the vanilla side scale when unknown.
-- SlideDownFaintedMonPic drops the pic one 8px row per 2-frame step, so
-- the offset advances Timing.FAINT_SLIDE_STEP (4px) per frame at 1x --
-- the full 56px PIC_HEIGHT slide over the 14-frame budget (#671: the
-- old (30 - frames) math teleported the sprite 32px down on frame one).
function BattleState:fxFaintOffset(battler, scale) function BattleState:fxFaintOffset(battler, scale)
local fx = self.fx local fx = self.fx
if self:fxFaintActive(battler) then if self:fxFaintActive(battler) then
scale = scale or (battler.isPlayer and 2 or 1) scale = scale or (battler.isPlayer and 2 or 1)
return (30 - fx.faint.frames) * 2 * scale return (Timing.FAINT_SLIDE - fx.faint.frames) * Timing.FAINT_SLIDE_STEP * scale
end end
return 0 return 0
end end
+16
View File
@@ -315,6 +315,15 @@ end
-- everything else here: the text box and YES/NO a battle puts up are states -- everything else here: the text box and YES/NO a battle puts up are states
-- of their own sitting above it, and they are exactly the elements that must -- of their own sitting above it, and they are exactly the elements that must
-- stay inside the battle's composition rather than dock to the window. -- stay inside the battle's composition rather than dock to the window.
-- UI LAYOUT: is edge docking switched on? Only the explicit "dynamic" turns
-- it on, so a save written before the option existed -- and any caller with no
-- save at all, which is most of the headless suites -- gets CENTERED, the
-- behaviour the port shipped with.
function Game.dynamicUI(save)
local options = save and save.options
return options ~= nil and options.uiLayout == "dynamic"
end
function Game.uiAnchorsHeldInStack(stack) function Game.uiAnchorsHeldInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i] local state = stack.states[i]
@@ -415,6 +424,13 @@ function Game:draw()
Renderer.uiWorldHold = Renderer.battleDim ~= nil Renderer.uiWorldHold = Renderer.battleDim ~= nil
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen -- ...and a battle keeps its dialogue box and YES/NO inside its own screen
-- instead of letting them dock to the window edge. -- instead of letting them dock to the window edge.
-- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element
-- stays inside the 160x144 canvas and the UI does not follow the survey
-- zoom, so the screen furniture never moves or resizes under the player.
-- That is the composition the port shipped with. DYNAMIC opts into both
-- halves: the dialogue box docks to the window's bottom edge, the START
-- menu to its top right, and the whole UI steps down with the zoom.
Renderer.uiCentered = not Game.dynamicUI(self.save)
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
Renderer:beginFrame(worldBelow) Renderer:beginFrame(worldBelow)
for i = drawFrom, #self.stack.states do for i = drawFrom, #self.stack.states do
+9
View File
@@ -228,6 +228,15 @@ function SaveData.defaultOptions()
-- "black" = plain black bars, "world" = the frozen overworld showing -- "black" = plain black bars, "world" = the frozen overworld showing
-- through, dimmed. See BattleState:bgMode. -- through, dimmed. See BattleState:bgMode.
battleBg = "white", battleBg = "white",
-- UI LAYOUT: "centered" = a fixed letterbox. Every element sits where it
-- was drawn in the 160x144 canvas and the UI does not follow the survey
-- zoom, so nothing moves or resizes under the player. The original
-- composition. "dynamic" = the dialogue box docks to the window's bottom
-- edge, the START menu to its top right, and the UI steps down with the
-- zoom. Centered is the default: dynamic reads better zoomed out, but it
-- moves the screen furniture, so it is opt-in.
-- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale.
uiLayout = "centered",
ruleset = "gen1_faithful", ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume -- 0-7 like the GB's NR50 master volume
musicVol = 7, musicVol = 7,
+1
View File
@@ -129,6 +129,7 @@ Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941 Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144 Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
Timing.FAINT_SLIDE_STEP = 8 / Timing.FAINT_SLIDE_ROW -- 4px per frame at 1x
Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
-- HP bar (engine/gfx/hp_bar.asm) --------------------------------------------- -- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
+15
View File
@@ -142,6 +142,13 @@ end
function Renderer:uiScale() function Renderer:uiScale()
local S = self:fitScale() local S = self:fitScale()
local off = Zoom.offset or 0 local off = Zoom.offset or 0
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw): the UI is
-- a fixed letterbox at the fit scale and does not follow the survey zoom at
-- all, which is the whole point of the setting -- the screen furniture
-- stays put instead of resizing under the player. DYNAMIC keeps the
-- step-down below. BATTLE SIZE is unaffected either way: uiFill overrides
-- the scale in endFrame, after this.
if self.uiCentered then return S end
-- Only follow the zoom when a world is actually behind the UI. Survey zoom -- Only follow the zoom when a world is actually behind the UI. Survey zoom
-- is an OVERWORLD control; the title screen, the intro and the credits show -- is an OVERWORLD control; the title screen, the intro and the credits show
-- no world at all, and shrinking them to match a zoom level the player set -- no world at all, and shrinking them to match a zoom level the player set
@@ -630,6 +637,14 @@ end
-- pixels, and consumed by endFrame this frame only. -- pixels, and consumed by endFrame this frame only.
-- anchor: "bottom" | "topright" | "topleft" | "bottomright" -- anchor: "bottom" | "topright" | "topleft" | "bottomright"
function Renderer:setUIAnchor(x, y, w, h, anchor) function Renderer:setUIAnchor(x, y, w, h, anchor)
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
-- save.options.uiLayout): every element stays where it was drawn in the
-- 160x144 canvas and the letterbox centres the lot, which is how the port
-- behaved before edge docking existed. This is the DEFAULT; DYNAMIC opts
-- back into docking. Gating here rather than at each caller means one
-- switch covers the dialogue box, its YES/NO, the START menu and anything
-- added later, and none of them has to know the option exists.
if self.uiCentered then return end
-- uiAnchorHold (Game:draw): a state that composes its own screen -- a -- uiAnchorHold (Game:draw): a state that composes its own screen -- a
-- battle -- keeps every element inside it, so the box blits where it was -- battle -- keeps every element inside it, so the box blits where it was
-- drawn in the canvas instead of being pulled to the window edge. -- drawn in the canvas instead of being pulled to the window edge.
+35
View File
@@ -20,6 +20,16 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true OakSpeech.isOpaque = true
-- The speech is a white field with a pic on it, and its dialogue box docks to
-- the WINDOW's bottom edge (Renderer:setUIAnchor, via TextBox). The white it
-- fills below is only the 160x144 UI canvas, so once the box moved to the
-- window edge the two stopped touching: black letterbox showed between the
-- bottom of Oak's white and the top of the box he is speaking from. Filling
-- the voids with the paper shade -- the same opt-in a battle uses -- puts the
-- box back on the field. Not a literal 1,1,1: the canvas is colorized, so
-- endFrame matches it with PaletteFX.paperShade.
OakSpeech.letterboxWhite = true
-- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon -- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon
-- sprite in from the right. Both play out before the beat's text prints. -- sprite in from the right. Both play out before the beat's text prints.
local FADE_FRAMES = 24 local FADE_FRAMES = 24
@@ -46,6 +56,8 @@ local FALLBACKS = {
_OakSpeechText3 = Strings.source("{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"), _OakSpeechText3 = Strings.source("{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"),
_IntroducePlayerText = Strings.source("First, what is\nyour name?"), _IntroducePlayerText = Strings.source("First, what is\nyour name?"),
_IntroduceRivalText = Strings.source("This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"), _IntroduceRivalText = Strings.source("This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"),
_YourNameIsText = Strings.source("Right! So your\nname is {PLAYER}!"),
_HisNameIsText = Strings.source("That's right! I\nremember now! His\vname is {RIVAL}!"),
} }
local function textOr(game, key) local function textOr(game, key)
@@ -152,6 +164,14 @@ function OakSpeech.defaultSteps(speech)
presetsWho = "player", presetsWho = "player",
presetsFallback = { "RED", "ASH", "JACK" }, presetsFallback = { "RED", "ASH", "JACK" },
}, },
{
-- oak_speech.asm prints YourNameIsText right after the naming screen
-- returns ("Right! So your name is RED!"); the port went straight on
-- to the rival and dropped it, in every language.
id = "confirm_player_name",
kind = "say",
textKey = "_YourNameIsText",
},
{ {
id = "ask_rival_name", id = "ask_rival_name",
kind = "say", kind = "say",
@@ -166,6 +186,12 @@ function OakSpeech.defaultSteps(speech)
presetsWho = "rival", presetsWho = "rival",
presetsFallback = { "BLUE", "GARY", "JOHN" }, presetsFallback = { "BLUE", "GARY", "JOHN" },
}, },
{
-- HisNameIsText, the rival's counterpart to the confirmation above
id = "confirm_rival_name",
kind = "say",
textKey = "_HisNameIsText",
},
{ {
id = "legend", id = "legend",
kind = "say", kind = "say",
@@ -611,6 +637,15 @@ function OakSpeech:draw()
love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60) love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60)
end end
if self.shrinkText then if self.shrinkText then
-- This is a REPLICA of the dialogue box that just closed, redrawn at
-- TextBox's own rect (BOX_TX..BOX_TH = 0,12,20,6) so the last page holds
-- while the pic shrinks. The real box rides the bottom anchor, so this
-- one has to as well -- otherwise the text visibly jumps up a letterbox
-- on the frame the real box is swapped for this copy.
local r = self.game and self.game.renderer
if r and r.setUIAnchor then
r:setUIAnchor(0, 12 * 8, 20 * 8, 6 * 8, "bottom")
end
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.shrinkText) do for i, line in ipairs(self.shrinkText) do
+17
View File
@@ -191,6 +191,23 @@ local function buildRows(game)
o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1] o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1]
return true return true
end }, end },
-- CENTERED is a fixed letterbox: elements stay inside the 160x144 canvas
-- and the UI does not follow the survey zoom, so nothing moves or resizes
-- under the player. The composition the port shipped with. DYNAMIC docks
-- the dialogue box to the window's bottom edge and the START menu to its
-- top right, and steps the UI down with the zoom -- easier to read zoomed
-- out, but it moves furniture the original never moved, so it is opt-in.
-- BATTLE SIZE is independent of this and works under either.
{ id = "uiLayout", label = Strings("UI LAYOUT"),
value = function(g)
return g.save.options.uiLayout == "dynamic" and Strings("DYNAMIC")
or Strings("CENTERED")
end,
step = function(g)
local o = g.save.options
o.uiLayout = o.uiLayout == "dynamic" and "centered" or "dynamic"
return true
end },
{ id = "ruleset", label = Strings("RULESET"), { id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end, value = function(g) return rulesetName(g) end,
step = function(g, dir) step = function(g, dir)
@@ -0,0 +1,67 @@
-- Driver: Oak's intro white field must reach the dialogue box.
--
-- The speech fills white over the 160x144 UI canvas, but TextBox docks to the
-- WINDOW's bottom edge (Renderer:setUIAnchor). In a letterboxed window that
-- left black between the bottom of the white and the top of the box.
-- OakSpeech.letterboxWhite fills the voids with the paper shade instead.
--
-- Needs a window that actually letterboxes -- an exact multiple of 160x144
-- has no voids to get wrong -- so it resizes before shooting.
-- POKEPORT_DRIVER=tests/drivers/oak_speech_letterbox_test.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local OakSpeech = require("src.ui.OakSpeech")
local function speechUp()
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == OakSpeech then return s end
end
return nil
end
-- 1000x700 is not a multiple of 160x144, so the UI blits at 4x (640x576)
-- with real bars above/below -- exactly where the seam shows
love.window.setMode(1000, 700)
U.wait(30)
U.wait(5)
U.tap(game, "start") -- skip the intro movie
U.wait(20)
U.tap(game, "a") -- title -> menu
U.wait(20)
U.tap(game, "a") -- NEW GAME
U.wait(30)
local speech
for _ = 1, 600 do
speech = speechUp()
if speech then break end
U.wait(2)
end
if not speech then
U.log("FAIL never reached Oak's speech")
return
end
U.log("Oak speech is up; letterboxWhite =", tostring(OakSpeech.letterboxWhite))
-- page through, shooting a few beats: the pic + box together is the shot
-- that shows whether the white reaches the box
for i = 1, 4 do
for _ = 1, 200 do
local top = game.stack:top()
if top and top ~= speech and top.done then break end
U.tap(game, "a")
U.wait(2)
if not speechUp() then break end
end
if not speechUp() then break end
U.wait(20)
U.shot(game, DIR .. ("/oak_%d.png"):format(i))
U.tap(game, "a")
U.wait(20)
end
U.log("done")
U.wait(30)
end
+53
View File
@@ -0,0 +1,53 @@
-- Driver: UI LAYOUT centered vs dynamic, same moment shot twice.
--
-- Only visible when the window letterboxes, so it resizes first: at an exact
-- multiple of 160x144 there is nowhere for a docked element to dock TO.
-- Shoots the overworld dialogue box and the START menu, the two pieces the
-- option moves.
-- POKEPORT_DRIVER=tests/drivers/ui_layout_option_test.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
love.window.setMode(1000, 700)
U.wait(30)
-- straight into the overworld, no intro
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
U.wait(40)
local function shootBoth(tag)
-- START menu (Menu anchors "topright")
U.tap(game, "start")
U.wait(45)
U.shot(game, DIR .. ("/uilayout_%s_startmenu.png"):format(tag))
U.tap(game, "b")
U.wait(30)
-- a dialogue box (TextBox anchors "bottom"): read the sign by the door
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, "UI LAYOUT check:\nthis box.", function() end))
for _ = 1, 400 do
local top = game.stack:top()
if top and top.done then break end
U.wait(2)
end
U.wait(30)
U.shot(game, DIR .. ("/uilayout_%s_textbox.png"):format(tag))
game.stack:pop()
U.wait(20)
end
game.save.options.uiLayout = "centered"
U.log("UI LAYOUT = centered (the default)")
U.wait(20)
shootBoth("centered")
game.save.options.uiLayout = "dynamic"
U.log("UI LAYOUT = dynamic")
U.wait(20)
shootBoth("dynamic")
U.log("done")
U.wait(20)
end
+9
View File
@@ -62,10 +62,19 @@ end
-- Blank out every Strings(...) / Strings.source(...) call span, parens -- Blank out every Strings(...) / Strings.source(...) call span, parens
-- balanced, so a call wrapped across lines counts as covered. A per-line -- balanced, so a call wrapped across lines counts as covered. A per-line
-- test reported the continuation lines of three real calls as misses. -- test reported the continuation lines of three real calls as misses.
--
-- romText(...) counts as a router too: it prefers the line the importer
-- extracted from the ROM and hands its literal straight to Strings(...)
-- whenever that label is absent (a cache built before it, or a dataset-less
-- unit test), so the literal is still catalog-backed and a translation mod
-- still reaches it. Blanking the whole span is safe -- the only literals
-- inside are the pokered label and that fallback.
local function stripStringsCalls(body) local function stripStringsCalls(body)
local out, i, n = {}, 1, #body local out, i, n = {}, 1, #body
while i <= n do while i <= n do
local s, e = body:find("Strings%.?s?o?u?r?c?e?%(", i) local s, e = body:find("Strings%.?s?o?u?r?c?e?%(", i)
local rs = body:find("romText%(", i)
if rs and (not s or rs < s) then s, e = rs, nil end
if not s then out[#out + 1] = body:sub(i) break end if not s then out[#out + 1] = body:sub(i) break end
out[#out + 1] = body:sub(i, s - 1) out[#out + 1] = body:sub(i, s - 1)
local depth, j = 0, body:find("%(", s) local depth, j = 0, body:find("%(", s)
+25 -8
View File
@@ -94,11 +94,18 @@ T.check(box:find("Those are", 1, true) == nil,
T.check(box:find("#MON", 1, true) == nil, T.check(box:find("#MON", 1, true) == nil,
"the ROM #MON ligature is spelled out as Pokémon") "the ROM #MON ligature is spelled out as Pokémon")
-- the pokered beat also turns Oak to face the player -- the pokered beat also turns Oak to face the player: find the
T.check(contribution.talk[BALL][20][1] == "face_object" -- face_object row in the leftover-ball path (content-based, so a jingle
and contribution.talk[BALL][20][2] == 5 -- row added for #668 doesn't shift the hard-coded index)
and contribution.talk[BALL][20][3] == "down", local oakFaceRow
"row 20 faces Oak down before the line (OaksLabLastMonScript)") for i, row in ipairs(contribution.talk[BALL]) do
if row[1] == "face_object" and row[2] == 5 and row[3] == "down" then
oakFaceRow = i
break
end
end
T.check(oakFaceRow ~= nil,
"a face_object row turns Oak down before the line (OaksLabLastMonScript)")
-- ---- pre-escort: still the vanilla "Those are POKé BALLs" line -- ---- pre-escort: still the vanilla "Those are POKé BALLs" line
local pre = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = false } local pre = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = false }
@@ -115,15 +122,25 @@ T.check(concat(t3):find("last Pokémon!", 1, true) == nil,
"no last-mon line before the pick") "no last-mon line before the pick")
-- ---- all three balls share the same table shape (last-mon beat present) -- ---- all three balls share the same table shape (last-mon beat present)
-- content-based again: locate the leftover-ball "Pokémon" line wherever
-- it sits, instead of pinning a row number (#668 added two jingle rows)
for _, key in ipairs({ for _, key in ipairs({
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL", "TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL", "TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL", "TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}) do }) do
local script = contribution.talk[key] local script = contribution.talk[key]
T.check(script and script[21] and script[21][2] and local lastMon
script[21][2]:find("Pokémon", 1, true) ~= nil, if script then
key .. " carries the last-mon line") for _, row in ipairs(script) do
if row[1] == "show_text" and type(row[2]) == "string"
and row[2]:find("Pokémon", 1, true) then
lastMon = row[2]
break
end
end
end
T.check(lastMon ~= nil, key .. " carries the last-mon line")
end end
T.finish("oaks_lab_last_ball_bug601") T.finish("oaks_lab_last_ball_bug601")
+20
View File
@@ -273,6 +273,26 @@ T.eq(Timing.BLINK_MON % 10, 0,
-- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2 -- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2
T.eq(Timing.FAINT_SLIDE, 14, "the faint slide is 7 steps x 2 frames") T.eq(Timing.FAINT_SLIDE, 14, "the faint slide is 7 steps x 2 frames")
-- #671: the slide must start at the sprite's resting spot and sink the
-- full PIC_HEIGHT (7 rows x 8px = 56px at 1x) across those 14 frames.
-- The old (30 - frames) * 2 math teleported the pic 32px down on frame
-- one once the budget was shortened from 30 to 14 frames.
local faintBattle = newBattle()
faintBattle.fx = { faint = { battler = faintBattle.enemy,
frames = Timing.FAINT_SLIDE } }
T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1), 0,
"the faint slide starts at offset 0 (#671)")
faintBattle.fx.faint.frames = Timing.FAINT_SLIDE - 7
T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1),
7 * Timing.FAINT_SLIDE_STEP,
"the slide sinks one 8px row per step")
faintBattle.fx.faint.frames = 1
T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1),
(Timing.FAINT_SLIDE - 1) * Timing.FAINT_SLIDE_STEP,
"the slide reaches 52px by the last visible frame")
T.eq(Timing.FAINT_SLIDE * Timing.FAINT_SLIDE_STEP, 56,
"and covers the full 7-row pic height over the whole budget")
T.eq(Timing.MOVE_STATUS_OR_MISS, 30, T.eq(Timing.MOVE_STATUS_OR_MISS, 30,
"a status move or a miss holds DelayFrames 30 before its text") "a status move or a miss holds DelayFrames 30 before its text")
+123
View File
@@ -0,0 +1,123 @@
-- UI LAYOUT (save.options.uiLayout): "centered" keeps every element where it
-- was drawn in the 160x144 canvas, so the letterbox centres the whole screen
-- the way the port composed it before edge docking existed. "dynamic" opts
-- into docking: the dialogue box to the window's bottom edge, the START menu
-- to its top right.
--
-- Centered is the DEFAULT. Docking is a real change to where screen
-- furniture sits, so it is opt-in rather than something a player has to
-- discover and turn off.
--
-- One gate, at Renderer:setUIAnchor, so the switch covers the dialogue box,
-- its YES/NO, the START menu and anything added later without any of them
-- knowing the option exists.
-- luajit tests/engine/ui_layout_option.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game = require("src.core.Game")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
-- ------------------------------------------------------------- the default
T.eq(SaveData.newGame().options.uiLayout, "centered",
"a new game starts centered, not docked")
-- ------------------------------------------------------ reading the option
-- Only the explicit "dynamic" switches docking on. Everything else means
-- centered, which is what makes this safe for a save written before the
-- option existed: the key is simply absent and the player keeps the layout
-- they already had.
T.eq(Game.dynamicUI({ options = { uiLayout = "dynamic" } }), true,
"DYNAMIC turns edge docking on")
T.eq(Game.dynamicUI({ options = { uiLayout = "centered" } }), false,
"CENTERED leaves it off")
T.eq(Game.dynamicUI({ options = {} }), false,
"a save from before the option existed is centered")
T.eq(Game.dynamicUI({}), false, "a save with no options at all is centered")
T.eq(Game.dynamicUI(nil), false, "and no save at all is centered")
-- ------------------------------------------------------------- the gate
local function anchorsAfter(opts)
Renderer.uiAnchors = nil
Renderer.uiAnchorHold = opts.hold or false
Renderer.uiCentered = opts.centered or false
-- the dialogue box's own declaration (TextBox:draw)
Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
-- and the START menu's (Menu:draw, anchor "topright")
Renderer:setUIAnchor(80, 0, 80, 88, "topright")
local n = #(Renderer.uiAnchors or {})
Renderer.uiAnchors, Renderer.uiAnchorHold, Renderer.uiCentered =
nil, false, false
return n
end
T.eq(anchorsAfter({ centered = true }), 0,
"CENTERED: neither the dialogue box nor the START menu leaves the canvas")
T.eq(anchorsAfter({ centered = false }), 2,
"DYNAMIC: both dock to the window edge")
-- the battle hold is unchanged by any of this -- a battle still keeps its own
-- prompts inside its screen even with DYNAMIC on (see battle_fixed_menu_scale)
T.eq(anchorsAfter({ centered = false, hold = true }), 0,
"a battle still holds the anchors while DYNAMIC is on")
T.eq(anchorsAfter({ centered = true, hold = true }), 0, "and with it off")
-- ------------------------------------------------- the scale half of it
-- CENTERED is a FIXED letterbox, so the UI must not follow the survey zoom
-- either: the box that stopped moving must not start resizing instead.
local g = love.graphics
local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
g.getDimensions = function() return 640, 576 end
g.getPixelDimensions = function() return 640, 576 end
T.eq(Renderer:fitScale(), 4, "the fixture window fits the classic surface at 4x")
local Zoom = require("src.render.Zoom")
local function scaleAt(offset, centered)
local oldOff, oldActive, oldCentered =
Zoom.offset, Renderer.worldActive, Renderer.uiCentered
-- worldActive true: a live overworld pass, the one case DYNAMIC steps down
Zoom.offset, Renderer.worldActive, Renderer.uiCentered = offset, true, centered
local s = Renderer:uiScale()
Zoom.offset, Renderer.worldActive, Renderer.uiCentered =
oldOff, oldActive, oldCentered
return s
end
T.eq(scaleAt(0, true), 4, "CENTERED at rest is the fit scale")
T.eq(scaleAt(-2, true), 4, "CENTERED zoomed out is STILL the fit scale")
T.eq(scaleAt(0, false), 4, "DYNAMIC at rest matches it")
T.eq(scaleAt(-2, false), 2, "DYNAMIC zoomed out steps the UI down, as before")
g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
-- ------------------------------------------------------------- the row
-- fixture data, not Data:load(): this tier runs ROM-free in CI, so a real
-- load has no data/generated/ to read and takes the suite down with it
local OptionsMenu = require("src.ui.OptionsMenu")
local Data = T.fixtures.load()
require("src.render.Font").load(Data)
local game = { data = Data, save = SaveData.newGame(),
stack = { states = {}, push = function() end,
pop = function() end, top = function() end } }
local menu = OptionsMenu.new(game)
local row
for _, r in ipairs(menu.rows) do
if r.id == "uiLayout" then row = r end
end
T.check(row ~= nil, "OPTIONS carries a UI LAYOUT row")
T.eq(row.value(game), "CENTERED", "and it opens on CENTERED")
row.step(game, 1)
T.eq(game.save.options.uiLayout, "dynamic", "stepping it turns docking on")
T.eq(row.value(game), "DYNAMIC", "and the row says so")
row.step(game, 1)
T.eq(game.save.options.uiLayout, "centered", "stepping again returns to it")
T.finish("ui layout option")
+4 -3
View File
@@ -779,8 +779,9 @@ check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
-- ------- intro.oak_speech.build -- ------- intro.oak_speech.build
local vanillaSteps = OakSpeech.defaultSteps(oak) local vanillaSteps = OakSpeech.defaultSteps(oak)
check(#vanillaSteps == 9, "vanilla speech has nine steps") check(#vanillaSteps == 11, "vanilla speech has eleven steps")
check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink", check(vanillaSteps[1].id == "oak_welcome"
and vanillaSteps[#vanillaSteps].id == "shrink",
"vanilla speech anchors start and end") "vanilla speech anchors start and end")
hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech) hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech)
@@ -799,7 +800,7 @@ hooks:removeOwner("fixture")
hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad") hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
built = oak:buildSteps() built = oak:buildSteps()
check(#built == 9 and built[1].id == "oak_welcome", check(#built == #vanillaSteps and built[1].id == "oak_welcome",
"a non-table intro.oak_speech.build result degrades to vanilla") "a non-table intro.oak_speech.build result degrades to vanilla")
check(logged("intro.oak_speech.build returned"), check(logged("intro.oak_speech.build returned"),
"the intro build degrade is logged") "the intro build degrade is logged")
+20
View File
@@ -34,6 +34,16 @@ Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
require("src.render.Font").load(Data) require("src.render.Font").load(Data)
-- spy on Sound.play so the starter-received jingle beat is observable
-- without real audio (parity_C does the same for its arrival SFX)
local Sound = require("src.core.Sound")
local realSoundPlay = Sound.play
local played = {}
Sound.play = function(data, name)
played[#played + 1] = name
return realSoundPlay(data, name)
end
-- pumps a script coroutine to completion; pressFn returns the Input.pressed -- pumps a script coroutine to completion; pressFn returns the Input.pressed
-- table for this frame (default: mash A through text/ask/naming) -- table for this frame (default: mash A through text/ask/naming)
local function runScript(script, pressFn) local function runScript(script, pressFn)
@@ -64,6 +74,14 @@ check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set")
eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter") eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter")
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR", check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
"starter joined the party") "starter joined the party")
-- #668: OaksLabReceivedMonText carries sound_get_key_item; the jingle
-- must fire as the starter is handed over (once for the player's mon,
-- once for the rival's counter-pick)
local jingles = 0
for _, name in ipairs(played) do
if name == "Get_Key_Item" then jingles = jingles + 1 end
end
eq(jingles, 2, "starter + rival counter-pick both play the Get_Key_Item jingle (#668)")
-- A-mash accepts the nickname prompt and fills NamingScreen with A's -- A-mash accepts the nickname prompt and fills NamingScreen with A's
check(Game.save.party[1].nickname == "AAAAAAAAAA", check(Game.save.party[1].nickname == "AAAAAAAAAA",
"starter nickname prompt accepted (AskName / #137)") "starter nickname prompt accepted (AskName / #137)")
@@ -182,4 +200,6 @@ do
Game.save = realSave Game.save = realSave
end end
Sound.play = realSoundPlay
S.finish() S.finish()