mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fa7c0eb43 | |||
| 839b238e74 | |||
| ec22d57cd1 | |||
| 23ef283272 | |||
| 327579f9b6 | |||
| e5f9d2903d | |||
| 1f3aa4e111 | |||
| 3f88d1c49a | |||
| 5348ba1c65 | |||
| 2f35fd44a1 | |||
| 470fe70f07 | |||
| 399bf557f3 | |||
| fc2ca6cc26 | |||
| d9d42ec956 | |||
| 5e41c74682 | |||
| 02ad846dfa | |||
| 4e7eda65ed | |||
| 3fabe4f591 | |||
| 81234ef2ab | |||
| 4bd28390ea | |||
| fa886899fc | |||
| a81126a03a | |||
| 164c555bb4 | |||
| e5926893a9 | |||
| dbcaf705c2 | |||
| f6809be81d | |||
| 3ddf70888e | |||
| fe7dcf33ec | |||
| 733450bf86 | |||
| 1dae9622e1 | |||
| da0fa5c9ad | |||
| e576dea676 | |||
| 4cac51a831 | |||
| b6a397460e |
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
@@ -28,6 +29,13 @@ jobs:
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$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
|
||||
id: build-info
|
||||
env:
|
||||
|
||||
@@ -397,12 +397,18 @@ jobs:
|
||||
date="$(date -u +"%Y-%m-%d")"
|
||||
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
|
||||
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 \
|
||||
--arg version "$v" \
|
||||
--arg date "$date" \
|
||||
--arg download_url "$download_url" \
|
||||
--arg localized_description "$localized_description" \
|
||||
--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" \
|
||||
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
|
||||
|
||||
@@ -112,6 +112,7 @@ supported out of the box.
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
@@ -121,8 +122,8 @@ supported out of the box.
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
||||
Options menu and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
|
||||
+29
-19
@@ -22,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "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)
|
||||
{ "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
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- 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
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||
{ "give_pokemon", species, 5 }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||
{ "set_flag", choseFlag }, -- 11
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "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
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- 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)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
{ "face_object", 1, "up" }, -- 14
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 14
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", "end" }, -- 18
|
||||
{ "jump", "end" }, -- 19 (spacer)
|
||||
or "BULBASAUR" } }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "jump", "end" }, -- 21 (spacer)
|
||||
-- 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
|
||||
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
|
||||
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
|
||||
-- out as Pokémon here.
|
||||
{ "face_object", 5, "down" }, -- 20
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
|
||||
{ "face_object", 5, "down" }, -- 22
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
|
||||
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
|
||||
-- through into the pre-pick line below (#601 remnant, reported on #600)
|
||||
{ "jump", "end" }, -- 22
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 23
|
||||
{ "jump", "end" }, -- 24
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
|
||||
}
|
||||
end
|
||||
|
||||
@@ -320,9 +325,14 @@ return {
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||
-- departure (lines 144-146), and this exit should match (#596).
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
-- restore the lab theme once he's walked out, same as the Yellow port
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
|
||||
@@ -287,10 +287,12 @@ return {
|
||||
table.insert(rows, { "label", "lost_lab" })
|
||||
table.insert(rows, { "set_field", "rivalStarter", 3 })
|
||||
table.insert(rows, { "label", "exit" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
|
||||
-- player, restore the lab theme
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player (#683).
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
|
||||
@@ -56,6 +56,7 @@ for mapId, holes in pairs(HOLE_FALLS) do
|
||||
M[mapId].onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(holes) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -942,6 +942,7 @@ M.VICTORY_ROAD_3F = {
|
||||
-- fall is onStep, not a collision block.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 23 and y == 15 then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -118,6 +118,7 @@ local MANSION_HOLES = {
|
||||
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(MANSION_HOLES) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
{
|
||||
"name": "gen1recomp App Repo",
|
||||
"identifier": "com.theboisclub.gen1recomp.repo",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||
"apps": [
|
||||
{
|
||||
"name": "gen1recomp",
|
||||
"bundleIdentifier": "com.theboisclub.gen1recomp",
|
||||
"developerName": "bryanthaboi",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": []
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.59",
|
||||
"date": "2026-08-02",
|
||||
"size": 8334731,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.59/gen1recomp-0.1.59-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #668 No Jingle playing when receiving first Pokemon from Oak\n- #671 faint animation error\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @jherediagu\n- @spiritsnails\n- Shane McGovern"
|
||||
},
|
||||
{
|
||||
"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
@@ -343,23 +343,12 @@ pack_game_love() {
|
||||
done
|
||||
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
|
||||
say "stamping engine version $VERSION into game.love"
|
||||
local stamp_dir
|
||||
stamp_dir="$(mktemp -d)"
|
||||
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"
|
||||
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||
local version_re
|
||||
@@ -464,6 +453,86 @@ print("patched project.pbxproj")
|
||||
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
|
||||
# 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
|
||||
@@ -543,6 +612,7 @@ run_xcodebuild() {
|
||||
MARKETING_VERSION="$marketing_version"
|
||||
CURRENT_PROJECT_VERSION="$project_version"
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
||||
)
|
||||
|
||||
if ! $DEVICE; then
|
||||
@@ -702,6 +772,7 @@ python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
ensure_manifests
|
||||
pack_game_love
|
||||
ensure_game_love_in_xcode
|
||||
suppress_love_dependency_warnings
|
||||
|
||||
if $PACKAGE_ONLY; then
|
||||
say "package-only: skipping xcodebuild (game.love + plist ready under mobile/ios/love-src/)"
|
||||
|
||||
+112
-49
@@ -33,6 +33,65 @@ local WideBattle = require("src.battle.WideBattle")
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
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
|
||||
-- window reads as one continuous battle screen (no black bars).
|
||||
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)
|
||||
markSeen(game, species)
|
||||
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
|
||||
self.introText = Strings("Wild %s\nappeared!", self.enemy.name)
|
||||
self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name)
|
||||
end
|
||||
return self
|
||||
end
|
||||
@@ -753,7 +812,7 @@ function BattleState:queueScopeReveal()
|
||||
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
||||
self:act(function() self.ghostReveal = { t = 0 } end)
|
||||
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))
|
||||
end
|
||||
|
||||
@@ -1292,7 +1351,7 @@ function BattleState:sendOutText(name)
|
||||
if pct >= 70 then return Strings("Go! %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
|
||||
return Strings("The enemy's weak!\nGet'm! %s!", name)
|
||||
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
||||
end
|
||||
|
||||
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
|
||||
@@ -1866,11 +1925,11 @@ function BattleState:update(dt)
|
||||
end
|
||||
local mv = moves[self.moveIndex]
|
||||
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.afterQueue = "menu"
|
||||
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.afterQueue = "menu"
|
||||
else
|
||||
@@ -1920,7 +1979,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
|
||||
table.insert(self.queue, self.nextInsert, { wait = 50 })
|
||||
if target.invulnerable
|
||||
or not self:accuracyRoll(move, user, target) then
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
|
||||
return
|
||||
end
|
||||
local slots = {}
|
||||
@@ -1930,7 +1989,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
|
||||
if #slots == 0 then
|
||||
-- .getRandomMove rerolls empty slots forever; a moveless target
|
||||
-- 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
|
||||
end
|
||||
if user.isPlayer and self.kind ~= "link" then
|
||||
@@ -1991,7 +2050,7 @@ function BattleState:applyMimic(user, target, moveInst, slot)
|
||||
entry.mimic = true
|
||||
self:animNext("MIMIC", user.isPlayer)
|
||||
-- _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))
|
||||
end
|
||||
|
||||
@@ -3053,7 +3112,7 @@ function BattleState:executeAction(user, target, action)
|
||||
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
|
||||
if self:preRechargeChecks(user, target) then return end
|
||||
user.mustRecharge = nil
|
||||
self:sayNext(Strings("%s\nmust recharge!", displayName(user)))
|
||||
self:sayNext(self:romText("_MustRechargeText", "%s\nmust recharge!", displayName(user)))
|
||||
return
|
||||
end
|
||||
if action.special == "bound" then
|
||||
@@ -3098,8 +3157,8 @@ function BattleState:statusOnomatopoeia(user, kind)
|
||||
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
|
||||
end
|
||||
local text = kind == "sleep"
|
||||
and Strings("%s\nis fast asleep!", displayName(user))
|
||||
or Strings("%s\nis confused!", displayName(user))
|
||||
and self:romText("_FastAsleepText", "%s\nis fast asleep!", displayName(user))
|
||||
or self:romText("_IsConfusedText", "%s\nis confused!", displayName(user))
|
||||
if kind == "sleep" and isPlayer then
|
||||
self:animNext(anim, isPlayer)
|
||||
self:sayNext(text)
|
||||
@@ -3138,18 +3197,18 @@ function BattleState:preRechargeChecks(user, target)
|
||||
user.sleepTurns = (user.sleepTurns or 1) - 1
|
||||
if user.sleepTurns <= 0 then
|
||||
mon.status = nil
|
||||
self:sayNext(Strings("%s\nwoke up!", displayName(user)))
|
||||
self:sayNext(self:romText("_WokeUpText", "%s\nwoke up!", displayName(user)))
|
||||
else
|
||||
self:statusOnomatopoeia(user, "sleep")
|
||||
end
|
||||
return true
|
||||
end
|
||||
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
|
||||
end
|
||||
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
|
||||
end
|
||||
if user.flinched then
|
||||
@@ -3157,7 +3216,7 @@ function BattleState:preRechargeChecks(user, target)
|
||||
-- player recharges, so the flinch eats the recharge turn and the
|
||||
-- flag survives (the Hyper Beam flinch glitch)
|
||||
user.flinched = false
|
||||
self:sayNext(Strings("%s\nflinched!", displayName(user)))
|
||||
self:sayNext(self:romText("_FlinchedText", "%s\nflinched!", displayName(user)))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
@@ -3178,7 +3237,7 @@ function BattleState:statusInterrupt(user, target)
|
||||
{ id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 },
|
||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||
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:applyDamage(user, dmg)
|
||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||
@@ -3266,7 +3325,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
|
||||
self.moveAnimRow = nil
|
||||
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
|
||||
-- damage path attaches the target's hit blink to this row so 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
|
||||
-- after the effect lands; a miss skips it
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
|
||||
return
|
||||
end
|
||||
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
|
||||
MoveEffects.warnUnknown(move.effect)
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -3380,7 +3439,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
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
|
||||
-- then jumps to GetPlayerAnimationType, so the trapping move's full
|
||||
-- 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)))
|
||||
return
|
||||
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
|
||||
user.bideTurns, user.bideDamage = nil, nil
|
||||
if dmg <= 0 then
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
self:sayNext(self:romText("_ButItFailedText", "But, it failed!"))
|
||||
return
|
||||
end
|
||||
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
|
||||
@@ -3434,9 +3493,9 @@ function BattleState:applyDamage(target, dmg)
|
||||
target.substituteHP = target.substituteHP - dmg
|
||||
if target.substituteHP <= 0 then
|
||||
target.substituteHP = nil
|
||||
self:sayNext(Strings("%s's\nSUBSTITUTE broke!", displayName(target)))
|
||||
self:sayNext(self:romText("_SubstituteBrokeText", "%s's\nSUBSTITUTE broke!", displayName(target)))
|
||||
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
|
||||
return dmg
|
||||
end
|
||||
@@ -3448,7 +3507,7 @@ function BattleState:applyDamage(target, dmg)
|
||||
end
|
||||
if target.rageMove and dealt > 0 then
|
||||
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
|
||||
return dealt
|
||||
end
|
||||
@@ -3776,7 +3835,7 @@ function BattleState:enemyMonFainted()
|
||||
-- scripted battles that print their own follow-up leave it nil.
|
||||
self:actNext(function() self:playVictoryMusic() end)
|
||||
-- _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:actNext(function()
|
||||
self.showEnemyTrainer = self.trainerPic ~= nil
|
||||
@@ -3803,7 +3862,7 @@ function BattleState:enemyMonFainted()
|
||||
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
|
||||
self.result = "win"
|
||||
self.afterQueue = "finish"
|
||||
@@ -3819,7 +3878,7 @@ function BattleState:learnMove(mon, moveId)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
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))
|
||||
return
|
||||
end
|
||||
@@ -3901,11 +3960,11 @@ function BattleState:playerMonFainted()
|
||||
local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0
|
||||
if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then
|
||||
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.afterQueue = "finish"
|
||||
else
|
||||
self:say(Strings("Can't escape!"))
|
||||
self:say(self:romText("_CantEscapeText", "Can't escape!"))
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -3925,7 +3984,7 @@ function BattleState:openReplacementMenu()
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
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
|
||||
end
|
||||
self:restoreMimicked(self.player)
|
||||
@@ -3969,7 +4028,7 @@ function BattleState:safariAction(choice)
|
||||
|
||||
if choice == "run" then
|
||||
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.afterQueue = "finish"
|
||||
return
|
||||
@@ -4006,12 +4065,12 @@ function BattleState:safariAction(choice)
|
||||
end
|
||||
|
||||
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.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5))
|
||||
self.escapeFactor = 0
|
||||
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.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5))
|
||||
self.baitFactor = 0
|
||||
@@ -4027,13 +4086,13 @@ end
|
||||
function BattleState:safariEnemyTurn()
|
||||
if self.baitFactor > 0 then
|
||||
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
|
||||
self.escapeFactor = self.escapeFactor - 1
|
||||
if self.escapeFactor == 0 then
|
||||
self.safariCatchRate = self.enemy.def.catchRate
|
||||
end
|
||||
self:sayNext(Strings("Wild %s\nis angry!", self.enemy.name))
|
||||
self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name))
|
||||
end
|
||||
self:act(function()
|
||||
local speed = self.enemy.curStats.speed % 256
|
||||
@@ -4049,7 +4108,7 @@ function BattleState:safariEnemyTurn()
|
||||
fled = self.rng(0, 255) < b
|
||||
end
|
||||
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()
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
startPicKind(self:picFxFor(self.enemy), "slideOff")
|
||||
@@ -4116,11 +4175,11 @@ function BattleState:tryRun()
|
||||
TurnOrder.effectiveSpeed(self.enemy))
|
||||
if escaped then
|
||||
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.afterQueue = "finish"
|
||||
else
|
||||
self:say(Strings("Can't escape!"))
|
||||
self:say(self:romText("_CantEscapeText", "Can't escape!"))
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
@@ -4157,9 +4216,9 @@ function BattleState:ballMissMessage(shakes)
|
||||
elseif shakes == 1 then
|
||||
return t._ItemUseBallText02 or Strings("Darn! The POKéMON\nbroke free!")
|
||||
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
|
||||
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
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
||||
@@ -4170,7 +4229,7 @@ function BattleState:askNicknameUI(mon, displayName)
|
||||
self.lockedBall = nil
|
||||
self.blankForAskName = true
|
||||
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
|
||||
if label then
|
||||
-- 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
|
||||
-- still wIsInBattle == 1, and this port models both as kind == "wild".
|
||||
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))
|
||||
end
|
||||
self:act(function()
|
||||
@@ -4321,9 +4380,9 @@ function BattleState:throwBall(ball)
|
||||
end)
|
||||
self:animNext("BLOCKBALL_ANIM", true)
|
||||
self:sayNext(t._ThrowBallAtTrainerMonText1
|
||||
or Strings("The trainer\nblocked the BALL!"))
|
||||
or self:romText("_ThrowBallAtTrainerMonText1", "The trainer\nblocked the BALL!"))
|
||||
self:sayNext(t._ThrowBallAtTrainerMonText2
|
||||
or Strings("Don't be a thief!"))
|
||||
or self:romText("_ThrowBallAtTrainerMonText2", "Don't be a thief!"))
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
@@ -4386,7 +4445,7 @@ function BattleState:openParty()
|
||||
if mon == self.player.mon then
|
||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||
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
|
||||
self:resolveSwitch(mon)
|
||||
end
|
||||
@@ -4412,7 +4471,7 @@ end
|
||||
function BattleState:finish()
|
||||
if self.payDay and self.result == "win" then
|
||||
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.afterQueue = "finish"
|
||||
self.phase = "messages"
|
||||
@@ -4542,11 +4601,15 @@ end
|
||||
-- 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
|
||||
-- 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)
|
||||
local fx = self.fx
|
||||
if self:fxFaintActive(battler) then
|
||||
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
|
||||
return 0
|
||||
end
|
||||
|
||||
@@ -8,8 +8,21 @@
|
||||
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
|
||||
--
|
||||
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
|
||||
-- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs
|
||||
-- that lack love.window.
|
||||
-- and on boot via Game:applyOptions. No-ops in headless stubs that lack
|
||||
-- love.window.
|
||||
--
|
||||
-- MOBILE takes the other route to the same place. There is no window to
|
||||
-- resize -- the window IS the screen, and it rotates -- so the lock caps the
|
||||
-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N
|
||||
-- physical pixels per GB pixel and centres it, and the rest of the display
|
||||
-- stays black. Same promise as the desktop lock (a GB pixel is exactly N
|
||||
-- screen pixels, no more) reached by moving the picture rather than the
|
||||
-- window. This used to return false on the first line, so the row sat in
|
||||
-- OPTIONS on Android and iOS doing nothing at all.
|
||||
--
|
||||
-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs
|
||||
-- every frame off the live drawable size, so portrait and landscape both get
|
||||
-- the same locked scale with the bars falling wherever the screen is longer.
|
||||
|
||||
local FaithfulRes = {}
|
||||
|
||||
@@ -17,6 +30,10 @@ FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
|
||||
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
|
||||
FaithfulRes.DEFAULT = 0
|
||||
|
||||
-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF.
|
||||
-- Renderer:fitScale reads it through FaithfulRes.scaleCap.
|
||||
FaithfulRes.mobileScale = 0
|
||||
|
||||
-- conf.lua's floor for the resizable desktop window, restored when the lock
|
||||
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
|
||||
-- as well as set the size or LOVE clamps the window back up.
|
||||
@@ -25,21 +42,52 @@ FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
|
||||
-- whether this module currently owns the window size
|
||||
FaithfulRes.locked = false
|
||||
|
||||
-- The highest level this display can actually show.
|
||||
--
|
||||
-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the
|
||||
-- feature shipped with. On mobile there is no window to size, so a fixed
|
||||
-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the
|
||||
-- levels the panel could really use are not on the list at all. Derive it
|
||||
-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top
|
||||
-- of the ladder is the biggest exact-pixel picture it can draw.
|
||||
--
|
||||
-- OFF (0) is untouched by any of this and keeps doing exactly what it always
|
||||
-- did: the renderer fits and letterboxes as usual.
|
||||
function FaithfulRes.maxLevel()
|
||||
-- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea --
|
||||
-- there it names a window size you can see. On a phone the same number
|
||||
-- means a different fraction of every device, and every level below the top
|
||||
-- is just a smaller picture for no reason. ON means one thing instead:
|
||||
-- lock the viewport to the Game Boy's 10:9 and size it to this screen.
|
||||
if FaithfulRes.isMobile() then return 1 end
|
||||
return 4
|
||||
end
|
||||
|
||||
-- the selectable ladder for this display: OFF, then 1X..maxLevel
|
||||
function FaithfulRes.levels()
|
||||
local out = { 0 }
|
||||
for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end
|
||||
return out
|
||||
end
|
||||
|
||||
function FaithfulRes.normalize(v)
|
||||
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
|
||||
if v < 0 then return 0 end
|
||||
if v > 4 then return 4 end
|
||||
local max = FaithfulRes.maxLevel()
|
||||
if v > max then return max end
|
||||
return v
|
||||
end
|
||||
|
||||
function FaithfulRes.label(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return "OFF" end
|
||||
-- mobile has one ON: the level is chosen from the display, not the player
|
||||
if FaithfulRes.isMobile() then return "ON" end
|
||||
return tostring(v) .. "X"
|
||||
end
|
||||
|
||||
function FaithfulRes.cycle(v, dir)
|
||||
local levels = FaithfulRes.LEVELS
|
||||
local levels = FaithfulRes.levels()
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == FaithfulRes.normalize(v) then cur = i break end
|
||||
@@ -48,6 +96,12 @@ function FaithfulRes.cycle(v, dir)
|
||||
end
|
||||
|
||||
function FaithfulRes.isMobile()
|
||||
-- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the
|
||||
-- scale lock can be seen and driven without a device. The window is still
|
||||
-- resizable, which is the point -- drag it to a phone aspect, rotate it by
|
||||
-- dragging the other way, and the lock has to hold through both. Only this
|
||||
-- module reads isMobile, so the override cannot leak into anything else.
|
||||
if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local osName = love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
@@ -86,13 +140,50 @@ end
|
||||
|
||||
-- Push the lock into the live window. Returns true when the window is
|
||||
-- locked afterwards.
|
||||
-- The largest WHOLE multiple of the Game Boy screen this display can hold.
|
||||
-- Integer, never fractional: a GB pixel has to be the same number of screen
|
||||
-- pixels in both axes or it is not pixel perfect, it is resampled.
|
||||
--
|
||||
-- The leftover is black bars, and on a tall phone there is a lot of it
|
||||
-- vertically -- that is simply what a 10:9 screen looks like on a 9:20
|
||||
-- display, and it is what an emulator shows too.
|
||||
function FaithfulRes.deviceScale()
|
||||
local g = love and love.graphics
|
||||
if not (g and g.getPixelDimensions) then return 1 end
|
||||
local pw, ph = g.getPixelDimensions()
|
||||
if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end
|
||||
return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH,
|
||||
ph / FaithfulRes.HEIGHT)))
|
||||
end
|
||||
|
||||
-- The scale the renderer must lock to, or nil for "fit the window as usual".
|
||||
-- Only ever set on mobile: on desktop the window itself is the lock, so
|
||||
-- fitScale already lands on N and this would be a second, redundant one.
|
||||
--
|
||||
-- Always the device maximum. Anything less is a smaller picture for no gain,
|
||||
-- which is how the first cut ended up showing a postage stamp on a 1080p
|
||||
-- phone.
|
||||
function FaithfulRes.scaleCap()
|
||||
if not FaithfulRes.locked then return nil end
|
||||
if not FaithfulRes.isMobile() then return nil end
|
||||
return FaithfulRes.deviceScale()
|
||||
end
|
||||
|
||||
function FaithfulRes.apply(v)
|
||||
if FaithfulRes.isMobile() then return false end
|
||||
v = FaithfulRes.normalize(v)
|
||||
-- Mobile: lock the render scale instead of the window. The scale itself
|
||||
-- comes from the display (deviceScale), not from v -- v only says whether
|
||||
-- the lock is on. Nothing to restore on release: the renderer simply goes
|
||||
-- back to filling the display.
|
||||
if FaithfulRes.isMobile() then
|
||||
FaithfulRes.locked = v > 0
|
||||
FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0
|
||||
return FaithfulRes.locked
|
||||
end
|
||||
if not love or not love.window or not love.window.setMode
|
||||
or not love.window.getMode then
|
||||
return false
|
||||
end
|
||||
v = FaithfulRes.normalize(v)
|
||||
local curW, curH, flags = love.window.getMode()
|
||||
flags = flags or {}
|
||||
|
||||
|
||||
@@ -315,6 +315,15 @@ end
|
||||
-- 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
|
||||
-- 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)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
@@ -415,6 +424,13 @@ function Game:draw()
|
||||
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||
-- 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:beginFrame(worldBelow)
|
||||
for i = drawFrom, #self.stack.states do
|
||||
@@ -493,6 +509,24 @@ function Game:wheelmoved(_, dy)
|
||||
end
|
||||
end
|
||||
|
||||
function Game:_cycleSpeed(dir)
|
||||
if not (self.save and self.save.options) then return end
|
||||
local busy
|
||||
local ow = self.overworld
|
||||
if ow then
|
||||
local top = self.stack:top()
|
||||
busy = ow.transitioning
|
||||
or (top == ow and (
|
||||
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
|
||||
or (ow.scriptMoves and #ow.scriptMoves > 0)
|
||||
or ow.engaging or ow.emote))
|
||||
end
|
||||
if busy then return end
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
|
||||
self:writeOptions()
|
||||
end
|
||||
|
||||
function Game:keypressed(key)
|
||||
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
|
||||
self.stack:top():onKeyPressed(key)
|
||||
@@ -530,6 +564,11 @@ function Game:keypressed(key)
|
||||
elseif key == "=" then
|
||||
self:zoomStep(1)
|
||||
return
|
||||
elseif key == "1" then
|
||||
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
|
||||
-- R2/L2 on gamepad do the same (see gamepadpressed)
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif key == "2" then
|
||||
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
|
||||
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
|
||||
@@ -610,6 +649,15 @@ function Game:gamepadpressed(joystick, button)
|
||||
-- a controller is being used: the touch overlay steps aside until the
|
||||
-- next screen touch (mobile only; a no-op elsewhere)
|
||||
TouchControls:noteGamepad()
|
||||
-- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster,
|
||||
-- L2/leftshoulder = slower; same as keyboard hotkey 1)
|
||||
if button == "rightshoulder" then
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif button == "leftshoulder" then
|
||||
self:_cycleSpeed(-1)
|
||||
return
|
||||
end
|
||||
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadPressed then
|
||||
|
||||
@@ -18,7 +18,7 @@ local GameSpeed = {}
|
||||
-- attempt is long enough that the iteration loop, not the engine, is the
|
||||
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
|
||||
-- multiplier is increasingly a ceiling rather than a rate.
|
||||
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
|
||||
GameSpeed.LEVELS = { 1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200 }
|
||||
GameSpeed.DEFAULT = 1
|
||||
|
||||
function GameSpeed.levelLabel(v)
|
||||
|
||||
@@ -228,6 +228,15 @@ function SaveData.defaultOptions()
|
||||
-- "black" = plain black bars, "world" = the frozen overworld showing
|
||||
-- through, dimmed. See BattleState:bgMode.
|
||||
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",
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
|
||||
@@ -129,6 +129,7 @@ Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
|
||||
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
|
||||
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
|
||||
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
|
||||
|
||||
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
|
||||
|
||||
@@ -324,6 +324,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
require("src.core.Sound").play(data, "Heal_HP")
|
||||
-- a revive takes the same .healHP -> .doneHealing route, animating up
|
||||
-- from the fainted mon's 0 HP (#252)
|
||||
-- re-add to participants so the revived mon gets its share of exp
|
||||
-- at battle end (onFaint clears the flag; revive must restore it)
|
||||
if battle and battle.participants then
|
||||
battle.participants[target] = true
|
||||
end
|
||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
|
||||
{ healedFrom = 0 }
|
||||
end
|
||||
|
||||
@@ -559,16 +559,26 @@ local TILESET_GROUP_EXCEPTIONS = {
|
||||
}
|
||||
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
|
||||
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
|
||||
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
|
||||
-- different palettes; the vanilla-derived blockset shares the one tile
|
||||
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
|
||||
-- is baked as a copy of `tile` in `group`'s colors, and the listed
|
||||
-- 0-based block cells draw the alias instead of the shared tile.
|
||||
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
|
||||
-- at a duplicate tile ($5a, BROWN) so the tabletop and the checkerboard
|
||||
-- floor -- both raw tile $37 -- can take different palettes; the
|
||||
-- vanilla-derived blockset shares the one tile id, so the RED++ atlas
|
||||
-- path re-creates the duplicate: the alias slot is baked as a copy of
|
||||
-- `tile` in `group`'s colors, and the listed 0-based block cells draw
|
||||
-- the alias instead of the shared tile.
|
||||
--
|
||||
-- Three LOBBY blocks share tile $37 on their flat surfaces:
|
||||
-- block 29: 2x2 table top at cells 5/6/9/10
|
||||
-- block 45: 2-tile strip at cells 13/14
|
||||
-- block 49: 2-tile strip at cells 1/2
|
||||
-- CELADON_DINER uses all three (#84, #85, #86); CELADON_MART_ROOF
|
||||
-- uses only block 29 (#52/#53).
|
||||
local LOBBY_TABLE_TOP_ALIAS = {
|
||||
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
{ block = 45, cells = { [13] = true, [14] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
{ block = 49, cells = { [1] = true, [2] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
}
|
||||
PaletteFX.TILE_ALIASES = {
|
||||
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
|
||||
|
||||
+42
-1
@@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -124,7 +126,16 @@ end
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
local w, h = self:uiSize()
|
||||
return math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||
local s = math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||
-- FAITHFUL RATIO on mobile locks the scale here rather than by resizing the
|
||||
-- window, which a phone does not have (see src/core/FaithfulRes.lua). The
|
||||
-- cap is the largest WHOLE multiple the display holds, so the picture is as
|
||||
-- big as exact pixels allow and the remainder is bars. Computed per frame
|
||||
-- off the live drawable size, so a rotate re-derives it with nothing to
|
||||
-- re-apply.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
if cap and cap < s then s = cap end
|
||||
return s
|
||||
end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel for the UI pass.
|
||||
@@ -142,6 +153,13 @@ end
|
||||
function Renderer:uiScale()
|
||||
local S = self:fitScale()
|
||||
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
|
||||
-- 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
|
||||
@@ -217,6 +235,21 @@ end
|
||||
-- tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||
-- the UI blit while the map kept filling the screen -- and showed MORE of
|
||||
-- the map, because a smaller scale fits more world pixels in.
|
||||
--
|
||||
-- Size the view against the LOCKED VIEWPORT rather than the display. A
|
||||
-- desktop lock gets this for free by making the window exactly 160N x 144N;
|
||||
-- this is the same sum with the viewport standing in for the window, so
|
||||
-- both platforms show the same map area at the same zoom.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
if cap then
|
||||
local uiw, uih = self:uiSize()
|
||||
pw, ph = uiw * cap, uih * cap
|
||||
end
|
||||
local sp = Zoom.scale(self:fitScale())
|
||||
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
||||
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||
@@ -630,6 +663,14 @@ end
|
||||
-- pixels, and consumed by endFrame this frame only.
|
||||
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
|
||||
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
|
||||
-- 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.
|
||||
|
||||
@@ -20,6 +20,16 @@ local OakSpeech = {}
|
||||
OakSpeech.__index = OakSpeech
|
||||
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
|
||||
-- sprite in from the right. Both play out before the beat's text prints.
|
||||
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!"),
|
||||
_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?"),
|
||||
_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)
|
||||
@@ -152,6 +164,14 @@ function OakSpeech.defaultSteps(speech)
|
||||
presetsWho = "player",
|
||||
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",
|
||||
kind = "say",
|
||||
@@ -166,6 +186,12 @@ function OakSpeech.defaultSteps(speech)
|
||||
presetsWho = "rival",
|
||||
presetsFallback = { "BLUE", "GARY", "JOHN" },
|
||||
},
|
||||
{
|
||||
-- HisNameIsText, the rival's counterpart to the confirmation above
|
||||
id = "confirm_rival_name",
|
||||
kind = "say",
|
||||
textKey = "_HisNameIsText",
|
||||
},
|
||||
{
|
||||
id = "legend",
|
||||
kind = "say",
|
||||
@@ -611,6 +637,15 @@ function OakSpeech:draw()
|
||||
love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60)
|
||||
end
|
||||
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)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(self.shrinkText) do
|
||||
|
||||
+18
-1
@@ -191,6 +191,23 @@ local function buildRows(game)
|
||||
o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1]
|
||||
return true
|
||||
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"),
|
||||
value = function(g) return rulesetName(g) end,
|
||||
step = function(g, dir)
|
||||
@@ -337,7 +354,7 @@ local function buildRows(game)
|
||||
-- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE
|
||||
-- because it overrides it: holding an exact size means dropping
|
||||
-- fullscreen.
|
||||
{ id = "faithfulRes", label = Strings("FAITHFUL RES"),
|
||||
{ id = "faithfulRes", label = Strings("FAITHFUL RATIO"),
|
||||
value = function(g)
|
||||
return FaithfulRes.label(g.save.options.faithfulRes)
|
||||
end,
|
||||
|
||||
@@ -203,7 +203,8 @@ function SummaryMenu:draw()
|
||||
local mdef = data.moves[mv.id]
|
||||
Font.draw(mdef.name, 16, y)
|
||||
Font.draw(Strings("PP"), 88, y + 8)
|
||||
Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8)
|
||||
local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)
|
||||
Font.draw(("%2d/%2d"):format(mv.pp, maxPP), 112, y + 8)
|
||||
else
|
||||
Font.draw("-", 16, y)
|
||||
Font.draw("--", 112, y + 8)
|
||||
|
||||
@@ -2580,8 +2580,13 @@ function OverworldState:openPC(onDone)
|
||||
-- (engine/menus/pokemon_pc.asm gates on EVENT_MET_BILL; we reach that
|
||||
-- when Bill hands over the SS Ticket)
|
||||
local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET
|
||||
-- keepOpen so B in the sub-PC returns here instead of exiting the
|
||||
-- PC session (#695); the sub-PC screens (BoxMenu, PlayerPC) already
|
||||
-- use keepOpen for their own rows, matching the original ROM's flow
|
||||
-- where the main menu stays underneath.
|
||||
table.insert(items, {
|
||||
label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"),
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||
Screens.push(Game, "BoxMenu")
|
||||
@@ -2592,6 +2597,7 @@ function OverworldState:openPC(onDone)
|
||||
-- the player's item storage is always available
|
||||
table.insert(items, {
|
||||
label = (Game.save.player.name or "RED") .. "'s PC",
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
Screens.push(Game, "PlayerPC")
|
||||
done()
|
||||
@@ -2602,6 +2608,7 @@ function OverworldState:openPC(onDone)
|
||||
if flags.EVENT_GOT_POKEDEX then
|
||||
table.insert(items, {
|
||||
label = Strings("PROF.OAK's PC"),
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
self:openOaksPC(done)
|
||||
end,
|
||||
@@ -3774,7 +3781,9 @@ function OverworldState:takeWarp(warpDef)
|
||||
self:startWarpTo(destMap, x, y, facing)
|
||||
return
|
||||
elseif pad == "hole" then
|
||||
-- falling through a hole: no door SFX, no walk-out step
|
||||
-- falling through a hole: Faint_Fall plays while the player drops,
|
||||
-- matching the boulder-hole Faint_Thud at line 3618 (#694)
|
||||
require("src.core.Sound").play(Game.data, "Faint_Fall")
|
||||
self:startWarpTo(destMap, x, y, facing)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
-- FAITHFUL RES: lock the window to an exact 160x144 multiple so the surface
|
||||
-- FAITHFUL RATIO: lock the window to an exact 160x144 multiple so the surface
|
||||
-- is the Game Boy screen with no letterbox at all.
|
||||
--
|
||||
-- The interesting parts are the two things a naive setMode gets wrong: the
|
||||
@@ -121,11 +121,22 @@ T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored
|
||||
T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes")
|
||||
T.eq(FaithfulRes.locked, false, "and the module no longer claims the window")
|
||||
|
||||
-- mobile has no resizable window to lock
|
||||
-- Mobile has no resizable window to lock, so it locks the RENDER scale
|
||||
-- instead and never calls setMode. It used to report unlocked and do
|
||||
-- nothing at all, which is why the OPTIONS row was inert on Android and iOS;
|
||||
-- tests/engine/faithful_res_mobile.lua covers the scale side.
|
||||
calls = stubWindow(1)
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
T.eq(FaithfulRes.apply(4), false, "mobile reports unlocked")
|
||||
T.eq(#calls, 0, "and never touches the window")
|
||||
T.eq(FaithfulRes.apply(4), true, "mobile locks, by capping the render scale")
|
||||
-- the level asked for is irrelevant on mobile: ON is ON, and the scale is
|
||||
-- read off the display so the picture is as big as exact pixels allow
|
||||
T.eq(FaithfulRes.scaleCap(), FaithfulRes.deviceScale(),
|
||||
"and the scale comes from the display, not from the level")
|
||||
T.eq(#calls, 0, "still without ever touching the window")
|
||||
T.eq(FaithfulRes.apply(0), false, "OFF releases it")
|
||||
T.eq(FaithfulRes.scaleCap(), nil, "and the cap goes away with it")
|
||||
T.eq(#calls, 0, "the window is left alone either way")
|
||||
FaithfulRes.mobileScale = 0
|
||||
|
||||
love.window, love.system = savedWindow, savedSystem
|
||||
if love.graphics then
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
-- FAITHFUL RATIO on Android / iOS.
|
||||
--
|
||||
-- On mobile the setting is ON or OFF, and ON means one thing: lock the
|
||||
-- viewport to the Game Boy's 10:9 at the largest WHOLE multiple this screen
|
||||
-- can hold, centred, black around it -- the way an emulator opens a Game Boy
|
||||
-- game on a phone.
|
||||
--
|
||||
-- Three things had to be true and none of them were:
|
||||
--
|
||||
-- * it had to apply at all. FaithfulRes.apply returned false on its first
|
||||
-- line for mobile, so the OPTIONS row did nothing on Android and iOS.
|
||||
-- A phone has no window to resize, so the lock caps the RENDER scale.
|
||||
--
|
||||
-- * it had to be sized for the device. The first cut kept the desktop's
|
||||
-- absolute 1X-4X ladder, which names a window size you can see on a
|
||||
-- desktop and means a different fraction of every phone: 4X was a quarter
|
||||
-- of a 1080p display and 5X/6X were not on the list at all. The scale is
|
||||
-- read off the display now, not chosen by the player.
|
||||
--
|
||||
-- * it had to work in the OVERWORLD. The world pass deliberately expands
|
||||
-- to cover the whole display so letterbox voids become more map. So the
|
||||
-- lock shrank the UI blit while the map kept filling the screen -- and
|
||||
-- showed MORE map, since a smaller scale fits more world pixels in.
|
||||
--
|
||||
-- Pixel perfect throughout: whole multiples only. The leftover is bars, and
|
||||
-- on a 9:20 phone there is a lot of it vertically. That is what a 10:9
|
||||
-- screen looks like on a tall display; stretching to reach the edges would
|
||||
-- resample every pixel, which is the one thing this setting exists to refuse.
|
||||
-- luajit tests/engine/faithful_res_mobile.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
|
||||
local g = love.graphics
|
||||
local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
|
||||
local realOS = love.system and love.system.getOS
|
||||
local savedOffset = Zoom.offset
|
||||
love.system = love.system or {}
|
||||
|
||||
local function pose(w, h, osName)
|
||||
love.system.getOS = function() return osName or "Android" end
|
||||
g.getDimensions = function() return w, h end
|
||||
g.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
Renderer.uiWidth, Renderer.uiHeight = Renderer.WIDTH, Renderer.HEIGHT
|
||||
Zoom.offset = 0
|
||||
|
||||
-- ------------------------------------------------------- it applies at all
|
||||
|
||||
pose(1080, 2400) -- a Pixel 7, portrait
|
||||
T.eq(FaithfulRes.isMobile(), true, "the fixture reads as a phone")
|
||||
T.eq(FaithfulRes.apply(1), true, "FAITHFUL RATIO applies on mobile at all now")
|
||||
T.eq(FaithfulRes.locked, true, "and reports itself locked")
|
||||
|
||||
-- ------------------------------------------------- one ON, sized by the device
|
||||
|
||||
T.eq(FaithfulRes.maxLevel(), 1, "mobile offers ON, not a ladder of multiples")
|
||||
T.eq(#FaithfulRes.levels(), 2, "so the row is exactly OFF and ON")
|
||||
T.eq(FaithfulRes.label(1), "ON", "and ON is spelled ON, not 1X")
|
||||
T.eq(FaithfulRes.label(0), "OFF", "with OFF unchanged")
|
||||
|
||||
-- 1080/160 = 6.75 and 2400/144 = 16.6, so the largest WHOLE multiple is 6
|
||||
T.eq(FaithfulRes.deviceScale(), 6, "the scale comes off the display: 6x here")
|
||||
T.eq(FaithfulRes.scaleCap(), 6, "and that is what the renderer is told to lock")
|
||||
T.eq(Renderer:fitScale(), 6, "so a GB pixel is exactly 6 screen pixels")
|
||||
|
||||
-- the player never picks a smaller one, which is how the first cut managed to
|
||||
-- draw a postage stamp on a 1080p phone
|
||||
T.eq(FaithfulRes.normalize(3), 1, "any ON value is just ON")
|
||||
FaithfulRes.apply(1)
|
||||
T.eq(Renderer:fitScale(), 6, "and always lands on the device maximum")
|
||||
|
||||
-- --------------------------------------------------------- the overworld
|
||||
|
||||
FaithfulRes.apply(0)
|
||||
local unlockedW = Renderer:worldViewSize()
|
||||
T.check(unlockedW > 160, "unlocked, the overworld view covers the whole display")
|
||||
|
||||
FaithfulRes.apply(1)
|
||||
local lockedW, lockedH = Renderer:worldViewSize()
|
||||
T.eq(lockedW, 160, "locked, the overworld shows exactly a GB screen wide")
|
||||
T.eq(lockedH, 144, "and exactly a GB screen tall")
|
||||
T.check(lockedW < unlockedW,
|
||||
"so the lock SHRINKS the map area instead of growing it")
|
||||
|
||||
-- ------------------------------------------------------ pixel perfect
|
||||
|
||||
-- 6 x 160 = 960 of 1080 wide. Reaching the edges would need 6.75, which
|
||||
-- resamples every pixel; the bars are the honest answer.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
T.eq(cap, math.floor(cap), "the locked scale is a whole number, never fractional")
|
||||
T.eq(160 * cap, 960, "which puts the GB screen at 960 of 1080 pixels wide")
|
||||
|
||||
-- ------------------------------------------------------- rotation is free
|
||||
|
||||
-- fitScale reads the live drawable size every frame, so a rotate re-derives
|
||||
-- the scale with nothing to re-apply
|
||||
pose(1080, 2400); FaithfulRes.apply(1)
|
||||
T.eq(Renderer:fitScale(), 6, "portrait locks at 6x")
|
||||
pose(2400, 1080); FaithfulRes.apply(1)
|
||||
T.eq(Renderer:fitScale(), 7, "landscape re-derives to 7x (1080/144), still whole")
|
||||
T.eq(Renderer:worldViewSize(), 160, "and the overworld stays locked through it")
|
||||
|
||||
-- ---------------------------------------------------------- OFF is OFF
|
||||
|
||||
-- Nothing about OFF changed: it is the behaviour the game already had.
|
||||
pose(1080, 2400)
|
||||
FaithfulRes.apply(0)
|
||||
T.eq(FaithfulRes.locked, false, "OFF releases the lock")
|
||||
T.eq(FaithfulRes.scaleCap(), nil, "with no cap on the renderer")
|
||||
T.eq(Renderer:fitScale(), 6, "the UI fits exactly as it did before")
|
||||
T.check(Renderer:worldViewSize() > 160, "and the overworld fills the screen again")
|
||||
|
||||
-- ------------------------------------------------- desktop is untouched
|
||||
|
||||
pose(1280, 800, "Windows")
|
||||
T.eq(FaithfulRes.isMobile(), false, "the fixture reads as desktop")
|
||||
T.eq(FaithfulRes.maxLevel(), 4, "desktop keeps its 1X-4X ladder")
|
||||
T.eq(FaithfulRes.label(2), "2X", "and its labels")
|
||||
T.eq(FaithfulRes.scaleCap(), nil, "no cap: the window itself is the lock there")
|
||||
T.eq(Renderer:fitScale(), 5, "so fitScale is untouched (800/144 = 5)")
|
||||
|
||||
g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
|
||||
if realOS then love.system.getOS = realOS end
|
||||
Zoom.offset = savedOffset
|
||||
FaithfulRes.locked = false
|
||||
FaithfulRes.mobileScale = 0
|
||||
|
||||
T.finish("faithful ratio mobile")
|
||||
@@ -62,10 +62,19 @@ end
|
||||
-- Blank out every Strings(...) / Strings.source(...) call span, parens
|
||||
-- balanced, so a call wrapped across lines counts as covered. A per-line
|
||||
-- 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 out, i, n = {}, 1, #body
|
||||
while i <= n do
|
||||
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
|
||||
out[#out + 1] = body:sub(i, s - 1)
|
||||
local depth, j = 0, body:find("%(", s)
|
||||
|
||||
@@ -94,11 +94,18 @@ T.check(box:find("Those are", 1, true) == nil,
|
||||
T.check(box:find("#MON", 1, true) == nil,
|
||||
"the ROM #MON ligature is spelled out as Pokémon")
|
||||
|
||||
-- the pokered beat also turns Oak to face the player
|
||||
T.check(contribution.talk[BALL][20][1] == "face_object"
|
||||
and contribution.talk[BALL][20][2] == 5
|
||||
and contribution.talk[BALL][20][3] == "down",
|
||||
"row 20 faces Oak down before the line (OaksLabLastMonScript)")
|
||||
-- the pokered beat also turns Oak to face the player: find the
|
||||
-- face_object row in the leftover-ball path (content-based, so a jingle
|
||||
-- row added for #668 doesn't shift the hard-coded index)
|
||||
local oakFaceRow
|
||||
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
|
||||
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")
|
||||
|
||||
-- ---- 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({
|
||||
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
|
||||
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
|
||||
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
|
||||
}) do
|
||||
local script = contribution.talk[key]
|
||||
T.check(script and script[21] and script[21][2] and
|
||||
script[21][2]:find("Pokémon", 1, true) ~= nil,
|
||||
key .. " carries the last-mon line")
|
||||
local lastMon
|
||||
if script then
|
||||
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
|
||||
|
||||
T.finish("oaks_lab_last_ball_bug601")
|
||||
|
||||
@@ -273,6 +273,26 @@ T.eq(Timing.BLINK_MON % 10, 0,
|
||||
-- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2
|
||||
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,
|
||||
"a status move or a miss holds DelayFrames 30 before its text")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -621,7 +621,9 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
|
||||
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
|
||||
"RED++ passes zone colors through like GBC")
|
||||
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
|
||||
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
|
||||
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias.
|
||||
-- Issue #689: blocks 45 and 49 also form tables with tile $37 on their
|
||||
-- flat surfaces; CELADON_DINER uses all three.
|
||||
do
|
||||
local aliases = PaletteFX.TILE_ALIASES
|
||||
local roof = aliases and aliases.CELADON_MART_ROOF
|
||||
@@ -630,11 +632,20 @@ do
|
||||
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
|
||||
check(diner == roof,
|
||||
"diner reuses the same lobby table-top alias as the mart roof")
|
||||
check(#diner == 3, "three LOBBY table blocks have the tile alias")
|
||||
local al = diner and diner[1]
|
||||
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
|
||||
and al.group == 5 and al.cells[5] and al.cells[6]
|
||||
and al.cells[9] and al.cells[10],
|
||||
"lobby table-top alias remaps block 29 cells 5/6/9/10")
|
||||
al = diner and diner[2]
|
||||
check(al and al.block == 45 and al.tile == 0x37 and al.alias == 0x5a
|
||||
and al.group == 5 and al.cells[13] and al.cells[14],
|
||||
"lobby table-top alias remaps block 45 cells 13/14")
|
||||
al = diner and diner[3]
|
||||
check(al and al.block == 49 and al.tile == 0x37 and al.alias == 0x5a
|
||||
and al.group == 5 and al.cells[1] and al.cells[2],
|
||||
"lobby table-top alias remaps block 49 cells 1/2")
|
||||
end
|
||||
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
|
||||
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
|
||||
|
||||
@@ -779,8 +779,9 @@ check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
|
||||
|
||||
-- ------- intro.oak_speech.build
|
||||
local vanillaSteps = OakSpeech.defaultSteps(oak)
|
||||
check(#vanillaSteps == 9, "vanilla speech has nine steps")
|
||||
check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink",
|
||||
check(#vanillaSteps == 11, "vanilla speech has eleven steps")
|
||||
check(vanillaSteps[1].id == "oak_welcome"
|
||||
and vanillaSteps[#vanillaSteps].id == "shrink",
|
||||
"vanilla speech anchors start and end")
|
||||
|
||||
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")
|
||||
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")
|
||||
check(logged("intro.oak_speech.build returned"),
|
||||
"the intro build degrade is logged")
|
||||
|
||||
@@ -34,6 +34,16 @@ Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
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
|
||||
-- table for this frame (default: mash A through text/ask/naming)
|
||||
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")
|
||||
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
|
||||
"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
|
||||
check(Game.save.party[1].nickname == "AAAAAAAAAA",
|
||||
"starter nickname prompt accepted (AskName / #137)")
|
||||
@@ -182,4 +200,6 @@ do
|
||||
Game.save = realSave
|
||||
end
|
||||
|
||||
Sound.play = realSoundPlay
|
||||
|
||||
S.finish()
|
||||
|
||||
Reference in New Issue
Block a user