mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 01:11:07 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01329758ff | |||
| 8e4d5b6807 | |||
| 26e9e1d597 | |||
| f7f32d2786 | |||
| c5192eaea4 | |||
| c2906744ce | |||
| 8e6b5ae73d | |||
| 0b96ac0ab6 | |||
| f6a035947f | |||
| 941181d31c | |||
| 9ceb1a8940 | |||
| 37051a26b5 | |||
| b12a0dac91 | |||
| a722d24759 | |||
| 833388c235 | |||
| 2ac527ae3c | |||
| df35193ad3 | |||
| 9b6365c9cc | |||
| 285a98533a | |||
| f952edf77a | |||
| ae8360b2ae | |||
| 52e36ad7e4 | |||
| 62b9f04191 | |||
| 1010d0584a | |||
| 6864173f35 | |||
| e9d431b3ff | |||
| f138827917 | |||
| a68c47e7e1 | |||
| d648990a81 | |||
| 6ff7dd0c38 | |||
| ee00728e9e | |||
| 0aab11b690 | |||
| c2d9af693d | |||
| 1642113d1f | |||
| d64627e062 |
@@ -94,8 +94,8 @@ jobs:
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-ios-ipa
|
||||
path: dist/ios/gen1recomp.ipa
|
||||
name: gen1recomp++-ios-ipa
|
||||
path: dist/ios/gen1recomp++.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: clean up signing keychain
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
[gen1recomp++.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
|
||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||
|
||||
@@ -444,9 +444,9 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp.ipa"
|
||||
ipa="dist/ios/gen1recomp++.ipa"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||
cp "$ipa" "$outdir/gen1recomp++-${v}-ios.ipa"
|
||||
|
||||
swzip="dist/switch/gen1recomp-${v}-switch.zip"
|
||||
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
|
||||
@@ -580,7 +580,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp++-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
@@ -602,14 +602,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
ipa="dist/release/gen1recomp-${v}-ios.ipa"
|
||||
ipa="dist/release/gen1recomp++-${v}-ios.ipa"
|
||||
app_repo="mobile/ios/app-repo.json"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
|
||||
|
||||
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"
|
||||
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa"
|
||||
bundle_id="com.theboisclub.gen1recompplusplus"
|
||||
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
|
||||
@@ -623,15 +624,15 @@ jobs:
|
||||
--argjson size "$size" \
|
||||
'{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)' \
|
||||
if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
|
||||
'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
|
||||
"$app_repo" >/dev/null; then
|
||||
jq --arg version "$v" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
|
||||
jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
else
|
||||
jq --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
|
||||
jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
fi
|
||||
mv "$app_repo.tmp" "$app_repo"
|
||||
|
||||
@@ -242,7 +242,7 @@ in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
Every release ships `gen1recomp++-*-ios.ipa`. Sideload it with AltStore
|
||||
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||
build and install from source on a Mac instead, see
|
||||
[docs/ios-install.md](docs/ios-install.md).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
+13
-13
@@ -246,9 +246,17 @@ return {
|
||||
-- the table sprites; re-entering the lab applies the same HideObject
|
||||
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if rival then
|
||||
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
|
||||
rival.cellY = 4
|
||||
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
@@ -286,6 +294,7 @@ return {
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
@@ -309,28 +318,19 @@ return {
|
||||
local base = #rows
|
||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
|
||||
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
|
||||
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
|
||||
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
|
||||
-- marches out. A loss skips only the gloat (that taunt was already
|
||||
-- shown in-battle via Rival1WinText), never the exit line (#231). The
|
||||
-- jump_if_false convergence point is the exit line: base+6 indexes the
|
||||
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
|
||||
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
|
||||
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, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
|
||||
@@ -226,9 +226,15 @@ return {
|
||||
},
|
||||
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if rival then
|
||||
rival.cellX, rival.cellY = 7, 4
|
||||
rival.px, rival.py = 7 * 16, 4 * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
@@ -296,13 +302,14 @@ return {
|
||||
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, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
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" })
|
||||
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||
table.insert(rows, { "face_player_dir", "up" })
|
||||
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||
table.insert(rows, { "spawn_pikachu_follower" })
|
||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||
|
||||
+19
-19
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
|
||||
M.BLUES_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
|
||||
{ "jump_if_false", 12 }, -- 5
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump_if_true", "got_map" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_false", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" },
|
||||
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
||||
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
|
||||
{ "jump", 13 }, -- 9
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
|
||||
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "got_map" },
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1058,6 +1061,7 @@ local championsRoomRivalScript = {
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "load_player_starter_name" },
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
@@ -1067,13 +1071,8 @@ local championsRoomRivalScript = {
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||
-- The player walks out after Oak instead of the screen just fading on the
|
||||
-- spot (#704). Route one tile right before walking north so the player
|
||||
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||
-- but this scene should not visibly walk through the defeated rival.
|
||||
{ "move_player", "right", 1 }, -- 26
|
||||
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 3 }, -- 27
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
@@ -1238,6 +1237,7 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
|
||||
@@ -413,6 +413,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_FAN_CLUB = {
|
||||
onEnter = function(game, ow)
|
||||
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||
{ "face_player" }, -- 1
|
||||
|
||||
+18
-13
@@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = {
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"MAGIKARP", 5)
|
||||
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
|
||||
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
|
||||
end)
|
||||
end,
|
||||
},
|
||||
@@ -512,24 +512,29 @@ M.ROUTE_24 = {
|
||||
local flags = game.save.flags
|
||||
local function battleOrDone()
|
||||
if ow:trainerDefeated(npc) then
|
||||
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
|
||||
.. "You just earned a\nfabulous prize!", function()
|
||||
local t = text(game)
|
||||
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
|
||||
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
|
||||
game.data) then
|
||||
push(game, t._Route24CooltrainerM1NoRoomText, done)
|
||||
return
|
||||
end
|
||||
flags.EVENT_GOT_NUGGET = true
|
||||
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
|
||||
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
|
||||
function()
|
||||
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
|
||||
function()
|
||||
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
|
||||
.. "you my power!", battleOrDone)
|
||||
end)
|
||||
end)
|
||||
game.stringBuffer = game.data.items.NUGGET.name
|
||||
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||
battleOrDone)
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
+22
-10
@@ -508,12 +508,12 @@ M.PEWTER_CITY = {
|
||||
-- Rival ambush: show the hidden rival, walk him up to the player, run
|
||||
-- the battle rows, march him back and hide him. On a loss the walk is
|
||||
-- skipped (the blackout rebuilds the map mid-script).
|
||||
local function runAmbush(game, ow, rows, playerFacing)
|
||||
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
|
||||
if ow.runner:isRunning() then return false end
|
||||
ow.player.facing = playerFacing
|
||||
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
||||
-- takes over and the map theme returns after the victory jingle
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
|
||||
ow.runner:run(rows)
|
||||
return true
|
||||
end
|
||||
@@ -567,10 +567,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", beatFlag }, -- 7
|
||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival",
|
||||
tempo = n == 2 and 100 or nil } },
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||
}
|
||||
@@ -594,7 +596,8 @@ M.ROUTE_22 = {
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
|
||||
{ tempo = 100 })
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -618,10 +621,11 @@ local function ceruleanRivalScene(px, py)
|
||||
{ "face_object", 1, "down" }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||
}
|
||||
@@ -730,7 +734,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||
-- per frame while the gate it returns still reads as playing.
|
||||
local function jigglypuffDance(game, npc)
|
||||
local function jigglypuffDance(game, npc, ow)
|
||||
local Music = require("src.core.Music")
|
||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||
-- matching the sprite's current facing (showMapText has just turned it
|
||||
@@ -776,7 +780,13 @@ local function jigglypuffDance(game, npc)
|
||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||
return
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
|
||||
if frames >= JIGGLYPUFF_TAIL then
|
||||
phase = "done"
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and require("src.world.PikachuFollower").starterInParty(game.save) then
|
||||
ow.pikachuPewterSleepScene = true
|
||||
end
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
@@ -789,7 +799,7 @@ M.PEWTER_POKECENTER = {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc) }))
|
||||
done, { auto = jigglypuffDance(game, npc, ow) }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -864,10 +874,11 @@ M.SILPH_CO_7F = {
|
||||
{ "face_object", 9, "up" }, -- 4
|
||||
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 7
|
||||
{ "jump_if_false", 13 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
||||
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
||||
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
||||
}, "down")
|
||||
@@ -899,10 +910,11 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||
}, onLeft and "up" or "left")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sideload the iOS build with AltStore
|
||||
|
||||
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
|
||||
Every GitHub Release ships an IPA (`gen1recomp++-*-ios.ipa`). Install it on
|
||||
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
|
||||
re-signs the app with **your** free Apple ID so you do not need a Mac or
|
||||
Xcode.
|
||||
@@ -23,7 +23,7 @@ Stuck? Start here:
|
||||
|
||||
## 2. Install the game
|
||||
|
||||
1. Download `gen1recomp-*-ios.ipa` from
|
||||
1. Download `gen1recomp++-*-ios.ipa` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||
2. Open **AltStore** on the phone (AltServer must be running on the same
|
||||
Wi‑Fi, or keep the phone plugged into the computer).
|
||||
|
||||
+8
-6
@@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
duplicate) and returns one row per mod:
|
||||
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
||||
enabled, matching the loader's own default).
|
||||
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
|
||||
missing entries default to enabled (except experimental mods), matching the
|
||||
loader. On the first run with per-game controls, legacy shared choices are
|
||||
copied to every installed game's answer.
|
||||
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||
and the validated manifests: `conflict` when enabling this mod collides
|
||||
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||
`love.*` calls, this half is table-driven by the test suite on its own.
|
||||
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
|
||||
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
||||
and the in-game `ManagerState` see the change on next boot. The mods panel
|
||||
calls this on every toggle and re-derives the list right away
|
||||
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
|
||||
answer, so the running game and the in-game `ManagerState` see the change on
|
||||
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
|
||||
and Gold on every row and re-derives the list right away
|
||||
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||
shows without waiting for a reload.
|
||||
- `LauncherMods.installZip(path)` mounts the archive with
|
||||
|
||||
@@ -96,14 +96,14 @@ The launcher asks the same question of a mod's dependencies: one whose hard
|
||||
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
|
||||
matching the loader's contagious skip.
|
||||
|
||||
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
|
||||
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
|
||||
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
|
||||
in-game manager and the loader all read *and* write the one shared
|
||||
`options.mods` flag. Nothing consults the overlay for enablement until that
|
||||
flips, which is deliberate: the overlay is plantable from an imported
|
||||
`.g1rmodlist`, and a reader scoped differently from the writers would show a
|
||||
mod set no boot would honour.
|
||||
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
|
||||
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
|
||||
for every installed mod, and the loader and in-game manager read the same
|
||||
game-specific answer on the next boot. On the first launch after this feature,
|
||||
the existing shared state is copied to every game, so a mod that was enabled
|
||||
remains enabled everywhere; after that, changing one checkbox affects only
|
||||
that game. New mods still default to enabled on every game (experimental mods
|
||||
retain their explicit opt-in default).
|
||||
|
||||
That is deliberate. Gold reimplements the battle engine, the overworld, the
|
||||
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
|
||||
@@ -474,6 +474,8 @@ Generation-agnostic; nothing to adapt.
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
documented on the module: Gold's world is not a stack state, and Gen 2 event
|
||||
flags are numeric ids into `wEventFlags` rather than string keys.
|
||||
`mapOverview` returns the same read-only terrain, tile-shading, and marker
|
||||
shape, using Gold's live object masks and event flags to omit collected items.
|
||||
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
|
||||
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
|
||||
an extracted one and survives a map reload; it is not serialized, so a mod
|
||||
|
||||
+2
-1
@@ -66,7 +66,8 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
## Party ordering
|
||||
|
||||
|
||||
@@ -260,18 +260,13 @@ selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
|
||||
|
||||
### One limit worth knowing
|
||||
|
||||
**Per-game enable flags are still a preview.** The overlay
|
||||
`options.modsByVersion[version][id]` exists and every surface goes through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
|
||||
`SaveData.PER_VERSION_MODS` is `false`
|
||||
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
|
||||
nil for every caller, so the launcher panel, the in-game manager *and* the
|
||||
loader all read and write the one shared `options.mods` flag and the overlay is
|
||||
not consulted for enablement anywhere. That matters because the overlay is
|
||||
plantable from an imported `.g1rmodlist`: keeping every reader on the same
|
||||
scope as every writer is what stops a stored per-game flag from showing a mod
|
||||
set no boot would honour. Nothing about this affects a mod author; it affects
|
||||
what a player can currently express.
|
||||
**Enablement is per game.** The overlay
|
||||
`options.modsByVersion[version][id]` is read and written through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
|
||||
manager, and loader. Existing shared settings are copied to every game the
|
||||
first time this version sees the installed mods; from then on, each coloured
|
||||
game checkbox changes only that game's next boot. Nothing about this affects a
|
||||
mod author; it affects what a player can express.
|
||||
|
||||
Targeting is a different question from enablement and *is* enforced per game,
|
||||
as above. The two do not share a switch.
|
||||
|
||||
+12
-1
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
|
||||
|
||||
## Version.lua fields
|
||||
|
||||
`src/core/Version.lua` carries three fields the updater reads directly (the
|
||||
`src/core/Version.lua` carries four fields the updater reads directly (the
|
||||
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
|
||||
untouched):
|
||||
|
||||
@@ -37,6 +37,11 @@ untouched):
|
||||
as a valid payload to chainload).
|
||||
- `shell` - the native-shell contract this build's fused executable
|
||||
implements.
|
||||
- `payloadHost` - the native host family an in-place payload targets. Ordinary
|
||||
LÖVE packages use `"love"`. A specialized native package uses a distinct,
|
||||
stable identifier and accepts only payloads carrying that same identifier.
|
||||
A missing field defaults to `"love"`, preserving compatibility with payloads
|
||||
released before this field existed.
|
||||
- `minShell` - the lowest shell contract required to *run* this payload.
|
||||
|
||||
Bump `minShell` only when a payload needs something the currently-shipped
|
||||
@@ -49,6 +54,12 @@ rather than deleting it, in case a future shell upgrade can run it, and
|
||||
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
|
||||
that is exactly the case the updater exists to avoid a reinstall for.
|
||||
|
||||
Change `payloadHost` only when the packaged Lua depends on a different native
|
||||
host family. This is separate from `minShell`: the host name answers *which*
|
||||
native integration the payload targets, while the shell number answers *which
|
||||
revision* of that integration it requires. A mismatched-host payload is never
|
||||
mounted or deleted as stale; the launcher directs the player to a full package.
|
||||
|
||||
## Release assets
|
||||
|
||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
|
||||
@@ -14,6 +14,7 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local NxDisplay = require("src.core.NxDisplay")
|
||||
local PlatformHooks = require("src.core.PlatformHooks")
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
@@ -197,14 +198,19 @@ end
|
||||
local touchEditorHost
|
||||
local closeTouchControlsEditor -- forward declaration
|
||||
|
||||
local function openTouchControlsEditor()
|
||||
-- `version` is the launcher tab the gear was opened on, and it decides which
|
||||
-- option block the layout lands in (src/ui/TouchControlsEditor.lua persist).
|
||||
local function openTouchControlsEditor(version)
|
||||
touchEditorHost = Importer
|
||||
if Importer and Importer.prepareOverlayHandoff then
|
||||
Importer:prepareOverlayHandoff()
|
||||
end
|
||||
Importer = nil
|
||||
TouchEditor = require("src.ui.TouchControlsEditor")
|
||||
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
||||
TouchEditor.load({
|
||||
version = version,
|
||||
onClose = function() closeTouchControlsEditor() end,
|
||||
})
|
||||
end
|
||||
|
||||
function closeTouchControlsEditor()
|
||||
@@ -425,6 +431,7 @@ function love.load(args)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
HostDisplay.update(dt)
|
||||
SwitchDiagnostics.maybeFlush(false)
|
||||
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
||||
NxDisplay.sync()
|
||||
@@ -472,11 +479,27 @@ function love.update(dt)
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
if editorMode then return EditorApp.draw() end
|
||||
if TouchEditor then return TouchEditor.draw() end
|
||||
if Importer then return Importer:draw() end
|
||||
if editorMode then
|
||||
HostDisplay.beginFrame("editor", EditorApp)
|
||||
local result = EditorApp.draw()
|
||||
HostDisplay.endFrame("editor", EditorApp)
|
||||
return result
|
||||
end
|
||||
if TouchEditor then
|
||||
HostDisplay.beginFrame("touch_editor", TouchEditor)
|
||||
local result = TouchEditor.draw()
|
||||
HostDisplay.endFrame("touch_editor", TouchEditor)
|
||||
return result
|
||||
end
|
||||
if Importer then
|
||||
HostDisplay.beginFrame("launcher", Importer)
|
||||
local result = Importer:draw()
|
||||
HostDisplay.endFrame("launcher", Importer)
|
||||
return result
|
||||
end
|
||||
if not Game then return end
|
||||
|
||||
HostDisplay.beginFrame("game", Game)
|
||||
Game:draw()
|
||||
-- frame capture requested by a driver
|
||||
if Game.capturePath then
|
||||
@@ -491,6 +514,7 @@ function love.draw()
|
||||
end
|
||||
end)
|
||||
end
|
||||
HostDisplay.endFrame("game", Game)
|
||||
end
|
||||
|
||||
function love.keypressed(key, scancode, isrepeat)
|
||||
|
||||
@@ -143,16 +143,36 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
|
||||
|
||||
/**
|
||||
* Native libraries required by an optional Android host extension.
|
||||
*
|
||||
* Subclasses supplied by another product flavor may override this method.
|
||||
* The libraries are loaded after LÖVE's dependencies and before liblove;
|
||||
* liblove must remain last because SDL treats the final entry as the main
|
||||
* shared object.
|
||||
*/
|
||||
protected String[] getHostLibraries() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String[] getLibraries() {
|
||||
return new String[] {
|
||||
"c++_shared",
|
||||
"mpg123",
|
||||
"openal",
|
||||
"love",
|
||||
};
|
||||
String[] hostLibraries = getHostLibraries();
|
||||
String[] libraries = new String[hostLibraries.length + 4];
|
||||
libraries[0] = "c++_shared";
|
||||
libraries[1] = "mpg123";
|
||||
libraries[2] = "openal";
|
||||
System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);
|
||||
libraries[libraries.length - 1] = "love";
|
||||
return libraries;
|
||||
}
|
||||
|
||||
protected void onHostCreateBeforeSDL(Bundle savedInstanceState) {}
|
||||
protected void onHostCreateAfterSDL(Bundle savedInstanceState) {}
|
||||
protected void onHostResume() {}
|
||||
protected void onHostPause() {}
|
||||
protected void onHostDestroy() {}
|
||||
|
||||
@Override
|
||||
protected String getMainSharedObject() {
|
||||
String[] libs = getLibraries();
|
||||
@@ -192,7 +212,9 @@ public class GameActivity extends SDLActivity {
|
||||
intent.setData(null);
|
||||
}
|
||||
|
||||
onHostCreateBeforeSDL(savedInstanceState);
|
||||
super.onCreate(savedInstanceState);
|
||||
onHostCreateAfterSDL(savedInstanceState);
|
||||
if (savedInstanceState != null) {
|
||||
// Restore the in-flight SAF destinations, so a pick that returns to
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
@@ -341,6 +363,7 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
onHostDestroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@@ -351,12 +374,14 @@ public class GameActivity extends SDLActivity {
|
||||
vibrator.cancel();
|
||||
}
|
||||
teardownSecondaryDisplay();
|
||||
onHostPause();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
onHostResume();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ an iOS app with LÖVE 12.0.
|
||||
|
||||
The app uses the public iOS Documents directory as its LÖVE save directory.
|
||||
There is no `pokemon-love2d` subdirectory and the app does not create a
|
||||
README file there. When browsing `On My iPhone > gen1recomp` in Files, the
|
||||
README file there. When browsing `On My iPhone > gen1recomp++` in Files, the
|
||||
directory contains the app's runtime data directly, including:
|
||||
|
||||
- installed mods and downloaded ROMs
|
||||
@@ -46,7 +46,7 @@ scripts/build_ios.sh --device --release --install
|
||||
Device builds require a paired, unlocked device and a valid Apple signing
|
||||
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
|
||||
signing cannot select the intended account. Add `--ipa` to create
|
||||
`dist/ios/gen1recomp.ipa`.
|
||||
`dist/ios/gen1recomp++.ipa`.
|
||||
|
||||
The script verifies the final app before packaging it:
|
||||
|
||||
@@ -76,10 +76,10 @@ option.
|
||||
Simulator and device app bundles are copied to:
|
||||
|
||||
```text
|
||||
dist/ios/Debug-iphonesimulator/gen1recomp.app
|
||||
dist/ios/Release-iphonesimulator/gen1recomp.app
|
||||
dist/ios/Debug-iphoneos/gen1recomp.app
|
||||
dist/ios/Release-iphoneos/gen1recomp.app
|
||||
dist/ios/Debug-iphonesimulator/gen1recomp++.app
|
||||
dist/ios/Release-iphonesimulator/gen1recomp++.app
|
||||
dist/ios/Debug-iphoneos/gen1recomp++.app
|
||||
dist/ios/Release-iphoneos/gen1recomp++.app
|
||||
```
|
||||
|
||||
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
|
||||
@@ -94,9 +94,9 @@ saves; those are created at runtime in Documents.
|
||||
|
||||
| Field | Default |
|
||||
| --- | --- |
|
||||
| Display name | `gen1recomp` |
|
||||
| Product name | `gen1recomp` |
|
||||
| Bundle identifier | `com.theboisclub.gen1recomp` |
|
||||
| Display name | `gen1recomp++` |
|
||||
| Product name | `gen1recomp++` |
|
||||
| Bundle identifier | `com.theboisclub.gen1recompplusplus` |
|
||||
| Save directory | Public `Documents` root |
|
||||
| Orientation | Portrait |
|
||||
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
{
|
||||
"name": "gen1recomp App Repo",
|
||||
"identifier": "com.theboisclub.gen1recomp.repo",
|
||||
"name": "gen1recomp++ App Repo",
|
||||
"identifier": "com.theboisclub.gen1recompplusplus.repo",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||
"apps": [
|
||||
{
|
||||
"name": "gen1recomp",
|
||||
"bundleIdentifier": "com.theboisclub.gen1recomp",
|
||||
"name": "gen1recomp++",
|
||||
"bundleIdentifier": "com.theboisclub.gen1recompplusplus",
|
||||
"developerName": "bryanthaboi",
|
||||
"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": [
|
||||
{
|
||||
"version": "0.1.83",
|
||||
"date": "2026-08-13",
|
||||
"size": 11282300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.82",
|
||||
"date": "2026-08-13",
|
||||
"size": 11281277,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.82/gen1recomp++-0.1.82-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #914 Immunity steps and no encounter tiles\n- #936 Fly and teleporting allow should block interactions\n- #944 When starting a new game, the character sprite is facing down instead of up\n- #949 Save game import, save game export, \"normal\" save game\n- #964 Menu closing when trying to view Pokemon before picking starter\n- #978 Following up of #847\n- #983 Pikachu incorrectly able to be released\n- #984 EXP calculation after trading a PKMN back isnt right.\n- #987 Celadon Game Corner Display Error (L22680)\n- #988 (Yellow) Missing Pikachu Interaction - Pewter City Pokemon Centre\n- #989 (Yellow) Missing Pikachu Interaction - Vermilion City Pokemon Fan Club\n- #990 (Yellow) Pikachu Missing Hat\n- #999 Pallet Town music while player follows Prof. Oak\n- #1007 Transition into battle state for the 'Catching Pikachu' scene missing\n- #1010 Rival's position resets\n- #1011 Rival dialogue issue\n- #1029 1px black line sits under the title logo at the SGB zone boundary\n- #1035 Alt Music not playing\n- #1041 Mimic text not swoing up\n- #1047 Using a TM or HM closes the menu afterwards.\n- #1048 Cannot select items from the sell menu.\n- #1056 Cursor not jumping when switching moves\n- #1065 Player walks through statue after final battle while walking into Hall Of Fame\n- #1066 Anomalies when opponent switches out Pokemon\n- #1069 missing {RAM:wNameBuffer}\n- #1073 _GotMonText not used\n- #1089 Scripted movement missing for player and rival\n- #1101 Recoil damage calculation bug\n- #1115 Hitting attack does no damage\n- #1119 Wrongs textlines and events\n- #1120 Health is black when viewing stats\n- #1129 Errors in bowing animations of the nurse\n- #1146 [Recomp] Map music glitch\n- #1149 issue that affects tranlations\n- #1175 Pikachu visible too late\n- #1189 Town Map Item\n- #1193 Status moves have (wrong) sound effects when battle animations are turned off\n- #1206 Gen1: Cinnebar island has unfishable tiles north and east\n- #1207 Poison Flicker Visual issue\n- #1214 Title Screen with OG Red is the wrong color\n- #1219 [GOLD] UNABLE TO USE SUN STONE\n- #1220 No screen shaking for hitting moves when battle animations are off\n- #1221 [Gold] Flaafy evolution to at Lvl 30 Ampharos skipped learn thunder punch\n- #1225 Jingles for learning moves missing\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.81",
|
||||
"date": "2026-08-13",
|
||||
"size": 9886854,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
|
||||
},
|
||||
{
|
||||
"version": "0.1.80",
|
||||
"date": "2026-08-12",
|
||||
"size": 10964568,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.80/gen1recomp-0.1.80-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1100 Allow for control edit and vibration toggle on gold\n- #1107 [Gold] When playing without a save slot in the launcher, no save will be created at all when saving ingame\n- #1112 [Gold] In the shop when buying items and pressing \"down\", it should not default to x99\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1126 [Gold] Jumping off ledge animation missing\n- #1130 A separate program for Gen2 (Gen2 Recomp)\n- #1145 Gen1recomp internal update - Mod didn't filter for Gen1 and Gen2 when click on individual tab\n- #1150 Control in Gold freeze when change\n- #1151 [Gold] Following up of #1127\n- #1152 [Gold] You cannot try to run away from wild battle after one of your Pokemon gets fainted.\n- #1161 window/borderless & resolution selection for gen2\n- #1162 [gold] learning TM/HM doesn't show if possible or not\n- #1164 Goldenrod Tower Softlock\n- #1165 [Gold] NPC trading behavior and ledge jumping missing still there despite fix\n- #1166 [Gold] nickname screen grey\n- #1167 [Gold] items in ilex forest missing\n- #1168 [Gold] multi-hit attacks only hits twice\n- #1169 [Gold] the sales lady in goldenrod underground\n- #1172 [Gold] {last item u found/got} used ROCK SMASH\n- #1173 [Gold] ROCK SMASH rocks keep slide left\n- #1177 [Gold] Touch controls position\n- #1178 No Vibration Setting for Gold\n- #1180 [Gold] something feels off with the DV calculation\n- #1184 [Gold] Kurt is in the wrong spot, blocking you from progressing\n- #1185 [Gold] Text messages is in the wrong order when a Pokemon learns a new skill after a trainer fight\n- #1188 GoldenRod tower softlock\n- #1190 POKEMON GOLD GEN1RECOMP++\n- #1192 cant import gold on RGXX build\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.1.79",
|
||||
"date": "2026-08-12",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>gen1recomp</string>
|
||||
<string>gen1recomp++</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>gen1recomp</string>
|
||||
<string>gen1recomp++</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -29,6 +29,9 @@ WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
||||
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
||||
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
|
||||
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
|
||||
IOS_MM = LOVE_SRC / "src" / "common" / "ios.mm"
|
||||
IOS_H = LOVE_SRC / "src" / "common" / "ios.h"
|
||||
SYSTEM_CPP = LOVE_SRC / "src" / "modules" / "system" / "System.cpp"
|
||||
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
||||
|
||||
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
||||
@@ -120,7 +123,7 @@ int w_pickFileKinds(lua_State *L)
|
||||
typedef const char *(*GRUTF8)(id, SEL);
|
||||
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
||||
sel_registerName("UTF8String"));
|
||||
if (bytes == nullptr || bytes[0] == '\0')
|
||||
if (bytes == nullptr || bytes[0] == '\\0')
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
@@ -358,6 +361,50 @@ def patch_public_documents():
|
||||
print("patch_love_src: iOS save directory routed to Documents root")
|
||||
|
||||
|
||||
def patch_ios_haptics():
|
||||
text = pristine(IOS_MM, ("UIImpactFeedbackGenerator",))
|
||||
original = """void vibrate()
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
|
||||
}
|
||||
}
|
||||
"""
|
||||
replacement = """void vibrate(double seconds)
|
||||
{
|
||||
@autoreleasepool
|
||||
{
|
||||
UIImpactFeedbackStyle style = UIImpactFeedbackStyleLight;
|
||||
if (seconds >= 0.035)
|
||||
style = UIImpactFeedbackStyleHeavy;
|
||||
else if (seconds >= 0.02)
|
||||
style = UIImpactFeedbackStyleMedium;
|
||||
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc]
|
||||
initWithStyle:style];
|
||||
[generator prepare];
|
||||
[generator impactOccurred];
|
||||
}
|
||||
}
|
||||
"""
|
||||
if original not in text:
|
||||
fail(f"iOS haptic anchor not found in {IOS_MM}")
|
||||
IOS_MM.write_text(text.replace(original, replacement, 1))
|
||||
|
||||
header = pristine(IOS_H, ("void vibrate(double seconds);",))
|
||||
header_original = "void vibrate();"
|
||||
if header_original not in header:
|
||||
fail(f"iOS haptic declaration not found in {IOS_H}")
|
||||
IOS_H.write_text(header.replace(header_original, "void vibrate(double seconds);", 1))
|
||||
|
||||
system = pristine(SYSTEM_CPP, ("love::ios::vibrate(seconds)",))
|
||||
system_original = "love::ios::vibrate();"
|
||||
if system_original not in system:
|
||||
fail(f"iOS haptic call site not found in {SYSTEM_CPP}")
|
||||
SYSTEM_CPP.write_text(system.replace(system_original, "love::ios::vibrate(seconds);", 1))
|
||||
print("patch_love_src: iOS haptics use Taptic Engine impact presets")
|
||||
|
||||
|
||||
def patch_pbxproj():
|
||||
text = pristine(PBXPROJ)
|
||||
|
||||
@@ -408,7 +455,9 @@ def patch_pbxproj():
|
||||
fail(f"build configuration {config_id} not found")
|
||||
settings = (
|
||||
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
|
||||
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
|
||||
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0;\n"
|
||||
"\t\t\t\tPRODUCT_NAME = \"gen1recomp++\";\n"
|
||||
"\t\t\t\tEXECUTABLE_NAME = \"gen1recomp++\";\n"
|
||||
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
|
||||
)
|
||||
text = text[: m.end()] + settings + text[m.end():]
|
||||
@@ -422,6 +471,7 @@ def main():
|
||||
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
||||
copy_native_files()
|
||||
patch_public_documents()
|
||||
patch_ios_haptics()
|
||||
patch_wrap_system()
|
||||
patch_pbxproj()
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||
# dist/android/debug/*.apk (full gradle output stays under
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
|
||||
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
|
||||
# under mobile/ios/build/Build/Products/)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
+43
-25
@@ -20,9 +20,9 @@
|
||||
# - macOS + Xcode (xcodebuild)
|
||||
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
||||
#
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||
# dist/ios/gen1recomp.ipa (device builds only)
|
||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp++.app (convenience copy)
|
||||
# dist/ios/gen1recomp++.ipa (device builds only)
|
||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp++.app
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -39,8 +39,9 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
|
||||
LOVE_FILE="$RESOURCES_DIR/game.love"
|
||||
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
DISPLAY_NAME="gen1recomp"
|
||||
APP_NAME="gen1recomp++"
|
||||
DISPLAY_NAME="gen1recomp++"
|
||||
PRODUCT_NAME="gen1recomp++"
|
||||
# Bundle ID resolution, most specific wins:
|
||||
# 1. GEN1_BUNDLE_ID env var
|
||||
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
|
||||
@@ -50,7 +51,7 @@ DISPLAY_NAME="gen1recomp"
|
||||
# capabilities like HealthKit are involved), so a per-team default
|
||||
# lets anyone build without colliding with someone else's app
|
||||
# 4. simulator: the project default (no App ID registration involved)
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
|
||||
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||
fi
|
||||
@@ -138,11 +139,7 @@ if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
fi
|
||||
fi
|
||||
if [ -z "$BUNDLE_ID" ]; then
|
||||
if $DEVICE; then
|
||||
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
|
||||
else
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
fi
|
||||
BUNDLE_ID="com.theboisclub.gen1recompplusplus"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- host checks
|
||||
@@ -226,13 +223,26 @@ verify_documents_overlay() {
|
||||
}
|
||||
|
||||
apply_ios_icon() {
|
||||
local source="$ROOT/assets/logo/gen1recomp_cover.png"
|
||||
local source="$ROOT/assets/logo/logo.png"
|
||||
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
||||
[ -f "$source" ] || fail "missing iOS icon source: $source"
|
||||
[ -d "$target" ] || fail "missing iOS app icon set: $target"
|
||||
local icon="$BUILD_DIR/gen1recomp-ios-icon.png"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
if command -v magick >/dev/null 2>&1; then
|
||||
magick -size 1024x1024 xc:black \
|
||||
\( "$source" -resize 900x900 \) -gravity center -composite \
|
||||
-alpha off "$icon" || fail "could not create iOS app icon: $source"
|
||||
else
|
||||
local scaled="$BUILD_DIR/gen1recomp-logo.png"
|
||||
sips -Z 900 "$source" --out "$scaled" >/dev/null \
|
||||
|| fail "could not resize iOS app icon source: $source"
|
||||
sips -p 1024 1024 --padColor 000000 "$scaled" --out "$icon" >/dev/null \
|
||||
|| fail "could not center iOS app icon source: $source"
|
||||
fi
|
||||
local entry name size
|
||||
while IFS=: read -r name size; do
|
||||
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
|
||||
sips -z "$size" "$size" "$icon" --out "$target/$name" >/dev/null
|
||||
done <<'EOF'
|
||||
icon-1024pt@1x.png:1024
|
||||
icon-29pt@1x.png:29
|
||||
@@ -647,6 +657,7 @@ run_xcodebuild() {
|
||||
CURRENT_PROJECT_VERSION="$project_version"
|
||||
INFOPLIST_KEY_UIFileSharingEnabled=YES
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
|
||||
IPHONEOS_DEPLOYMENT_TARGET=15.0
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
||||
)
|
||||
@@ -666,6 +677,9 @@ run_xcodebuild() {
|
||||
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
||||
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
||||
fi
|
||||
if [ "${GEN1_DISABLE_HEALTHKIT:-0}" = "1" ]; then
|
||||
args+=(CODE_SIGN_ENTITLEMENTS=)
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then
|
||||
@@ -708,18 +722,22 @@ run_xcodebuild() {
|
||||
fi
|
||||
|
||||
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
|
||||
local app="$products/$APP_NAME.app"
|
||||
if [ ! -d "$app" ]; then
|
||||
# PRODUCT_NAME override can still leave love.app on older projects
|
||||
if [ -d "$products/love.app" ]; then
|
||||
app="$products/$APP_NAME.app"
|
||||
mv "$products/love.app" "$app"
|
||||
warn "renamed love.app to $APP_NAME.app"
|
||||
else
|
||||
warn "xcodebuild finished but no .app under $products"
|
||||
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
|
||||
return 0
|
||||
local app=""
|
||||
local candidate
|
||||
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
app="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$app" ]; then
|
||||
warn "xcodebuild finished but no .app under $products"
|
||||
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
|
||||
return 0
|
||||
fi
|
||||
if [ "$app" != "$products/$APP_NAME.app" ]; then
|
||||
mv "$app" "$products/$APP_NAME.app"
|
||||
app="$products/$APP_NAME.app"
|
||||
fi
|
||||
|
||||
verify_documents_configuration "$app"
|
||||
@@ -760,7 +778,7 @@ run_xcodebuild() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
|
||||
# Pack Payload/<app>.app into dist/ios/gen1recomp++.ipa for release / sideload tools.
|
||||
package_ipa() {
|
||||
local app="$1"
|
||||
local ipa="$DIST/$APP_NAME.ipa"
|
||||
|
||||
+34
-13
@@ -613,6 +613,7 @@ local function newBattle(game)
|
||||
self.phase = "intro"
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.playerMoveListIndex = 1
|
||||
self.frame = 0
|
||||
return self
|
||||
end
|
||||
@@ -929,6 +930,11 @@ function BattleState:sayNext(text)
|
||||
table.insert(self.queue, self.nextInsert, { text = text })
|
||||
end
|
||||
|
||||
function BattleState:sayNextWaitSfx(text, sfx)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
|
||||
end
|
||||
|
||||
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
||||
function BattleState:sayNextAuto(text, delay)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
@@ -1255,13 +1261,13 @@ function BattleState:updateQueue()
|
||||
-- no subanimation player: keep the single-sound fallback (with
|
||||
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
|
||||
-- attacker's cry -- GetMoveSound/IsCryMove)
|
||||
if item.anim == "GROWL" or item.anim == "ROAR" then
|
||||
if self:animationsOn() and (item.anim == "GROWL" or item.anim == "ROAR") then
|
||||
local attacker = item.attackerIsPlayer and self.player or self.enemy
|
||||
if attacker then
|
||||
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
|
||||
anim and anim.tempo)
|
||||
end
|
||||
elseif anim and anim.sound then
|
||||
elseif self:animationsOn() and anim and anim.sound then
|
||||
local Sound = require("src.core.Sound")
|
||||
if Sound.playMove then
|
||||
Sound.playMove(self.data, anim)
|
||||
@@ -1361,6 +1367,11 @@ function BattleState:updateQueue()
|
||||
self.current = nil
|
||||
end
|
||||
elseif not (item and item.choice) then
|
||||
if item and item.waitForLearningSfx and not item.soundStarted then
|
||||
item.soundStarted = true
|
||||
self.waitingSound = item.waitForLearningSfx()
|
||||
return true
|
||||
end
|
||||
-- The page is typed out and waiting on the player: PromptText
|
||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
||||
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||
@@ -1746,6 +1757,7 @@ end
|
||||
local function sendOutMonCursors(self)
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.playerMoveListIndex = 1
|
||||
end
|
||||
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
||||
@@ -2044,8 +2056,10 @@ function BattleState:update(dt)
|
||||
if self.moveSwapIndex then
|
||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||
self.moveSwapIndex = nil
|
||||
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||
else
|
||||
self.moveSwapIndex = self.moveIndex
|
||||
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
@@ -2068,6 +2082,7 @@ function BattleState:update(dt)
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
self.playerMoveListIndex = self.moveIndex
|
||||
self:resolveTurn(mv)
|
||||
end
|
||||
end
|
||||
@@ -2423,11 +2438,9 @@ function BattleState:resolveTurn(playerAction)
|
||||
end
|
||||
local order
|
||||
if pFirst then
|
||||
order = { { self.player, self.enemy, playerAction },
|
||||
{ self.enemy, self.player, enemyAction } }
|
||||
order = { { true, playerAction }, { false, enemyAction } }
|
||||
else
|
||||
order = { { self.enemy, self.player, enemyAction },
|
||||
{ self.player, self.enemy, playerAction } }
|
||||
order = { { false, enemyAction }, { true, playerAction } }
|
||||
end
|
||||
|
||||
self.phase = "messages"
|
||||
@@ -2435,7 +2448,9 @@ function BattleState:resolveTurn(playerAction)
|
||||
|
||||
for _, entry in ipairs(order) do
|
||||
self:act(function()
|
||||
self:executeAction(entry[1], entry[2], entry[3])
|
||||
local user = entry[1] and self.player or self.enemy
|
||||
local target = entry[1] and self.enemy or self.player
|
||||
self:executeAction(user, target, entry[2])
|
||||
end)
|
||||
end
|
||||
self:act(function() self:endOfTurn() end)
|
||||
@@ -2945,7 +2960,7 @@ function BattleState:applyHitFx(hit)
|
||||
Sound.play(self.data, hit.sfx)
|
||||
end
|
||||
end
|
||||
if not t or not self:animationsOn() then return end
|
||||
if not t then return end
|
||||
if t == 1 then
|
||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||
-- then home for 3, b counting down
|
||||
@@ -3839,9 +3854,12 @@ function BattleState:awardExp()
|
||||
participants, alive = 1, { self.player.mon }
|
||||
end
|
||||
local function applyShare(mon, split, announce)
|
||||
local playerId = self.game.save.player and self.game.save.player.id
|
||||
local traded = mon.otId ~= nil and playerId ~= nil
|
||||
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, mon.traded)
|
||||
split, traded)
|
||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||
-- without this gate it re-triggers after every later fight (#213).
|
||||
@@ -3865,7 +3883,7 @@ function BattleState:awardExp()
|
||||
local text = Strings.source("%s gained\n%d EXP. Points!")
|
||||
if announce == "expAll" then
|
||||
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
||||
elseif mon.traded then
|
||||
elseif traded then
|
||||
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
||||
end
|
||||
self:sayNext(Strings(text, name, gained))
|
||||
@@ -4129,15 +4147,17 @@ 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(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name))
|
||||
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name), function()
|
||||
return require("src.core.Sound").play(self.data, "Level_Up")
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
|
||||
-- ordered insert so multi-level gains keep each level's checks
|
||||
-- between its own stat box and the next "grew to level" text
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("MoveLearnMenu", mon, moveId)
|
||||
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -5791,6 +5811,7 @@ function BattleState:drawTextArea()
|
||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
||||
end
|
||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
-- struggle recoils even when its effect id resolves to no record
|
||||
local recoil = math.max(1, math.floor(dmg / 2))
|
||||
local recoil = math.max(1, math.floor(totalDealt / 2))
|
||||
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
||||
battle:applyDamage(user, recoil)
|
||||
end
|
||||
|
||||
@@ -348,8 +348,9 @@ MoveEffects.primary = {
|
||||
target.disabledTurns = battle.rng(1, 8)
|
||||
local id = target.curMoves[slot].id
|
||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
|
||||
battle.data.moves[id].name) }
|
||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
|
||||
{ TARGET = displayName(target),
|
||||
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function(battle)
|
||||
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
|
||||
return dist[r + 1]
|
||||
end
|
||||
|
||||
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
|
||||
-- that amount, so Counter would see the halved value
|
||||
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
|
||||
local function drainHalf(label, text)
|
||||
return function(ctx)
|
||||
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
|
||||
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
|
||||
ctx.battle.lastDamage = heal
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
@@ -529,9 +529,8 @@ MoveEffects.full = {
|
||||
|
||||
RECOIL_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- recoil.asm reads the RAW computed wDamage (not the HP actually
|
||||
-- removed): overkill and substitute hits recoil at full strength
|
||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||
-- engine/battle/move_effects/recoil.asm
|
||||
local recoil = math.max(1, math.floor(ctx.totalDealt
|
||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
||||
ctx.battle:applyDamage(ctx.user, recoil)
|
||||
|
||||
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
|
||||
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
||||
or tostring(selectedMoveId)
|
||||
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
||||
"%s's\n%s is\ndisabled!", name(battler), shown))
|
||||
"%s's\n%s is\ndisabled!", {
|
||||
USER = name(battler), ["RAM:wNameBuffer"] = shown,
|
||||
}))
|
||||
return false, msgs
|
||||
end
|
||||
end
|
||||
|
||||
+41
-10
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
|
||||
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
||||
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
||||
-- table directly, so that is what these payloads carry.
|
||||
-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is
|
||||
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
|
||||
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
|
||||
-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the
|
||||
-- battle: `random` / `roller()` are BattleRandom for damage, accuracy,
|
||||
-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream.
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
|
||||
-- literal has to stay reachable from a translation mod (#186, #245), which is
|
||||
@@ -88,6 +90,19 @@ local function rand(random, n)
|
||||
return math.random(n) - 1
|
||||
end
|
||||
|
||||
-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi.
|
||||
local function loveStyleRng(random)
|
||||
return function(lo, hi)
|
||||
if hi == nil then
|
||||
local n = lo or 1
|
||||
if n < 1 then n = 1 end
|
||||
return (rand(random, n) or 0) + 1
|
||||
end
|
||||
if hi < lo then lo, hi = hi, lo end
|
||||
return lo + (rand(random, hi - lo + 1) or 0)
|
||||
end
|
||||
end
|
||||
|
||||
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
|
||||
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
||||
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
|
||||
@@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = {
|
||||
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
|
||||
-- through Roamers.beginBattle and reads Battle.roaming back to
|
||||
-- bank the beast's HP afterwards
|
||||
-- random(n) 0..n-1, injected so a test is deterministic
|
||||
-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom)
|
||||
-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random`
|
||||
function Battle.new(opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Battle)
|
||||
self.data = opts.data or {}
|
||||
self.random = opts.random or function(n) return rand(nil, n) end
|
||||
-- Same stream as `random`, Gen 1 / love.math calling convention.
|
||||
self.rng = opts.rng or loveStyleRng(self.random)
|
||||
self.party = opts.party or {}
|
||||
self.trainer = opts.trainer
|
||||
self.save = opts.save
|
||||
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
|
||||
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
||||
end
|
||||
|
||||
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
|
||||
-- constructor (a headless test injects one, the game leaves it to love.math),
|
||||
-- and a mod reaching for ctx.rng must not have to know that. Note the
|
||||
-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's
|
||||
-- love.math.random (1..n).
|
||||
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
|
||||
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
|
||||
function Battle:roller()
|
||||
if not self.rollerFn then
|
||||
self.rollerFn = function(n) return rand(self.random, n) end
|
||||
@@ -1604,9 +1619,25 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
end
|
||||
|
||||
-- Damage that skips the formula entirely.
|
||||
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random)
|
||||
-- Damage that skips the formula entirely. The move's own power goes with
|
||||
-- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
|
||||
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
|
||||
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
|
||||
def.power)
|
||||
if fixed then
|
||||
-- The constant-damage effect list carries `resettypematchup` instead of
|
||||
-- `stab`, and that command misses the move outright when the matchup byte
|
||||
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
|
||||
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
|
||||
local defenderTypes = (self:speciesDef(defender) or {}).types
|
||||
or defender.types
|
||||
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
||||
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message",
|
||||
text = "It doesn't affect " .. self:monName(defender) .. "..." })
|
||||
return
|
||||
end
|
||||
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
|
||||
return
|
||||
end
|
||||
|
||||
@@ -179,9 +179,9 @@ end
|
||||
|
||||
-- --------------------------------------------------------------- fixed damage
|
||||
|
||||
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the
|
||||
-- damage formula entirely.
|
||||
function Effects.fixedDamage(effect, attacker, defender, random)
|
||||
-- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
|
||||
-- one command for the whole SuperFang / Psywave / StaticDamage list.
|
||||
function Effects.fixedDamage(effect, attacker, defender, random, power)
|
||||
if effect == "EFFECT_LEVEL_DAMAGE" then
|
||||
return math.max(1, attacker.level or 1)
|
||||
end
|
||||
@@ -189,9 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random)
|
||||
return math.max(1, math.floor((defender.hp or 1) / 2))
|
||||
end
|
||||
if effect == "EFFECT_PSYWAVE" then
|
||||
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
|
||||
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
|
||||
return math.max(1, (random and random(ceiling) or 0) + 1)
|
||||
-- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
|
||||
-- top of the range is that ceiling minus one (effect_commands.asm:3163).
|
||||
local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
|
||||
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
|
||||
end
|
||||
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
|
||||
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
|
||||
-- (20 and 40) IS the damage, never a formula input
|
||||
-- (effect_commands.asm:3157-3161).
|
||||
if effect == "EFFECT_STATIC_DAMAGE" then
|
||||
return math.max(1, math.floor(power or 0))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -257,8 +265,18 @@ Effects.MAGNITUDE_POWER = {
|
||||
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
|
||||
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
||||
-- power and the magnitude number the text prints.
|
||||
--
|
||||
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
||||
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
|
||||
function Effects.magnitudePower(random)
|
||||
local roll = random and random(256) or 0
|
||||
local roll
|
||||
if type(random) == "function" then
|
||||
roll = random(256) or 0
|
||||
elseif love and love.math and love.math.random then
|
||||
roll = love.math.random(256) - 1
|
||||
else
|
||||
roll = math.random(256) - 1
|
||||
end
|
||||
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
||||
if row[1] >= roll then return row[2], row[3] end
|
||||
end
|
||||
|
||||
@@ -313,6 +313,16 @@ local function equalData(a, b)
|
||||
return okA and okB and encodedA == encodedB
|
||||
end
|
||||
|
||||
local function normalizeVerificationMetadata(actual, expected)
|
||||
if type(actual) == "table" and type(actual.identity) == "table"
|
||||
and type(expected) == "table" and type(expected.identity) == "table" then
|
||||
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime
|
||||
-- state. A fresh recapture stamps the running engine, so normalize only
|
||||
-- that metadata before differential verification.
|
||||
actual.identity.engineVersion = expected.identity.engineVersion
|
||||
end
|
||||
end
|
||||
|
||||
local function firstDifference(a, b, path)
|
||||
path = path or "$"
|
||||
if type(a) ~= type(b) then return path .. " (type)" end
|
||||
@@ -394,6 +404,7 @@ function Checkpoint.restore(game, checkpoint)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
normalizeVerificationMetadata(restored, validated)
|
||||
if restored and equalData(restored, validated) then
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
@@ -474,6 +485,7 @@ function Checkpoint.resume(game, checkpoint)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
normalizeVerificationMetadata(restored, validated)
|
||||
if restored and equalData(restored, validated) then
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
|
||||
+11
-2
@@ -1230,8 +1230,17 @@ function Engine.new(data, header, options)
|
||||
engine.tempo = header.tempo
|
||||
engine.tempoLocked = true
|
||||
end
|
||||
for _, spec in ipairs(chip and chip.channels
|
||||
or headerChannels(banks, header)) do
|
||||
local channels = chip and chip.channels or headerChannels(banks, header)
|
||||
if header.startChannels then
|
||||
local byNumber = {}
|
||||
for _, start in ipairs(header.startChannels) do
|
||||
byNumber[start.number] = start.address
|
||||
end
|
||||
for _, spec in ipairs(channels) do
|
||||
spec.address = byNumber[spec.number] or spec.address
|
||||
end
|
||||
end
|
||||
for _, spec in ipairs(channels) do
|
||||
local frameTicks = options.frameTicks
|
||||
local hardware = (spec.number - 1) % 4 + 1
|
||||
if hardware == 4 then
|
||||
|
||||
+18
-2
@@ -689,10 +689,26 @@ function Game2:usePartyItem(itemId)
|
||||
self:say(result.text)
|
||||
return
|
||||
end
|
||||
self:consumeItem(itemId)
|
||||
if action == "candy" then
|
||||
if action == "stone" then
|
||||
local party = (self.save and self.save.party) or {}
|
||||
local index
|
||||
for i, member in ipairs(party) do
|
||||
if member == mon then index = i break end
|
||||
end
|
||||
Screens.push(self, "Gen2EvolutionAnim", {
|
||||
mon = mon, entry = result.evolution, index = index,
|
||||
party = party, save = self.save,
|
||||
force = true,
|
||||
onDone = function(evolution)
|
||||
if evolution and evolution.evolved then self:consumeItem(itemId) end
|
||||
self.stack:pop()
|
||||
end,
|
||||
})
|
||||
elseif action == "candy" then
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||
else
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Optional native-host display lifecycle.
|
||||
--
|
||||
-- The engine always owns simulation and drawing. A packaged host may install
|
||||
-- one backend to observe per-frame updates and prepare/finalize a render target
|
||||
-- around an otherwise unchanged draw. With no backend installed every method
|
||||
-- is a no-op, which is the normal desktop and mobile path.
|
||||
--
|
||||
-- Backend methods are optional:
|
||||
-- backend:update(dt)
|
||||
-- backend:beginFrame(kind, subject)
|
||||
-- backend:endFrame(kind, subject)
|
||||
--
|
||||
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
|
||||
-- the object whose existing draw method runs between beginFrame and endFrame.
|
||||
local HostDisplay = {}
|
||||
|
||||
local backend
|
||||
|
||||
function HostDisplay.setBackend(value)
|
||||
if value ~= nil and type(value) ~= "table" then
|
||||
error("host display backend must be a table or nil", 2)
|
||||
end
|
||||
backend = value
|
||||
end
|
||||
|
||||
function HostDisplay.update(dt)
|
||||
local fn = backend and backend.update
|
||||
if fn then return fn(backend, dt) end
|
||||
end
|
||||
|
||||
function HostDisplay.beginFrame(kind, subject)
|
||||
local fn = backend and backend.beginFrame
|
||||
if fn then return fn(backend, kind, subject) end
|
||||
end
|
||||
|
||||
function HostDisplay.endFrame(kind, subject)
|
||||
local fn = backend and backend.endFrame
|
||||
if fn then return fn(backend, kind, subject) end
|
||||
end
|
||||
|
||||
return HostDisplay
|
||||
+17
-2
@@ -64,6 +64,7 @@ state = {
|
||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||
tempo = nil, -- alternate-tempo override in force for `current`
|
||||
start = nil,
|
||||
failed = {}, -- labels whose def could not be started; logged once
|
||||
}
|
||||
|
||||
@@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx)
|
||||
song = selectSong(song, ctx)
|
||||
|
||||
local tempo = ctx and ctx.tempo or nil
|
||||
local start = ctx and ctx.start or nil
|
||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||
-- below has to compare against
|
||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
||||
if not song or (song == state.current and tempo == state.tempo
|
||||
and start == state.start) then return end
|
||||
local def = songDef(data, song)
|
||||
if not def or state.failed[song] then return end
|
||||
|
||||
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
|
||||
slowed.tempo = tempo
|
||||
def = slowed
|
||||
end
|
||||
if start == "rival" and song == "Music_MeetRival"
|
||||
and def.bank == 2 and def.address == 17050 then
|
||||
local started = {}
|
||||
for key, value in pairs(def) do started[key] = value end
|
||||
started.startChannels = {
|
||||
{ number = 1, address = 0x71a2 },
|
||||
{ number = 2, address = 0x721d },
|
||||
{ number = 3, address = 0x72b5 },
|
||||
}
|
||||
def = started
|
||||
end
|
||||
local wantLoop = loop ~= false
|
||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||
if not src then
|
||||
@@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx)
|
||||
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||
state.current = song
|
||||
state.tempo = tempo
|
||||
state.start = start
|
||||
if Runtime.wants("music.started") then
|
||||
Runtime.emit("music.started", {
|
||||
song = song, previous = previous, chip = isChip,
|
||||
@@ -290,7 +305,7 @@ function Music.stop()
|
||||
stopSource(state.loopSource)
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.tempo = nil
|
||||
state.tempo, state.start = nil, nil
|
||||
state.chip = false
|
||||
state.pendingRestore = nil
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
|
||||
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
|
||||
local args = { ... }
|
||||
if #args == 0 then return text end
|
||||
|
||||
if #args == 1 and type(args[1]) == "table" then
|
||||
local values = args[1]
|
||||
return (text:gsub("%b{}", function(token)
|
||||
local value = values[token] or values[token:sub(2, -2)]
|
||||
return value == nil and token or tostring(value)
|
||||
end))
|
||||
end
|
||||
|
||||
local slots, named = 0, 0
|
||||
for token in text:gmatch("%b{}") do
|
||||
slots = slots + 1
|
||||
|
||||
+94
-16
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
|
||||
-- options.lua written before this key keeps its exact meaning. Read and
|
||||
-- written through SaveData.modEnabled / SaveData.setModEnabled.
|
||||
modsByVersion = {},
|
||||
-- Set after the first per-game enablement migration. Older options files
|
||||
-- have only `mods`, so the migration copies each installed mod's current
|
||||
-- answer to every game before game-specific toggles begin changing it.
|
||||
modsByVersionMigrated = false,
|
||||
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
|
||||
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
||||
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
|
||||
@@ -335,11 +339,11 @@ function SaveData.defaultOptions()
|
||||
-- TouchControls.normalizeConfig folds it into both orientations on load.
|
||||
touchControls = { enabled = true },
|
||||
-- Haptic feedback level for on-screen pad presses (#806):
|
||||
-- off | light | medium | heavy, mapped to a love.system.vibrate
|
||||
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
|
||||
-- overlay itself defaulting on, so an options.lua predating this key
|
||||
-- gets the tick without going looking for the row. Inert wherever the
|
||||
-- overlay never appears (desktop) or LOVE has no vibrator.
|
||||
-- off | light | normal | strong, mapped to a love.system.vibrate duration
|
||||
-- in src/core/TouchControls.lua. LIGHT by default, so an options.lua
|
||||
-- predating this key gets the tick without going looking for the row.
|
||||
-- Inert wherever the overlay never appears (desktop) or LOVE has no
|
||||
-- vibrator.
|
||||
haptics = "light",
|
||||
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
|
||||
-- locale where the platform exposes one; otherwise DateTime falls back to
|
||||
@@ -563,19 +567,68 @@ end
|
||||
-- only holds the games the player actually chose for, so a mod set can differ
|
||||
-- between Red and Gold without either one owning the other's flags.
|
||||
|
||||
-- Whether a per-game answer is honoured at boot. The loader reads the enable
|
||||
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
|
||||
-- flips on with that read and not before: until then every writer keeps to the
|
||||
-- shared flag and no surface promises what the boot does not do.
|
||||
SaveData.PER_VERSION_MODS = false
|
||||
-- Per-game answers are live. Every reader and writer goes through modScope,
|
||||
-- so a choice made in the launcher is the choice the next boot loads.
|
||||
SaveData.PER_VERSION_MODS = true
|
||||
|
||||
-- The version a write should be scoped to: the game asked for once per-game
|
||||
-- flags are live, nil (the shared flag) while they are only a preview.
|
||||
-- The version a write is scoped to: per-game controls name one game; a nil
|
||||
-- caller still addresses the legacy shared fallback.
|
||||
function SaveData.modScope(version)
|
||||
if SaveData.PER_VERSION_MODS then return version end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Promote an installation that predates per-game flags. `mods` may contain
|
||||
-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry
|
||||
-- had the loader default: on, except for experimental mods. Copy that answer
|
||||
-- to every game once, preserving any per-game overlay somebody imported before
|
||||
-- this feature shipped. New installs need no rows here: an absent answer
|
||||
-- still defaults to enabled for every game.
|
||||
function SaveData.migrateModEnablement(options, mods)
|
||||
if type(options) ~= "table" or options.modsByVersionMigrated then return false end
|
||||
options.mods = type(options.mods) == "table" and options.mods or {}
|
||||
options.modsByVersion = type(options.modsByVersion) == "table"
|
||||
and options.modsByVersion or {}
|
||||
|
||||
local known = {}
|
||||
for id in pairs(options.mods) do
|
||||
if type(id) == "string" and id ~= "" then known[id] = { id = id } end
|
||||
end
|
||||
for version, bucket in pairs(options.modsByVersion) do
|
||||
if GameVersion.VERSIONS[version] and type(bucket) == "table" then
|
||||
for id in pairs(bucket) do
|
||||
if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, mod in ipairs(mods or {}) do
|
||||
local id = type(mod) == "table" and mod.id or mod
|
||||
if type(id) == "string" and id ~= "" then
|
||||
known[id] = type(mod) == "table" and mod or (known[id] or { id = id })
|
||||
end
|
||||
end
|
||||
|
||||
-- Do not create options.lua just to record an empty migration on a fresh
|
||||
-- no-mod boot. Keep it pending until there is a real installed or saved
|
||||
-- mod answer to preserve.
|
||||
if next(known) == nil then return false end
|
||||
|
||||
for id, mod in pairs(known) do
|
||||
local shared = options.mods[id]
|
||||
if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local bucket = options.modsByVersion[version]
|
||||
if type(bucket) ~= "table" then
|
||||
bucket = {}
|
||||
options.modsByVersion[version] = bucket
|
||||
end
|
||||
if type(bucket[id]) ~= "boolean" then bucket[id] = shared end
|
||||
end
|
||||
end
|
||||
options.modsByVersionMigrated = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- true/false as chosen for `version`, else the shared flag, else nil -- the
|
||||
-- caller owns the default (the loader enables, the launcher keeps
|
||||
-- experimental mods off until asked).
|
||||
@@ -818,9 +871,19 @@ end
|
||||
function SaveData.slotSummary(save)
|
||||
if type(save) ~= "table" then return nil, nil end
|
||||
local name = save.player and save.player.name or nil
|
||||
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
|
||||
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
|
||||
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
|
||||
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
|
||||
local dexCount = 0
|
||||
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
||||
dexCount = dexCount + 1
|
||||
if gen2 then
|
||||
for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
|
||||
if has then dexCount = dexCount + 1 end
|
||||
end
|
||||
else
|
||||
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
||||
dexCount = dexCount + 1
|
||||
end
|
||||
end
|
||||
-- playTime is a plain seconds count in a Gen 1 save but a
|
||||
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
|
||||
@@ -838,8 +901,21 @@ function SaveData.slotSummary(save)
|
||||
end
|
||||
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60)
|
||||
local badges
|
||||
if gen2 then
|
||||
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
|
||||
-- (engine/menus/intro_menu.asm:461-469).
|
||||
badges = 0
|
||||
local p = save.player or {}
|
||||
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
|
||||
for _, has in pairs(p.kantoBadges or {}) do
|
||||
if has then badges = badges + 1 end
|
||||
end
|
||||
else
|
||||
badges = Badges.count(nil, save)
|
||||
end
|
||||
return name, {
|
||||
badges = Badges.count(nil, save),
|
||||
badges = badges,
|
||||
timeText = timeText,
|
||||
dexCount = dexCount,
|
||||
}
|
||||
@@ -1811,6 +1887,8 @@ function SaveData.newGame(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
local x, y = boot.startX or 3, boot.startY or 6
|
||||
local facing = boot.startFacing or "down"
|
||||
if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
local save = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
@@ -1821,7 +1899,7 @@ function SaveData.newGame(boot)
|
||||
map = map,
|
||||
x = x,
|
||||
y = y,
|
||||
facing = boot.startFacing or "down",
|
||||
facing = facing,
|
||||
name = boot.playerName or "RED",
|
||||
rival = boot.rivalName or "BLUE",
|
||||
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
|
||||
|
||||
@@ -105,17 +105,21 @@ end
|
||||
-- would be dropped on every editor save.
|
||||
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
|
||||
-- duration preset: Android runs the platform vibrator for exactly that long,
|
||||
-- while iOS ignores the duration and fires the fixed system vibration, so
|
||||
-- there the three levels all read as simply on.
|
||||
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
|
||||
-- while iOS maps each duration to a matching Taptic Engine impact.
|
||||
TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
|
||||
TouchControls.HAPTIC_DEFAULT = "light"
|
||||
|
||||
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
|
||||
local HAPTIC_SECONDS = {
|
||||
off = 0, light = 0.012, normal = 0.025, strong = 0.045,
|
||||
}
|
||||
local HAPTIC_LABELS = {
|
||||
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
|
||||
off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
|
||||
}
|
||||
|
||||
function TouchControls.normalizeHaptics(level)
|
||||
if level == "physical" then return "light" end
|
||||
if level == "medium" then return "normal" end
|
||||
if level == "heavy" then return "strong" end
|
||||
if HAPTIC_SECONDS[level] then return level end
|
||||
return TouchControls.HAPTIC_DEFAULT
|
||||
end
|
||||
|
||||
@@ -8,6 +8,9 @@ local Version = {
|
||||
-- "-dev" placeholder; CI stamps the real X.Y.Z into
|
||||
-- the packed game.love only, never the working tree.
|
||||
shell = 1, -- native-shell contract this build implements
|
||||
payloadHost = "love", -- native host family for in-place Lua payloads.
|
||||
-- A payload must name the same family; this prevents
|
||||
-- mounting code packaged for a different native host.
|
||||
minShell = 1, -- lowest shell contract that can RUN this payload.
|
||||
-- Bump only when a payload needs a newer native
|
||||
-- binary (e.g. a LOVE version bump); an older shell
|
||||
|
||||
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
|
||||
return rareCandy(ctx.mon, ctx.data)
|
||||
end)
|
||||
|
||||
for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE",
|
||||
"THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do
|
||||
record(itemId, "stone", function(ctx)
|
||||
if ctx.mon.item == "EVERSTONE" then
|
||||
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
|
||||
end
|
||||
local Evolution = require("src.core.gen2.Evolution")
|
||||
local entry = Evolution.checkMon(ctx.data, ctx.mon,
|
||||
{ force = true, item = ctx.item })
|
||||
if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
|
||||
return { used = true, evolution = entry }
|
||||
end)
|
||||
end
|
||||
|
||||
for itemId in pairs(ItemEffects.REVIVE) do
|
||||
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
|
||||
end
|
||||
|
||||
@@ -764,9 +764,14 @@ end
|
||||
function Save.summary(save)
|
||||
if type(save) ~= "table" then return nil end
|
||||
local badges = 0
|
||||
-- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges
|
||||
-- (engine/menus/intro_menu.asm:461-469).
|
||||
for _, has in pairs(save.player and save.player.badges or {}) do
|
||||
if has then badges = badges + 1 end
|
||||
end
|
||||
for _, has in pairs(save.player and save.player.kantoBadges or {}) do
|
||||
if has then badges = badges + 1 end
|
||||
end
|
||||
local caught = 0
|
||||
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
|
||||
if has then caught = caught + 1 end
|
||||
|
||||
@@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
|
||||
-- The core rows. Helper modules are required lazily under pcall: they are
|
||||
-- pure label/cycle tables, but the launcher must never die because a render
|
||||
-- module grew a dependency on live game data.
|
||||
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
|
||||
--
|
||||
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
|
||||
-- so `opts` is whichever table the gear is editing and the rows never have to
|
||||
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
|
||||
-- of them, so a phone player could turn the pad and the buzz off in Red and
|
||||
-- had no way to reach either in Gold.
|
||||
local function addTouchRows(rows, add, opts, hooks)
|
||||
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
|
||||
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local show = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
if show then
|
||||
add(Strings("TOUCH PAD"),
|
||||
function()
|
||||
local tc = opts.touchControls
|
||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||
return on and Strings("ON") or Strings("OFF")
|
||||
end,
|
||||
function()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled = tc.enabled == false
|
||||
opts.touchControls = tc
|
||||
return true
|
||||
end)
|
||||
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
|
||||
-- the row buzzes once at the level being selected.
|
||||
local okTC, TC = pcall(require, "src.core.TouchControls")
|
||||
if okTC then
|
||||
add(Strings("VIBRATION"),
|
||||
function() return Strings(TC.hapticLabel(opts.haptics)) end,
|
||||
function(dir)
|
||||
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
|
||||
TC.buzz(opts.haptics)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
|
||||
-- button on the game panel, once per game -- but the overlay layout is
|
||||
-- global (options.touchControls.layouts), so three tabs offered three
|
||||
-- buttons that edited the same thing while crowding the column that has to
|
||||
-- hold Play. It belongs with the other control rows, behind the gear.
|
||||
-- The host owns the editor screen, so the row only fires when a hook was
|
||||
-- supplied (the standalone save editor opens this model with none).
|
||||
if hooks and hooks.editTouchControls then
|
||||
rows[#rows + 1] = {
|
||||
label = Strings("TOUCH CONTROLS"),
|
||||
actionLabel = Strings("Edit"),
|
||||
action = function()
|
||||
hooks.editTouchControls()
|
||||
-- The editor replaces the whole screen: nothing left to persist here
|
||||
-- beyond what the caller already saved on the way out.
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local function coreRows(opts, hooks)
|
||||
local rows = {}
|
||||
local function add(label, value, step)
|
||||
@@ -236,60 +298,7 @@ local function coreRows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
|
||||
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
|
||||
do
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local show = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
if show then
|
||||
add(Strings("TOUCH PAD"),
|
||||
function()
|
||||
local tc = opts.touchControls
|
||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||
return on and Strings("ON") or Strings("OFF")
|
||||
end,
|
||||
function()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled = tc.enabled == false
|
||||
opts.touchControls = tc
|
||||
return true
|
||||
end)
|
||||
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
|
||||
-- the row buzzes once at the level being selected.
|
||||
local okTC, TC = pcall(require, "src.core.TouchControls")
|
||||
if okTC then
|
||||
add(Strings("VIBRATION"),
|
||||
function() return Strings(TC.hapticLabel(opts.haptics)) end,
|
||||
function(dir)
|
||||
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
|
||||
TC.buzz(opts.haptics)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
|
||||
-- button on the game panel, once per game -- but the overlay layout is
|
||||
-- global (options.touchControls.layouts), so three tabs offered three
|
||||
-- buttons that edited the same thing while crowding the column that has to
|
||||
-- hold Play. It belongs with the other control rows, behind the gear.
|
||||
-- The host owns the editor screen, so the row only fires when a hook was
|
||||
-- supplied (the standalone save editor opens this model with none).
|
||||
if hooks and hooks.editTouchControls then
|
||||
rows[#rows + 1] = {
|
||||
label = Strings("TOUCH CONTROLS"),
|
||||
actionLabel = Strings("Edit"),
|
||||
action = function()
|
||||
hooks.editTouchControls()
|
||||
-- The editor replaces the whole screen: nothing left to persist here
|
||||
-- beyond what the caller already saved on the way out.
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
addTouchRows(rows, add, opts, hooks)
|
||||
|
||||
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
|
||||
-- (src/core/Input.lua:applyBindings layers options.bindings over the
|
||||
@@ -462,7 +471,7 @@ end
|
||||
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
|
||||
local GEN2_KEY = "gold"
|
||||
|
||||
local function gen2Rows(opts)
|
||||
local function gen2Rows(opts, hooks)
|
||||
local rows = {}
|
||||
local function add(label, value, step)
|
||||
rows[#rows + 1] = { label = label, value = value, step = step }
|
||||
@@ -551,6 +560,8 @@ local function gen2Rows(opts)
|
||||
end)
|
||||
end
|
||||
|
||||
addTouchRows(rows, add, opts, hooks)
|
||||
|
||||
return rows
|
||||
end
|
||||
|
||||
@@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version)
|
||||
opts[GEN2_KEY] = block
|
||||
end
|
||||
sections = {
|
||||
{ title = Strings("OPTIONS"), rows = gen2Rows(block) },
|
||||
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
|
||||
}
|
||||
else
|
||||
sections = {
|
||||
|
||||
+353
-52
@@ -14,10 +14,9 @@
|
||||
-- height, and flex-shrink compressing text until it overlapped.
|
||||
--
|
||||
-- THE RULES THIS FILE FOLLOWS:
|
||||
-- * NO SCROLLING. Every list paginates (Kit.pager). Rows per page come
|
||||
-- from the real viewport height, so a tall window shows more and a phone
|
||||
-- shows fewer -- but a page's row count is bounded either way, which is
|
||||
-- what makes a 500-mod index cost the same as a 10-mod one.
|
||||
-- * Lists paginate (Kit.pager). The installed-mod list also scrolls inside
|
||||
-- its viewport, so each of its pages can hold at least ten entries without
|
||||
-- requiring a tall window. Pages still bound how many mod rows we visit.
|
||||
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
|
||||
-- the queue, so an action that tears the view down (Play, Edit save)
|
||||
-- never runs inside the frame that dispatched it.
|
||||
@@ -46,9 +45,17 @@ local COMMUNITY_URL = "https://bois.icu"
|
||||
local ACT_DEDUP = 0.35
|
||||
-- Finger travel past this (px) is a drag, not a tap.
|
||||
local TAP_SLOP2 = 16 * 16
|
||||
-- Installed mods should not turn into a one- or two-item pager on a compact
|
||||
-- display. Keep a useful page size, then let the list viewport scroll.
|
||||
local MIN_MODS_PER_PAGE = 10
|
||||
|
||||
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
|
||||
|
||||
local function inRect(rect, x, y)
|
||||
return rect ~= nil and x >= rect.x and x <= rect.x + rect.w
|
||||
and y >= rect.y and y <= rect.y + rect.h
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- lifecycle
|
||||
|
||||
local function ensureState(imp)
|
||||
@@ -100,6 +107,7 @@ end
|
||||
-- other half of that guarantee.
|
||||
function LauncherView.update(imp, dt)
|
||||
if not imp._flex then return end
|
||||
if imp._launchFade then return end
|
||||
|
||||
local down = false
|
||||
if love.mouse and love.mouse.isDown then
|
||||
@@ -145,7 +153,10 @@ end
|
||||
function LauncherView.touchpressed(imp, id, x, y)
|
||||
if not imp._flex then return end
|
||||
imp._touchAt = imp._touchAt or {}
|
||||
imp._touchAt[tostring(id)] = { x = x, y = y }
|
||||
imp._touchAt[tostring(id)] = {
|
||||
x = x, y = y,
|
||||
modsList = (imp._modScrollMax or 0) > 0 and inRect(imp._modListRect, x, y),
|
||||
}
|
||||
end
|
||||
|
||||
function LauncherView.touchmoved(imp, id, x, y)
|
||||
@@ -156,8 +167,14 @@ function LauncherView.touchmoved(imp, id, x, y)
|
||||
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||
start.dragged = true
|
||||
end
|
||||
-- Short-window mode: a vertical drag scrolls the page (draw() clamps).
|
||||
if start.dragged and (imp._pageScrollMax or 0) > 0 then
|
||||
-- A drag that began in the installed-mod viewport scrolls that page's
|
||||
-- rows. Its pager remains available for moving to the next ten-plus
|
||||
-- entries; a drag elsewhere keeps the normal short-window page scroll.
|
||||
if start.dragged and start.modsList then
|
||||
local last = start.lastY or start.y
|
||||
imp.modScroll = clamp((imp.modScroll or 0) - (y - last), 0,
|
||||
imp._modScrollMax or 0)
|
||||
elseif start.dragged and (imp._pageScrollMax or 0) > 0 then
|
||||
local last = start.lastY or start.y
|
||||
imp._pageScroll = (imp._pageScroll or 0) - (y - last)
|
||||
end
|
||||
@@ -312,6 +329,236 @@ local function cartColor(version)
|
||||
return CART_COLOR[version] or PAL.green
|
||||
end
|
||||
|
||||
local CART_DRAG_SLOP = 8
|
||||
local TAU = math.pi * 2
|
||||
|
||||
local function cartridgeState(imp, version)
|
||||
imp._cartridge = imp._cartridge or {}
|
||||
local state = imp._cartridge[version]
|
||||
if not state then
|
||||
state = { spin = 0, lastTime = Kit.time }
|
||||
imp._cartridge[version] = state
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
local function cartridgeLabel(imp, version)
|
||||
imp._cartridgeLabels = imp._cartridgeLabels or {}
|
||||
local label = imp._cartridgeLabels[version]
|
||||
if label ~= nil then return label or nil end
|
||||
local ok, image = pcall(love.graphics.newImage,
|
||||
"assets/labels/" .. tostring(version) .. ".png")
|
||||
if not ok then
|
||||
imp._cartridgeLabels[version] = false
|
||||
return nil
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
label = { image = image, width = iw, height = ih }
|
||||
imp._cartridgeLabels[version] = label
|
||||
return label
|
||||
end
|
||||
|
||||
local function cartProject(cx, cy, yaw, pitch, x, y, z)
|
||||
local cyaw, syaw = math.cos(yaw), math.sin(yaw)
|
||||
local cpitch, spitch = math.cos(pitch), math.sin(pitch)
|
||||
local rx = x * cyaw + z * syaw
|
||||
local rz = -x * syaw + z * cyaw
|
||||
local ry = y * cpitch - rz * spitch
|
||||
rz = y * spitch + rz * cpitch
|
||||
local perspective = 620 / (620 - rz)
|
||||
return cx + rx * perspective, cy + ry * perspective
|
||||
end
|
||||
|
||||
local function cartPolygon(points, color, alpha)
|
||||
local flat = {}
|
||||
for i = 1, #points do
|
||||
flat[#flat + 1], flat[#flat + 2] = points[i][1], points[i][2]
|
||||
end
|
||||
Theme.col(color, alpha or 1)
|
||||
love.graphics.polygon("fill", flat)
|
||||
end
|
||||
|
||||
local function cartQuad(project, x, y, w, h, z)
|
||||
return {
|
||||
{ project(x, y, z) }, { project(x + w, y, z) },
|
||||
{ project(x + w, y + h, z) }, { project(x, y + h, z) },
|
||||
}
|
||||
end
|
||||
|
||||
local function cartPill(project, x, y, w, h, z, color, alpha)
|
||||
local points, radius = {}, h / 2
|
||||
for i = 0, 10 do
|
||||
local a = math.pi + math.pi * i / 10
|
||||
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
|
||||
y + radius + math.sin(a) * radius, z) }
|
||||
end
|
||||
for i = 0, 10 do
|
||||
local a = math.pi * i / 10
|
||||
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
|
||||
y + radius + math.sin(a) * radius, z) }
|
||||
end
|
||||
cartPolygon(points, color, alpha)
|
||||
end
|
||||
|
||||
local function cartLabelMesh(imp, version, label, points)
|
||||
if not love.graphics.newMesh then return nil end
|
||||
imp._cartridgeLabelMeshes = imp._cartridgeLabelMeshes or {}
|
||||
local mesh = imp._cartridgeLabelMeshes[version]
|
||||
if not mesh then
|
||||
mesh = love.graphics.newMesh({
|
||||
{ 0, 0, 0, 0, 255, 255, 255, 255 },
|
||||
{ 0, 0, 1, 0, 255, 255, 255, 255 },
|
||||
{ 0, 0, 1, 1, 255, 255, 255, 255 },
|
||||
{ 0, 0, 0, 1, 255, 255, 255, 255 },
|
||||
}, "fan", "dynamic")
|
||||
mesh:setTexture(label.image)
|
||||
imp._cartridgeLabelMeshes[version] = mesh
|
||||
end
|
||||
mesh:setVertices({
|
||||
{ points[1][1], points[1][2], 0, 0, 255, 255, 255, 255 },
|
||||
{ points[2][1], points[2][2], 1, 0, 255, 255, 255, 255 },
|
||||
{ points[3][1], points[3][2], 1, 1, 255, 255, 255, 255 },
|
||||
{ points[4][1], points[4][2], 0, 1, 255, 255, 255, 255 },
|
||||
})
|
||||
return mesh
|
||||
end
|
||||
|
||||
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
local state = cartridgeState(imp, version)
|
||||
local focused = Kit.focusable(key, x, y, w, h)
|
||||
local hot = Kit.hover(x, y, w, h)
|
||||
local active = state.active
|
||||
local cx, cy = x + w / 2, y + h / 2
|
||||
|
||||
if Kit.mouseClicked and Kit.hit(x, y, w, h) and not Kit.blockClicks then
|
||||
if Kit.mouseDown then
|
||||
state.active = true
|
||||
state.startX, state.startY = Kit.mouseX, Kit.mouseY
|
||||
state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY
|
||||
state.dragged = false
|
||||
active = true
|
||||
else
|
||||
queueAction(imp, key, action)
|
||||
end
|
||||
end
|
||||
|
||||
if state.active then
|
||||
active = true
|
||||
if Kit.mouseDown then
|
||||
local movedX, movedY = Kit.mouseX - state.startX, Kit.mouseY - state.startY
|
||||
if movedX * movedX + movedY * movedY > CART_DRAG_SLOP * CART_DRAG_SLOP then
|
||||
state.dragged = true
|
||||
end
|
||||
if state.dragged then
|
||||
local dragX = Kit.mouseX - (state.lastDragX or Kit.mouseX)
|
||||
local dragY = Kit.mouseY - (state.lastDragY or Kit.mouseY)
|
||||
state.spin = state.spin + dragX * 0.018
|
||||
state.pitchDrag = clamp((state.pitchDrag or 0) + dragY * 0.010,
|
||||
-1.20, 1.20)
|
||||
end
|
||||
state.lastDragX, state.lastDragY = Kit.mouseX, Kit.mouseY
|
||||
else
|
||||
if not state.dragged then queueAction(imp, key, action) end
|
||||
state.active, active = nil, false
|
||||
state.dragged = nil
|
||||
end
|
||||
end
|
||||
|
||||
local dt = math.min(0.08, math.max(0, Kit.time - (state.lastTime or Kit.time)))
|
||||
state.lastTime = Kit.time
|
||||
if not state.active then
|
||||
local upright = math.floor(state.spin / TAU + 0.5) * TAU
|
||||
state.spin = state.spin + (upright - state.spin) * math.min(1, dt * 4)
|
||||
state.pitchDrag = (state.pitchDrag or 0)
|
||||
* (1 - math.min(1, dt * 4))
|
||||
end
|
||||
local pointerX = clamp((Kit.mouseX - cx) / math.max(1, w / 2), -1, 1)
|
||||
local pointerY = clamp((Kit.mouseY - cy) / math.max(1, h / 2), -1, 1)
|
||||
local hoverTilt = hot and pointerX * 0.12 or math.sin(Kit.time * 0.75) * 0.035
|
||||
local pressX = active and pointerX * w * 0.025 or 0
|
||||
local pressY = active and pointerY * h * 0.018 or 0
|
||||
local yaw = -0.42 + state.spin + hoverTilt
|
||||
local pitch = 0.14 + (state.pitchDrag or 0)
|
||||
+ (hot and pointerY * 0.08 or math.sin(Kit.time * 0.6) * 0.018)
|
||||
local pressedScale = active and 0.965 or 1
|
||||
|
||||
Kit._audit("control", x, y, w, h, key)
|
||||
if focused then
|
||||
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.lineStrong,
|
||||
Theme.A.focus, 2, Theme.cardRadius() + 2)
|
||||
end
|
||||
|
||||
local halfW, halfH = w / 2, h / 2
|
||||
local depth = math.max(8, w * 0.14)
|
||||
local project = function(px, py, pz)
|
||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||
end
|
||||
|
||||
local capH = h * 3 / 65
|
||||
local mainTop = -halfH + capH
|
||||
local capRight = halfW - w * 5 / 57
|
||||
local mainFront = cartQuad(project, -halfW, mainTop, w, h - capH, depth)
|
||||
local mainBack = cartQuad(project, -halfW, mainTop, w, h - capH, -depth)
|
||||
local capFront = cartQuad(project, -halfW, -halfH,
|
||||
capRight + halfW, capH, depth)
|
||||
local capBack = cartQuad(project, -halfW, -halfH,
|
||||
capRight + halfW, capH, -depth)
|
||||
local shell = cartColor(version)
|
||||
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
|
||||
math.floor(shell[3] * 0.54) }
|
||||
|
||||
cartPolygon(mainBack, side, 1)
|
||||
cartPolygon(capBack, side, 1)
|
||||
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
|
||||
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
|
||||
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
|
||||
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
|
||||
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
|
||||
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
|
||||
cartPolygon(mainFront, shell, 1)
|
||||
cartPolygon(capFront, shell, 1)
|
||||
|
||||
local faceZ = depth + 0.8
|
||||
for i = 0, 4 do
|
||||
local ry = mainTop + 7 + i * h * 0.025
|
||||
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
end
|
||||
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
|
||||
local recessW, recessH = w * 0.64, h * 0.24
|
||||
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
|
||||
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
|
||||
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
|
||||
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
|
||||
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
|
||||
|
||||
local labelX, labelY = -w * 0.33, -h * 0.20
|
||||
local labelW, labelH = w * 0.66, h * 0.55
|
||||
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
|
||||
cartPolygon(plate, side, 0.95)
|
||||
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
|
||||
local label = cartridgeLabel(imp, version)
|
||||
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
|
||||
if mesh then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(mesh)
|
||||
elseif label then
|
||||
local artScale = math.min(labelW / label.width, labelH / label.height)
|
||||
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
|
||||
0, artScale, artScale)
|
||||
end
|
||||
cartPolygon({
|
||||
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
|
||||
{ project(w * 0.07, h * 0.37, faceZ + 1) },
|
||||
{ project(0, h * 0.43, faceZ + 1) },
|
||||
}, side, 0.70)
|
||||
|
||||
if not state.active and (Kit._activateId == key) then
|
||||
queueAction(imp, key, action)
|
||||
end
|
||||
end
|
||||
|
||||
local function modStatusColor(status)
|
||||
if status == "ok" then return Strings("Ready"), PAL.green end
|
||||
if status == "conflict" then return Strings("Conflict"), PAL.red end
|
||||
@@ -1011,8 +1258,16 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
Kit.tag(x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
|
||||
+ math.floor(12 * m.s), y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
|
||||
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
|
||||
+ math.floor(12 * m.s)
|
||||
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
|
||||
if ready then
|
||||
local hint = Strings("(PRESS THE CART TO PLAY)")
|
||||
local hintX = tagX + tagW + math.floor(10 * m.s)
|
||||
local hintW = math.max(0, x + w - hintX)
|
||||
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
|
||||
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
|
||||
end
|
||||
local cy = y + titleH + math.floor(12 * m.s)
|
||||
local remaining = availH - (titleH + math.floor(12 * m.s))
|
||||
|
||||
@@ -1038,25 +1293,20 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
local ly = cy
|
||||
|
||||
if ready then
|
||||
-- Play IS the panel: it takes the space the ROM buttons used to hold, at
|
||||
-- the top of the column where the eye lands, wearing this game's own
|
||||
-- cartridge colour rather than a generic green.
|
||||
-- Play grows into the room the column has: it is the one thing on this
|
||||
-- screen the player came for, and the space freed by moving ROM and
|
||||
-- control management out belongs to it rather than to a gap. Clamped at
|
||||
-- both ends so a short window still gets a real button and a tall one
|
||||
-- does not get a billboard.
|
||||
local playH = math.floor(clamp(remaining * 0.30, 64 * m.s, 132 * m.s))
|
||||
local mgW = playH
|
||||
-- The cartridge takes the Play button's former place. Its portrait
|
||||
-- ratio comes from a real Game Boy cart rather than stretching the old
|
||||
-- horizontal control, and its body colour comes from the active game.
|
||||
local playH = math.floor(clamp(remaining * 0.52, 112 * m.s, 260 * m.s))
|
||||
local mgW = math.max(Kit.tapMin(), math.floor(34 * m.s))
|
||||
local bgap = math.floor(8 * m.s)
|
||||
btn(imp, lx, ly, lw - mgW - bgap, playH, "play-" .. version,
|
||||
Strings("Play ") .. gameName, {
|
||||
fill = cartColor(version), ink = PAL.inverse, font = "stat",
|
||||
action = function() imp:play(version) end,
|
||||
})
|
||||
local cartAreaW = lw - mgW - bgap
|
||||
local cartW = math.min(cartAreaW, math.floor(playH * 0.88))
|
||||
local cartX = lx + math.floor((cartAreaW - cartW) / 2)
|
||||
cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version,
|
||||
version, gameName, function() imp:play(version, true) end)
|
||||
imp._gearIcon = imp._gearIcon
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, playH,
|
||||
iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, mgW,
|
||||
imp._gearIcon, function() imp._gameManage = version end)
|
||||
ly = ly + playH + gap
|
||||
end
|
||||
@@ -1139,6 +1389,28 @@ local function drawCheck(x, y, size, color)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One compact coloured checkbox for each game. The cartridge colour carries
|
||||
-- the game identity even when the row is narrow; the letter keeps an unchecked
|
||||
-- box legible without relying on colour alone.
|
||||
local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
local color = cartColor(game)
|
||||
local focused = Kit.focusable(id, x, y, size, size)
|
||||
local hot = focused or Kit.hover(x, y, size, size)
|
||||
if love.graphics then
|
||||
if checked then
|
||||
Theme.fillRounded(x, y, size, size, color, 1)
|
||||
drawCheck(x, y, size, PAL.inverse)
|
||||
else
|
||||
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
|
||||
Kit.textCenterBold("micro", game:sub(1, 1):upper(), x,
|
||||
y + (size - Kit.textHeight("micro")) / 2, size, color)
|
||||
end
|
||||
Theme.strokeRounded(x, y, size, size, color,
|
||||
hot and Theme.A.focus or Theme.A.hover, 1)
|
||||
end
|
||||
return Kit.press(x, y, size, size) or Kit._activateId == id
|
||||
end
|
||||
|
||||
local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
imp:_ensureMods()
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
@@ -1185,6 +1457,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
cy = cy + buildModScopeRow(imp, x, cy, w, m)
|
||||
|
||||
if #mods == 0 then
|
||||
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
|
||||
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
|
||||
return
|
||||
end
|
||||
@@ -1225,28 +1498,41 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
mods = sorted
|
||||
end
|
||||
|
||||
-- A mod row is a fixed height: name line, version + status line, one line
|
||||
-- of description, and an action row. Fixed because a page of uniform rows
|
||||
-- is what lets perPage come from the viewport.
|
||||
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
-- Text block on the left, chips right-aligned beside it: one row, not a
|
||||
-- text block with a button strip stacked under it.
|
||||
-- A mod row is a fixed height: its details first, then a dedicated second
|
||||
-- line of per-game checkboxes. Fixed because a page of uniform rows is
|
||||
-- what lets perPage come from the viewport.
|
||||
local togH = math.floor(26 * m.s)
|
||||
local gamesLabel = Strings("Enable for:")
|
||||
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
|
||||
local rowH = math.floor(8 * m.s) + math.max(textH, chipH)
|
||||
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
|
||||
+ math.floor(8 * m.s)
|
||||
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
local listH = availH - (cy - y) - pagerH - gap
|
||||
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 20)
|
||||
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_MODS_PER_PAGE, 20)
|
||||
local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage)
|
||||
setPage(imp, "mods", cur)
|
||||
local listTop = cy
|
||||
setPage(imp, "mods",
|
||||
Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage))
|
||||
local shown = math.max(0, last - first + 1)
|
||||
local contentH = shown * rowH + math.max(0, shown - 1) * gap
|
||||
local scrollMax = math.max(0, contentH - listH)
|
||||
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
|
||||
imp._modListRect = { x = x, y = listTop, w = w, h = listH }
|
||||
imp._modScrollMax = scrollMax
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then
|
||||
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
|
||||
Kit.wheelY = 0
|
||||
elseif scrollMax == 0 then
|
||||
local wheelPage = Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage)
|
||||
if wheelPage ~= cur then imp.modScroll = 0 end
|
||||
setPage(imp, "mods", wheelPage)
|
||||
end
|
||||
imp.modScroll = scroll
|
||||
|
||||
Kit.pushClip(x, listTop, w, listH)
|
||||
for i = first, last do
|
||||
local mod = mods[i]
|
||||
local ry = listTop + (i - first) * (rowH + gap)
|
||||
local ry = listTop + (i - first) * (rowH + gap) - scroll
|
||||
local rowKey = "mod-row-" .. mod.id
|
||||
-- The whole row is the control: it opens the per-mod actions popup
|
||||
-- (update / versions / delete moved there). Only the enable toggle
|
||||
@@ -1258,28 +1544,36 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local px, inner = x + pad, w - 2 * pad
|
||||
local ly = ry + math.floor(10 * m.s)
|
||||
|
||||
local togW = math.floor(56 * m.s)
|
||||
local togH = math.floor(26 * m.s)
|
||||
local togGap = math.floor(4 * m.s)
|
||||
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
||||
|
||||
local togKey = "mod-toggle-" .. mod.id
|
||||
-- The toggle reports its own new value, but the importer owns the state:
|
||||
-- queue the flip and let _toggleMod (which may raise an experimental-mod
|
||||
-- confirm) decide what actually happens.
|
||||
local _, flipped = Kit.toggle(px + inner - togW,
|
||||
ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey)
|
||||
if flipped then
|
||||
queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end)
|
||||
-- These answer separate games, not a single shared install flag. The
|
||||
-- importer receives the game id so an experimental confirmation also
|
||||
-- applies only to the checkbox the player pressed.
|
||||
local flipped = false
|
||||
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
|
||||
Kit.text("micro", gamesLabel, px,
|
||||
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
|
||||
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
|
||||
if modGameCheckbox(tx, gamesY, togH,
|
||||
mod.enabledByVersion and mod.enabledByVersion[game] == true,
|
||||
game, togKey) then
|
||||
local version = game
|
||||
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
|
||||
flipped = true
|
||||
end
|
||||
tx = tx + togH + togGap
|
||||
end
|
||||
-- The toggle sits inside the row's rect, so its press also passes the
|
||||
-- row's hit test; `flipped` gates the row action to everywhere else.
|
||||
-- The checkboxes sit inside the row's rect, so their press also passes the
|
||||
-- row hit test; `flipped` gates the row action to everywhere else.
|
||||
if not flipped
|
||||
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
|
||||
local id = mod.id
|
||||
queueAction(imp, rowKey, function() imp._modActions = id end)
|
||||
end
|
||||
local chipsW = togW + math.floor(6 * m.s)
|
||||
local textW = inner - chipsW - math.floor(12 * m.s)
|
||||
local textW = inner
|
||||
|
||||
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
|
||||
-- the games the mod is for, beside its category: the same chip the
|
||||
@@ -1337,9 +1631,11 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
px, ly, PAL.detail)
|
||||
end
|
||||
end
|
||||
Kit.popClip()
|
||||
|
||||
local pagerY = listTop + (last - first + 1) * (rowH + gap)
|
||||
local pagerY = listTop + listH + gap
|
||||
local newPage = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
|
||||
if newPage ~= cur then imp.modScroll = 0 end
|
||||
setPage(imp, "mods", newPage)
|
||||
end
|
||||
|
||||
@@ -1741,7 +2037,7 @@ local function buildConfirmModal(imp, m)
|
||||
elseif c.kind == "importOversize" then
|
||||
imp:_importSave(c.version, c.source, true)
|
||||
else
|
||||
imp:_toggleMod(c.id, true)
|
||||
imp:_toggleMod(c.id, true, c.version)
|
||||
end
|
||||
end,
|
||||
})
|
||||
@@ -2608,6 +2904,11 @@ function LauncherView.draw(imp)
|
||||
end
|
||||
end
|
||||
|
||||
if imp._launchFade then
|
||||
Theme.fill(0, 0, m.W, m.H, PAL.bg,
|
||||
math.min(1, imp._launchFade.elapsed / imp._launchFade.duration))
|
||||
end
|
||||
|
||||
Kit.endFrame()
|
||||
drawPadCursor(imp)
|
||||
end
|
||||
|
||||
+40
-11
@@ -1228,8 +1228,8 @@ function RomImporter.new(onComplete, opts)
|
||||
saveNotice = {},
|
||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
@@ -2048,6 +2048,15 @@ end
|
||||
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
if self._launchFade then
|
||||
self._launchFade.elapsed = self._launchFade.elapsed + dt
|
||||
if self._launchFade.elapsed >= self._launchFade.duration then
|
||||
local version = self._launchFade.version
|
||||
self._launchFade = nil
|
||||
self:play(version)
|
||||
return
|
||||
end
|
||||
end
|
||||
self:_updatePadCursor(dt)
|
||||
self:_stepBaseRomScan()
|
||||
-- Pump the FlexLove view (input polling + the queued click actions). The
|
||||
@@ -2436,9 +2445,15 @@ function RomImporter:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||
function RomImporter:play(version)
|
||||
function RomImporter:play(version, fade)
|
||||
if self.workState == "working" then return end
|
||||
if not self.ready[version] then return end
|
||||
if fade then
|
||||
if not self._launchFade then
|
||||
self._launchFade = { version = version, elapsed = 0, duration = 0.24 }
|
||||
end
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
-- #835: remember the game being launched so the next launcher start opens on
|
||||
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
|
||||
@@ -2652,11 +2667,14 @@ function RomImporter:_openSettings()
|
||||
-- hook rather than reaching for main.lua's handler itself. Closing the
|
||||
-- settings panel FIRST persists the pending edits (_closeSettings saves)
|
||||
-- and leaves no modal behind the editor to return to.
|
||||
-- The tab rides along: the editor persists the layout into that game's own
|
||||
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
||||
local hooks = {}
|
||||
if self.onEditTouchControls then
|
||||
local version = self.tab
|
||||
hooks.editTouchControls = function()
|
||||
self:_closeSettings()
|
||||
self.onEditTouchControls()
|
||||
self.onEditTouchControls(version)
|
||||
end
|
||||
end
|
||||
-- The tab the gear was opened on decides the row set: Gold reads a
|
||||
@@ -2741,6 +2759,13 @@ function RomImporter:keypressed(key)
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
-- inside the modal branch so text fields retain exclusive keyboard input.
|
||||
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
|
||||
return
|
||||
end
|
||||
if key == "escape" then
|
||||
if self._findDetails then
|
||||
self._findDetails = nil
|
||||
@@ -3054,15 +3079,19 @@ function RomImporter:_modUpdateInfo(id)
|
||||
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
|
||||
end
|
||||
|
||||
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist
|
||||
-- so the toggle, count, and every status chip reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirm first.
|
||||
function RomImporter:_toggleMod(id, confirmed)
|
||||
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
|
||||
-- so that game's checkbox and status chips reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||
function RomImporter:_toggleMod(id, confirmed, version)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local cur, experimental = false, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
if m.id == id then
|
||||
cur = m.enabled
|
||||
if version and m.enabledByVersion then
|
||||
cur = m.enabledByVersion[version] == true
|
||||
else
|
||||
cur = m.enabled
|
||||
end
|
||||
experimental = m.experimental == true
|
||||
break
|
||||
end
|
||||
@@ -3070,7 +3099,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
||||
local want = not cur
|
||||
if want and experimental and not confirmed then
|
||||
self._modConfirm = {
|
||||
kind = "experimental", id = id,
|
||||
kind = "experimental", id = id, version = version,
|
||||
title = "Experimental mod",
|
||||
yesLabel = "Enable",
|
||||
lines = {
|
||||
@@ -3082,7 +3111,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
||||
return
|
||||
end
|
||||
self._modConfirm = nil
|
||||
LauncherMods.setEnabled(id, want, self.modScope)
|
||||
LauncherMods.setEnabled(id, want, version or self.modScope)
|
||||
self:_refreshMods()
|
||||
end
|
||||
|
||||
|
||||
@@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
|
||||
if itemId == "POKE_FLUTE" then
|
||||
if not battle then
|
||||
if ow and ow.map and ow.map.id == "PEWTER_POKECENTER"
|
||||
and ow.pikachuPewterSleepScene then
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
local pika = Follower.current(ow)
|
||||
local player = ow.player
|
||||
if pika and player
|
||||
and math.abs(pika.cellX - player.cellX) + math.abs(pika.cellY - player.cellY) == 1 then
|
||||
return "flute_wake_pikachu", { romText(data, "_PlayedFluteHadEffectText",
|
||||
"{PLAYER} played the\nPOKé FLUTE.") }
|
||||
end
|
||||
end
|
||||
-- standing next to a not-yet-beaten Snorlax: this is the ONLY way
|
||||
-- Snorlax wakes -- using the flute from the item-use menu, never
|
||||
-- just talking to it with the flute in the bag (see
|
||||
|
||||
@@ -736,7 +736,7 @@ COVERAGE["src.pokemon.Boxes"] = {
|
||||
|
||||
COVERAGE["src.world.WorldAPI"] = {
|
||||
kind = "alias", target = "src.world.gen2.WorldAPI",
|
||||
backed = "new __index overworld current warpTo toggleObject replaceBlock "
|
||||
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap",
|
||||
warned = "setFlag getFlag",
|
||||
absent = "",
|
||||
|
||||
+44
-11
@@ -36,6 +36,7 @@ local ModTargets = require("src.mods.ModTargets")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local LauncherMods = {}
|
||||
@@ -107,8 +108,9 @@ end
|
||||
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (options.mods, options.modsByVersion and
|
||||
-- options.modsGen2 are read). `version` is the game the panel is showing:
|
||||
-- nil keeps the pre-per-game view, where the shared flag is the whole answer.
|
||||
-- options.modsGen2 are read). `version` is the game the panel is showing;
|
||||
-- each row also carries its answer for every game so the launcher can render
|
||||
-- the coloured game checkboxes together.
|
||||
-- Rows come back sorted by id so the panel order is stable.
|
||||
function LauncherMods.deriveList(manifests, options, version)
|
||||
local ordered = {}
|
||||
@@ -126,8 +128,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
-- this game's choice, then the shared flag, then the default: enabled,
|
||||
-- matching the loader -- except experimental mods, which stay off until
|
||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||
-- setEnabled writes and the loader loads: while per-game flags are a
|
||||
-- preview the shared flag is the whole answer on every surface.
|
||||
-- setEnabled writes and the loader loads for the selected game.
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
@@ -152,6 +153,14 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
badge = badge,
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
enabledByVersion = (function()
|
||||
local answers = {}
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local answer = SaveData.modEnabled(options, m.id, game)
|
||||
answers[game] = answer == true or (answer == nil and not m.experimental)
|
||||
end
|
||||
return answers
|
||||
end)(),
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
@@ -281,7 +290,14 @@ end
|
||||
function LauncherMods.list(version)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options, version)
|
||||
local manifests = discover()
|
||||
-- The first build containing game-specific switches turns the old shared
|
||||
-- state into one explicit answer per installed mod and game. Saving here
|
||||
-- means users who only visit the launcher still receive the migration.
|
||||
if SaveData.migrateModEnablement(options, manifests) then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return LauncherMods.deriveList(manifests, options, version)
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
@@ -364,10 +380,8 @@ function LauncherMods.translationStrings()
|
||||
return merged
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact
|
||||
-- shape Loader:_saveState writes (a plain boolean), so the running game and
|
||||
-- the in-game ManagerState pick it up unchanged. With `version` the choice
|
||||
-- lands in that game's overlay instead and no other game moves.
|
||||
-- setEnabled(id, enabled [, version]): with a game, persist just that game's
|
||||
-- answer. The loader and the in-game manager use the same scope on next boot.
|
||||
function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
@@ -384,7 +398,15 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
local scope = SaveData.modScope(version)
|
||||
for _, id in ipairs(ids or {}) do
|
||||
SaveData.setModEnabled(options, id, enabled, scope)
|
||||
if scope then
|
||||
SaveData.setModEnabled(options, id, enabled, scope)
|
||||
elseif SaveData.PER_VERSION_MODS then
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
SaveData.setModEnabled(options, id, enabled, game)
|
||||
end
|
||||
else
|
||||
SaveData.setModEnabled(options, id, enabled)
|
||||
end
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
@@ -843,7 +865,7 @@ end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
-- or the save directory, CacheFs decides -- #330) and clears every enable flag
|
||||
-- so the loader and in-game manager no longer see it. Rejects missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
@@ -872,8 +894,19 @@ function LauncherMods.uninstall(id)
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
local options = SaveData.loadOptions()
|
||||
local changed = false
|
||||
if options.mods and options.mods[id] ~= nil then
|
||||
options.mods[id] = nil
|
||||
changed = true
|
||||
end
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local bucket = options.modsByVersion and options.modsByVersion[version]
|
||||
if type(bucket) == "table" and bucket[id] ~= nil then
|
||||
bucket[id] = nil
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
|
||||
+20
-4
@@ -270,8 +270,7 @@ function Loader:_targetVersion()
|
||||
return version
|
||||
end
|
||||
|
||||
-- The version an enable flag is read and written under: this game once
|
||||
-- per-game flags are live, nil (the shared flag) while they are a preview.
|
||||
-- The version an enable flag is read and written under: this running game.
|
||||
-- Reads and writes go through the same answer so the two can never drift.
|
||||
function Loader:_enableScope()
|
||||
return SaveData.modScope(self:_targetVersion())
|
||||
@@ -1306,13 +1305,30 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
-- Existing installs stored one shared answer. Once their manifests are
|
||||
-- known, split that answer across every game before the next launcher/game
|
||||
-- toggle can change one independently. _loadState already used the same
|
||||
-- fallback, so this write cannot change the current boot's result.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local installed = {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
installed[#installed + 1] = {
|
||||
id = id,
|
||||
experimental = mod.manifest and mod.manifest.experimental == true,
|
||||
}
|
||||
end
|
||||
if SaveData.migrateModEnablement(options, installed) and self.fs.write then
|
||||
SaveData.saveOptions(options, self.fs)
|
||||
end
|
||||
end
|
||||
-- Experimental mods stay off until the player opts in: a missing
|
||||
-- options.mods entry normally means enabled, but experimental flips that.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local modsOpt = options.mods or {}
|
||||
local scope = self:_enableScope()
|
||||
for id, mod in pairs(self.mods) do
|
||||
if not self.disabled[id] and modsOpt[id] == nil
|
||||
if not self.disabled[id] and SaveData.modEnabled(options, id, scope) == nil
|
||||
and mod.manifest.experimental then
|
||||
self.disabled[id] = true
|
||||
end
|
||||
|
||||
@@ -735,8 +735,7 @@ function ManagerState:toggleGen2Force(m)
|
||||
}, apply)
|
||||
end
|
||||
|
||||
-- Where the loader persisted an enable flag: this game's slot once it keeps
|
||||
-- them per game, the shared flag until then (SaveData.modScope).
|
||||
-- Where the loader persists an enable flag: this running game's slot.
|
||||
function ManagerState:enableScope()
|
||||
return SaveData.modScope((self:targetGame()))
|
||||
end
|
||||
|
||||
@@ -140,6 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name), nextStep))
|
||||
|
||||
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
|
||||
return obpCache[key]
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = getObpImage
|
||||
|
||||
-- hot reload drops the sheets; live instances hold their own image, so
|
||||
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
||||
function SpriteRenderer.invalidate()
|
||||
@@ -281,7 +283,7 @@ end
|
||||
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
|
||||
-- and trailing, so every existing call site is unchanged.
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
||||
topHalf, forceFlip)
|
||||
topHalf, forceFlip, frameOverride)
|
||||
local x, y = self:getScreenOrigin(px, py, camX, camY)
|
||||
local image = self.image
|
||||
local redraw = false
|
||||
@@ -329,6 +331,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
-- facePlayer on STAY NPCs) but never show walk frames.
|
||||
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||
if frameOverride and self.frames[frameOverride] then
|
||||
frame, flip = frameOverride, false
|
||||
end
|
||||
if forceFlip then flip = true end
|
||||
local quad = self.frames[frame]
|
||||
local drawHeight = self.frameHeight
|
||||
|
||||
@@ -130,6 +130,7 @@ TextBox.TOKENS = {
|
||||
STRBUF = function(game) return game.stringBuffer end,
|
||||
RAM = function(game, arg)
|
||||
if arg == "wStringBuffer" then return game.stringBuffer end
|
||||
if arg == "wNameBuffer" then return game.stringBuffer end
|
||||
if arg == "wBoxNumString" then return game.boxNumString end
|
||||
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
||||
if arg == "wBoxMonNicks" then return game.boxMonNicks end
|
||||
|
||||
@@ -100,6 +100,7 @@ O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_
|
||||
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
|
||||
-- 0x2852 (#763, #857).
|
||||
O.toggleObjectFlags = O.coins + 2 -- 32B
|
||||
O.hiddenItemFlags = O.townVisited - 27
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
if data.hiddenItems then
|
||||
save.hiddenTaken = {}
|
||||
for i, row in ipairs(data.hiddenItems) do
|
||||
if bitGet(bytes, O.hiddenItemFlags, i - 1) then
|
||||
save.hiddenTaken[row[1] .. "_" .. row[2] .. "_" .. row[3]] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -953,6 +963,14 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
if data.hiddenItems then
|
||||
local taken = save.hiddenTaken or {}
|
||||
for i, row in ipairs(data.hiddenItems) do
|
||||
local key = row[1] .. "_" .. row[2] .. "_" .. row[3]
|
||||
bitSet(buf, O.hiddenItemFlags, i - 1, taken[key] and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
|
||||
@@ -49,6 +49,7 @@ local DATA_MODULES = {
|
||||
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
|
||||
hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" },
|
||||
}
|
||||
|
||||
local OPTIONAL_MODULES = { tilesets = true, audio = true }
|
||||
@@ -64,6 +65,10 @@ local YELLOW_EVENT_FLAGS = {
|
||||
"src.save_convert.data.event_flags_yellow",
|
||||
"src/save_convert/data/event_flags_yellow.lua",
|
||||
}
|
||||
local YELLOW_HIDDEN_ITEMS = {
|
||||
"src.save_convert.data.hidden_items_yellow",
|
||||
"src/save_convert/data/hidden_items_yellow.lua",
|
||||
}
|
||||
|
||||
local function loadTable(requirePath, filePath)
|
||||
local ok, mod = pcall(require, requirePath)
|
||||
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
|
||||
if name ~= "charmap" then
|
||||
if name == "eventFlags" and gameVersion == "yellow" then
|
||||
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
|
||||
elseif name == "hiddenItems" and gameVersion == "yellow" then
|
||||
spec = YELLOW_HIDDEN_ITEMS
|
||||
end
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
return {
|
||||
{ "VIRIDIAN_FOREST", 1, 18 }, { "VIRIDIAN_FOREST", 16, 42 },
|
||||
{ "MT_MOON_B2F", 18, 12 }, { "ROUTE_25", 38, 3 },
|
||||
{ "ROUTE_9", 14, 7 }, { "SS_ANNE_KITCHEN", 13, 9 },
|
||||
{ "SS_ANNE_B1F_ROOMS", 3, 1 }, { "ROUTE_10", 9, 17 },
|
||||
{ "ROUTE_10", 16, 53 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
|
||||
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
|
||||
{ "POKEMON_TOWER_5F", 4, 12 }, { "ROUTE_13", 1, 14 },
|
||||
{ "ROUTE_13", 16, 13 }, { "POKEMON_MANSION_B1F", 1, 9 },
|
||||
{ "SAFARI_ZONE_GATE", 10, 1 }, { "SAFARI_ZONE_WEST", 6, 5 },
|
||||
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
|
||||
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "CERULEAN_CAVE_1F", 14, 11 },
|
||||
{ "CERULEAN_CAVE_B1F", 27, 3 }, { "POWER_PLANT", 17, 16 },
|
||||
{ "POWER_PLANT", 12, 1 }, { "SEAFOAM_ISLANDS_B2F", 15, 15 },
|
||||
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "POKEMON_MANSION_1F", 8, 16 },
|
||||
{ "POKEMON_MANSION_3F", 1, 9 }, { "ROUTE_23", 9, 44 },
|
||||
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
|
||||
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
|
||||
{ "UNUSED_MAP_6F", 14, 11 }, { "VIRIDIAN_CITY", 14, 4 },
|
||||
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
|
||||
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
|
||||
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
|
||||
{ "ROUTE_17", 8, 121 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
|
||||
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "CELADON_CITY", 48, 15 },
|
||||
{ "ROUTE_25", 10, 1 }, { "MT_MOON_B2F", 33, 9 },
|
||||
{ "SEAFOAM_ISLANDS_B3F", 9, 16 }, { "VERMILION_CITY", 14, 11 },
|
||||
{ "CERULEAN_CITY", 15, 8 }, { "ROUTE_4", 40, 3 },
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
return {
|
||||
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
|
||||
{ "POKEMON_MANSION_3F", 1, 9 }, { "POKEMON_MANSION_B1F", 1, 9 },
|
||||
{ "SAFARI_ZONE_WEST", 6, 5 }, { "CERULEAN_CAVE_2F", 16, 13 },
|
||||
{ "CERULEAN_CAVE_B1F", 8, 14 }, { "UNUSED_MAP_6F", 14, 11 },
|
||||
{ "SEAFOAM_ISLANDS_B2F", 15, 15 }, { "SEAFOAM_ISLANDS_B3F", 9, 16 },
|
||||
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "VIRIDIAN_FOREST", 1, 18 },
|
||||
{ "VIRIDIAN_FOREST", 16, 42 }, { "MT_MOON_B2F", 18, 12 },
|
||||
{ "MT_MOON_B2F", 33, 9 }, { "SS_ANNE_B1F_ROOMS", 3, 1 },
|
||||
{ "SS_ANNE_KITCHEN", 13, 9 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
|
||||
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
|
||||
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
|
||||
{ "ROUTE_10", 9, 17 }, { "ROUTE_10", 16, 53 },
|
||||
{ "POWER_PLANT", 17, 16 }, { "POWER_PLANT", 12, 1 },
|
||||
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
|
||||
{ "ROUTE_13", 1, 14 }, { "ROUTE_13", 16, 13 },
|
||||
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
|
||||
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
|
||||
{ "ROUTE_17", 8, 121 }, { "ROUTE_23", 9, 44 },
|
||||
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
|
||||
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
|
||||
{ "ROUTE_25", 38, 3 }, { "ROUTE_25", 10, 1 },
|
||||
{ "ROUTE_4", 40, 3 }, { "ROUTE_9", 14, 7 },
|
||||
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "VIRIDIAN_CITY", 14, 4 },
|
||||
{ "CERULEAN_CITY", 15, 8 }, { "CERULEAN_CAVE_1F", 18, 7 },
|
||||
{ "POKEMON_TOWER_5F", 4, 12 }, { "VERMILION_CITY", 14, 11 },
|
||||
{ "CELADON_CITY", 48, 15 }, { "SAFARI_ZONE_GATE", 10, 1 },
|
||||
{ "POKEMON_MANSION_1F", 8, 16 },
|
||||
}
|
||||
+17
-3
@@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value)
|
||||
ctx.save[key] = value
|
||||
end
|
||||
|
||||
function Commands.load_player_starter_name(ctx)
|
||||
local flags = ctx.save.flags or {}
|
||||
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
|
||||
or flags.EVENT_CHOSE_CHARMANDER and "CHARMANDER"
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and "SQUIRTLE"
|
||||
or flags.EVENT_CHOSE_BULBASAUR and "BULBASAUR"
|
||||
or (ctx.save.party and ctx.save.party[1] and ctx.save.party[1].species)
|
||||
local def = species and ctx.game.data.pokemon[species]
|
||||
ctx.game.stringBuffer = def and def.name or species or ""
|
||||
end
|
||||
|
||||
function Commands.spawn_pikachu_follower(ctx)
|
||||
require("src.world.PikachuFollower").onMapEntered(
|
||||
ctx.game, ctx.overworld, nil, false)
|
||||
end
|
||||
|
||||
local function toggleObject(ctx, mapId, objName, visible)
|
||||
local save = ctx.save
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
@@ -1131,9 +1147,7 @@ end
|
||||
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
|
||||
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
|
||||
function Commands.play_music(ctx, songId, opts)
|
||||
local tempo = opts and opts.tempo
|
||||
require("src.core.Music").play(ctx.game.data, songId, nil,
|
||||
tempo and { tempo = tempo } or nil)
|
||||
require("src.core.Music").play(ctx.game.data, songId, nil, opts)
|
||||
if opts and opts.keep and ctx.overworld then
|
||||
ctx.overworld.keepMusicOnce = true
|
||||
end
|
||||
|
||||
+19
-7
@@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return
|
||||
end
|
||||
|
||||
if result == "flute_wake_pikachu" then
|
||||
require("src.core.Sound").play(game.data, "Pokeflute")
|
||||
showMessages(game, payload, function()
|
||||
game.overworld.pikachuPewterSleepScene = nil
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
|
||||
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
|
||||
if result == "flute_wake" then
|
||||
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local p = ow and ow.player
|
||||
if ow and p then
|
||||
local fx, fy = p:facingCell()
|
||||
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
if ow and p and ow:facingIsShoreOrWater() then
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||
return
|
||||
@@ -176,19 +181,25 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) })
|
||||
if result == "learn" then consume(game, id) end
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
taught()
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
||||
function(learned)
|
||||
if learned and result == "learn" then consume(game, id) end
|
||||
if learned then
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
end
|
||||
if learned then taught() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
list:close()
|
||||
teach()
|
||||
return
|
||||
end
|
||||
@@ -322,6 +333,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local mdef = game.data.moves[moveId]
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local name = target.nickname or def.name
|
||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||
nextStep)
|
||||
|
||||
@@ -119,6 +119,13 @@ local function deposit(game)
|
||||
onChoose = function(item, list)
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
if Follower.isFollowingDisabled(game.overworld)
|
||||
and Follower.isStarterPikachu(game.save, mon) then
|
||||
game.stack:push(TextBox.new(game, t._SleepingPikachuText2
|
||||
or Strings("There isn't any\nresponse...")))
|
||||
return
|
||||
end
|
||||
monSubmenu(game, "DEPOSIT", mon, function()
|
||||
if #game.save.party <= 1 then
|
||||
list.footer = Strings("You need at least\none POKéMON!")
|
||||
@@ -167,6 +174,16 @@ local function release(game)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and mon.species == "PIKACHU"
|
||||
and mon.otId == game.save.player.id
|
||||
and mon.ot == game.save.player.name then
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
|
||||
:gsub("{RAM:wNameBuffer}", name)))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
||||
defaultNo = true, noSound = true,
|
||||
|
||||
@@ -17,12 +17,13 @@ local HM_MOVES = {
|
||||
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
|
||||
}
|
||||
|
||||
function MoveLearnMenu.new(game, mon, newMoveId, onDone)
|
||||
function MoveLearnMenu.new(game, mon, newMoveId, onDone, learnedSound)
|
||||
local self = setmetatable({}, MoveLearnMenu)
|
||||
self.game = game
|
||||
self.mon = mon
|
||||
self.newMoveId = newMoveId
|
||||
self.onDone = onDone
|
||||
self.learnedSound = learnedSound or "Get_Item1"
|
||||
self.index = 1
|
||||
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
|
||||
self.selecting = false
|
||||
@@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned)
|
||||
self.selecting = false
|
||||
game.stack:pop()
|
||||
local msg
|
||||
local opts
|
||||
if learned then
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
@@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned)
|
||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name)
|
||||
opts = { auto = { sound = function()
|
||||
return require("src.core.Sound").play(game.data, self.learnedSound)
|
||||
end, wait = true } }
|
||||
else
|
||||
msg = romText(game.data, "_DidNotLearnText",
|
||||
"%s\ndid not learn\v%s!", name, mdef.name)
|
||||
end
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
if self.onDone then self.onDone(learned) end
|
||||
end))
|
||||
end, opts))
|
||||
end
|
||||
|
||||
function MoveLearnMenu:draw()
|
||||
|
||||
@@ -266,7 +266,10 @@ function OakSpeech.new(game, onDone)
|
||||
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
|
||||
-- standing, facing down)
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
|
||||
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED
|
||||
-- The fallback has to read the same guarded table: reaching for
|
||||
-- game.data.sprites.SPRITE_RED after the `and` already found it nil threw.
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED
|
||||
self.walkSheet = tryImage(red and red.image)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -501,7 +501,7 @@ local function buildRows(game)
|
||||
return true
|
||||
end },
|
||||
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
|
||||
-- MEDIUM / HEAVY, where the intensity is a vibration duration --
|
||||
-- NORMAL / STRONG, where the intensity is a vibration duration --
|
||||
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
|
||||
-- since the only thing that buzzes is a virtual button press.
|
||||
{ id = "haptics", label = Strings("VIBRATION"),
|
||||
@@ -515,7 +515,7 @@ local function buildRows(game)
|
||||
o.haptics = TC.cycleHaptics(o.haptics, dir)
|
||||
TC:applyOptions(o)
|
||||
-- sample the level being selected: stepping the row is the only way
|
||||
-- to compare LIGHT against HEAVY without leaving the menu
|
||||
-- to compare LIGHT against STRONG without leaving the menu
|
||||
TC.buzz(o.haptics)
|
||||
return true
|
||||
end },
|
||||
|
||||
@@ -72,6 +72,21 @@ end
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
local function followerUnavailable(game, mon)
|
||||
local ow = game.overworld
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
return Follower.isFollowingDisabled(ow)
|
||||
and Follower.isStarterPikachu(game.save, mon)
|
||||
end
|
||||
|
||||
local function refuseUnavailable(self)
|
||||
self.swapFrom = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = self.game.data and self.game.data.text or {}
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
|
||||
end
|
||||
|
||||
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
|
||||
-- excluded by map id in ItemUseEscapeRope)
|
||||
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
@@ -367,6 +382,10 @@ function PartyMenu:update(dt)
|
||||
self.submenu = nil
|
||||
elseif input:wasPressed("a") then
|
||||
local mon = party[self.index]
|
||||
if followerUnavailable(self.game, mon) then
|
||||
refuseUnavailable(self)
|
||||
return
|
||||
end
|
||||
local entry = self.subItems[self.subIndex]
|
||||
local action = entry.action
|
||||
if not action and entry.onSelect then
|
||||
@@ -577,6 +596,10 @@ function PartyMenu:update(dt)
|
||||
if self.onCancel then self.onCancel() end
|
||||
elseif input:wasPressed("a") and #party > 0 then
|
||||
local mon = party[self.index]
|
||||
if followerUnavailable(self.game, mon) then
|
||||
refuseUnavailable(self)
|
||||
return
|
||||
end
|
||||
if self.softboiledFrom then
|
||||
local user = party[self.softboiledFrom]
|
||||
local heal = math.floor(user.stats.hp / 5)
|
||||
|
||||
@@ -104,6 +104,27 @@ local function sell(game)
|
||||
dialogue = true,
|
||||
money = function() return game.save.money end,
|
||||
footer = greet,
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
if not l.swapIndex then
|
||||
l.swapIndex = l.index
|
||||
return
|
||||
end
|
||||
local order = Bag.order(game.save)
|
||||
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
|
||||
l.swapIndex = nil
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
local rebuilt = {}
|
||||
for _, id in ipairs(order) do
|
||||
local def = game.data.items[id]
|
||||
rebuilt[#rebuilt + 1] = {
|
||||
value = id,
|
||||
label = def and def.name or id,
|
||||
right = "x" .. game.save.inventory[id],
|
||||
}
|
||||
end
|
||||
l.items = rebuilt
|
||||
end,
|
||||
onChoose = function(item)
|
||||
local def = game.data.items[item.value]
|
||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
||||
|
||||
@@ -34,7 +34,8 @@ function StartMenu.new(game)
|
||||
|
||||
-- POKéMON is always listed (draw_start_menu.asm prints it even with
|
||||
-- an empty party; selecting it then just no-ops)
|
||||
table.insert(items, { label = Strings("POKéMON"), onSelect = function()
|
||||
table.insert(items, { label = Strings("POKéMON"),
|
||||
keepOpen = #game.save.party == 0, onSelect = function()
|
||||
if #game.save.party == 0 then return end
|
||||
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
+15
-8
@@ -200,8 +200,9 @@ function TitleState.new(game, opts)
|
||||
self.versionFull = imagePath(title.versionRibbon) ~= nil
|
||||
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
||||
or "assets/generated/title/red_version.png")
|
||||
self.player = tryImage(imagePath(title.player)
|
||||
or "assets/generated/title/player.png")
|
||||
self.playerPath = imagePath(title.player)
|
||||
or "assets/generated/title/player.png"
|
||||
self.player = tryImage(self.playerPath)
|
||||
-- ..(engine/movie/title2.asm ln 85)
|
||||
if self.player then
|
||||
local pw, ph = self.player:getDimensions()
|
||||
@@ -597,6 +598,12 @@ end
|
||||
|
||||
-- ..(engine/movie/title.asm ln 28)
|
||||
function TitleState:draw()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local playerImage = self.player
|
||||
if playerImage and PaletteFX.usesSpriteObp() then
|
||||
playerImage = require("src.render.SpriteRenderer").obpImage(
|
||||
self.playerPath, PaletteFX.ogObj())
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local scrollY = -(self.scy or 0)
|
||||
@@ -660,8 +667,8 @@ function TitleState:draw()
|
||||
-- layout has no cycling mon and no Red art (title_yellow.asm).
|
||||
if spriteTrueColor then
|
||||
local cover
|
||||
if self.player then
|
||||
local pw, ph = self.player:getDimensions()
|
||||
if playerImage then
|
||||
local pw, ph = playerImage:getDimensions()
|
||||
cover = { 82, 80, pw, ph }
|
||||
end
|
||||
markVisibleTrueColor(x, y, w, h, cover)
|
||||
@@ -670,11 +677,11 @@ function TitleState:draw()
|
||||
-- Red is OAM in the original: he draws over the mon's box edge
|
||||
if self.playerQuads then
|
||||
for _, part in ipairs(self.playerQuads) do
|
||||
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
|
||||
love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3])
|
||||
end
|
||||
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
|
||||
elseif self.player then
|
||||
love.graphics.draw(self.player, 82, 80)
|
||||
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
|
||||
elseif playerImage then
|
||||
love.graphics.draw(playerImage, 82, 80)
|
||||
end
|
||||
end
|
||||
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
|
||||
|
||||
+54
-11
@@ -106,6 +106,10 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?")
|
||||
local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.")
|
||||
local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?")
|
||||
|
||||
-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231).
|
||||
local TEXT_ENEMY_ABOUT_TO_USE = Strings.source(
|
||||
"%s\nis about to use\v%s.\fWill %s\nchange POKéMON?")
|
||||
|
||||
-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165).
|
||||
local TEXT_ASK_FORGET_MOVE = Strings.source(
|
||||
"%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves."
|
||||
@@ -548,6 +552,14 @@ BattleState.PLAYER_PIC_TILES = 6
|
||||
-- box's own bottom centre.
|
||||
local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 }
|
||||
|
||||
-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on
|
||||
-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port
|
||||
-- carries both as the volatile `vanished` flag.
|
||||
function BattleState.isVanished(mon)
|
||||
local volatiles = mon and mon.volatile
|
||||
return (volatiles and volatiles.vanished) and true or false
|
||||
end
|
||||
|
||||
function BattleState:drawPic(mon, back)
|
||||
-- During the intro slide the player-side pic belongs to presentSlide's
|
||||
-- backpic overlay, not to the baked bands (see BattleAnimView).
|
||||
@@ -571,6 +583,13 @@ function BattleState:drawPic(mon, back)
|
||||
-- The box is empty either because the animation running right now has
|
||||
-- cleared it, or because the last one ENDED with it cleared (picHidden).
|
||||
if (anim and anim.hidden) or self.picHidden[side] then return end
|
||||
-- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it
|
||||
-- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the
|
||||
-- stored attack (engine/battle/effect_commands.asm:2113-2117).
|
||||
if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon)
|
||||
and not (self.vanishAnim and self.vanishAnim == self.anim) then
|
||||
return
|
||||
end
|
||||
local G = love.graphics
|
||||
local w, h = image:getDimensions()
|
||||
local px, py
|
||||
@@ -582,10 +601,12 @@ function BattleState:drawPic(mon, back)
|
||||
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
|
||||
boxTiles = BattleState.PLAYER_PIC_TILES
|
||||
else
|
||||
-- Bottom-aligned and horizontally centred inside the 7x7 box.
|
||||
-- PadFrontpic pads a short pic and never a long one
|
||||
-- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the
|
||||
-- box's own corner at hlcoord 12, 0 rather than to a negative offset.
|
||||
local box = BattleState.ENEMY_PIC_TILES * 8
|
||||
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2)
|
||||
py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h)
|
||||
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2))
|
||||
py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h)
|
||||
boxTiles = BattleState.ENEMY_PIC_TILES
|
||||
end
|
||||
-- One tile per two frames to the right, SlideBattlePicOut's own step.
|
||||
@@ -903,11 +924,12 @@ function BattleState:startAnim(key, opts)
|
||||
param = opts.param or 0,
|
||||
sfxOrder = audio.sfxOrder,
|
||||
ballPalette = opts.ballPalette,
|
||||
-- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3
|
||||
-- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit
|
||||
-- on the mon's volatile table, not on the mon itself.
|
||||
flying = {
|
||||
player = self.battle and self.battle.player
|
||||
and self.battle.player.vanished or false,
|
||||
enemy = self.battle and self.battle.enemy
|
||||
and self.battle.enemy.vanished or false,
|
||||
player = BattleState.isVanished(self.battle and self.battle.player),
|
||||
enemy = BattleState.isVanished(self.battle and self.battle.enemy),
|
||||
},
|
||||
hooks = {
|
||||
-- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls
|
||||
@@ -1365,6 +1387,13 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser
|
||||
-- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw
|
||||
-- the take-off or the burrow on the turn the substatus goes up; the box
|
||||
-- only empties once THIS animation is done with.
|
||||
if BattleState.isVanished(self:activeMon(event.side)) then
|
||||
self.vanishAnim = self.anim
|
||||
end
|
||||
elseif event.kind == "damage" and event.side then
|
||||
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
|
||||
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
|
||||
@@ -1864,6 +1893,20 @@ function BattleState:update(_dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one
|
||||
-- (engine/battle/core.asm:3302-3305), so the mon is named and read first.
|
||||
if self.phase == "shift-intro" then
|
||||
if self.messageTimer > 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
self:nextPage()
|
||||
if not self.messagePages then self.phase = "ask-shift" end
|
||||
return
|
||||
end
|
||||
|
||||
-- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls
|
||||
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
|
||||
if self.phase == "ask-shift" then
|
||||
@@ -2464,13 +2507,13 @@ end
|
||||
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
|
||||
function BattleState:offerShiftSwitch(mon)
|
||||
self.shiftIndex = 1
|
||||
self.phase = "ask-shift"
|
||||
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
|
||||
local player = (self.save and self.save.player and self.save.player.name)
|
||||
or "GOLD"
|
||||
self.message = trainer .. " is about to use " .. self:name(mon)
|
||||
.. ". Will " .. player .. " change POKéMON?"
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
-- The `para` splits this in two (data/text/battle.asm:222-231): the incoming
|
||||
-- mon is NAMED on its own page, and only the second carries the yes/no box.
|
||||
self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player))
|
||||
self.phase = self.messagePages and "shift-intro" or "ask-shift"
|
||||
end
|
||||
|
||||
function BattleState:answerUseNextMon(yes)
|
||||
|
||||
@@ -272,9 +272,6 @@ function EvolutionAnim:commit()
|
||||
evolved.level, evolved)
|
||||
end
|
||||
|
||||
-- One LearnMove call per move the new species picks up at this level. There
|
||||
-- is no forget screen in the Gold port yet, so a full moveset reports the way
|
||||
-- src/battle/gen2/Battle.lua's own level-up path does instead of prompting.
|
||||
function EvolutionAnim:nextLearn()
|
||||
self.learnIndex = (self.learnIndex or 0) + 1
|
||||
local moveId = self.pending and self.pending[self.learnIndex]
|
||||
@@ -289,6 +286,17 @@ function EvolutionAnim:nextLearn()
|
||||
self.learned[#self.learned + 1] = moveId
|
||||
self.lines = { self.nick .. " learned", moveName .. "!" }
|
||||
elseif reason == "full" then
|
||||
if self.game and self.game.learnMoveOn then
|
||||
self.phase = "waitingLearn"
|
||||
return self.game:learnMoveOn(self.evolved, moveId, function(learned)
|
||||
if learned then
|
||||
self.learned[#self.learned + 1] = moveId
|
||||
else
|
||||
self.full[#self.full + 1] = moveId
|
||||
end
|
||||
self:nextLearn()
|
||||
end)
|
||||
end
|
||||
self.full[#self.full + 1] = moveId
|
||||
self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" }
|
||||
else
|
||||
|
||||
+48
-13
@@ -80,7 +80,8 @@ local function purgeBundledModules()
|
||||
end
|
||||
end
|
||||
|
||||
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
|
||||
-- Boot.probePayload(rel)
|
||||
-- -> { engine = string, minShell = number, payloadHost = string } | nil, err
|
||||
--
|
||||
-- Mount the archive at rel (a save-directory-relative path) on an isolated
|
||||
-- mountpoint, read its src/core/Version.lua by executing the source with
|
||||
@@ -104,24 +105,50 @@ function Boot.probePayload(rel)
|
||||
if type(v) ~= "table" or type(v.engine) ~= "string" then
|
||||
return nil, "payload has no usable Version table"
|
||||
end
|
||||
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
|
||||
return {
|
||||
engine = v.engine,
|
||||
minShell = tonumber(v.minShell) or 1,
|
||||
payloadHost = type(v.payloadHost) == "string" and v.payloadHost or "love",
|
||||
}
|
||||
end
|
||||
|
||||
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
|
||||
-- Pure host gate shared by boot selection and the download worker. Missing
|
||||
-- payloadHost fields mean "love" so payloads made before this contract remain
|
||||
-- compatible with ordinary LOVE packages.
|
||||
function Boot.canHost(info, bundledShell, bundledPayloadHost)
|
||||
if type(info) ~= "table" then return false end
|
||||
local payloadHost = type(info.payloadHost) == "string"
|
||||
and info.payloadHost or "love"
|
||||
local host = type(bundledPayloadHost) == "string"
|
||||
and bundledPayloadHost or "love"
|
||||
return payloadHost == host and (tonumber(info.minShell) or 1)
|
||||
<= (tonumber(bundledShell) or 1)
|
||||
end
|
||||
|
||||
local function samePayloadHost(info, bundledPayloadHost)
|
||||
local payloadHost = type(info.payloadHost) == "string"
|
||||
and info.payloadHost or "love"
|
||||
local host = type(bundledPayloadHost) == "string"
|
||||
and bundledPayloadHost or "love"
|
||||
return payloadHost == host
|
||||
end
|
||||
|
||||
-- Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
|
||||
-- -> chosen | nil, toDelete
|
||||
--
|
||||
-- Pure (no love.*): decide which payload to run and which to delete.
|
||||
-- candidates is a list of { name = , engine = , minShell = }.
|
||||
-- candidates is a list of { name = , engine = , minShell = , payloadHost = }.
|
||||
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
|
||||
-- whose minShell <= bundledShell (a payload the running shell can host).
|
||||
-- whose payloadHost matches and minShell <= bundledShell.
|
||||
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
|
||||
-- already ship), or superseded by the chosen one (not newer than chosen).
|
||||
-- A payload newer than the chosen one but unrunnable here (minShell too
|
||||
-- high) is kept: a future shell upgrade may be able to run it.
|
||||
function Boot.select(candidates, bundledEngine, bundledShell)
|
||||
-- A newer incompatible payload is kept: a matching host or future shell
|
||||
-- may be able to run it.
|
||||
function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
|
||||
local chosen
|
||||
for _, c in ipairs(candidates) do
|
||||
local newer = Semver.compare(c.engine, bundledEngine) > 0
|
||||
local runnable = (c.minShell or 1) <= bundledShell
|
||||
local runnable = Boot.canHost(c, bundledShell, bundledPayloadHost)
|
||||
if newer and runnable then
|
||||
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
|
||||
chosen = c
|
||||
@@ -132,9 +159,15 @@ function Boot.select(candidates, bundledEngine, bundledShell)
|
||||
local toDelete = {}
|
||||
for _, c in ipairs(candidates) do
|
||||
if not (chosen and c.name == chosen.name) then
|
||||
local stale = Semver.compare(c.engine, bundledEngine) <= 0
|
||||
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
|
||||
stale = true
|
||||
local stale = false
|
||||
-- Never clean up another host family's payloads. A shared save directory
|
||||
-- may be opened by multiple native packages, and only the matching host
|
||||
-- can decide whether one of its own archives is stale.
|
||||
if samePayloadHost(c, bundledPayloadHost) then
|
||||
stale = Semver.compare(c.engine, bundledEngine) <= 0
|
||||
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
|
||||
stale = true
|
||||
end
|
||||
end
|
||||
if stale then toDelete[#toDelete + 1] = c.name end
|
||||
end
|
||||
@@ -223,6 +256,7 @@ local function runInner(args)
|
||||
name = entry,
|
||||
engine = info.engine,
|
||||
minShell = info.minShell,
|
||||
payloadHost = info.payloadHost,
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -230,7 +264,8 @@ local function runInner(args)
|
||||
end
|
||||
|
||||
local Version = require("src.core.Version")
|
||||
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
|
||||
local chosen, toDelete = Boot.select(candidates, Version.engine,
|
||||
Version.shell, Version.payloadHost)
|
||||
|
||||
for _, victim in ipairs(toDelete) do
|
||||
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
|
||||
|
||||
@@ -153,8 +153,9 @@ local function gatePasses(rel)
|
||||
local info = Boot.probePayload(rel)
|
||||
if not info then return true end
|
||||
local shell = (Version and Version.shell) or 1
|
||||
if info.minShell and info.minShell > shell then return false end
|
||||
return true
|
||||
local payloadHost = (Version and Version.payloadHost) or "love"
|
||||
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
|
||||
return not (info.minShell and info.minShell > shell)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
-- Generation-neutral minimap rasterization. WorldAPI arms own semantic
|
||||
-- markers because object/event visibility differs; terrain and tile shading
|
||||
-- share one output contract.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local MapOverview = {}
|
||||
local overviewShades = {}
|
||||
|
||||
Assets.register(function() overviewShades = {} end)
|
||||
|
||||
local function shadeDigit(sum, pixelCount)
|
||||
return tostring(math.max(0, math.min(3,
|
||||
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
|
||||
end
|
||||
|
||||
local function tileRows(map)
|
||||
local tileset = map.tileset
|
||||
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
||||
local cached = overviewShades[tileset.image]
|
||||
if not cached then
|
||||
local ok, pixels = pcall(Assets.imageData, tileset.image)
|
||||
if not ok then return nil end
|
||||
cached = { pixels = pixels, shades = {} }
|
||||
overviewShades[tileset.image] = cached
|
||||
end
|
||||
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
|
||||
for ty = 0, map.heightCells * 2 - 1 do
|
||||
local row, detailTop, detailBottom = {}, {}, {}
|
||||
for tx = 0, map.widthCells * 2 - 1 do
|
||||
local tile = map:tileAt(tx, ty)
|
||||
local shades = cached.shades[tile]
|
||||
if shades == nil then
|
||||
local sums = { 0, 0, 0, 0 }
|
||||
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
||||
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
|
||||
sums[quadrant] = sums[quadrant]
|
||||
+ r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
end
|
||||
end
|
||||
shades = {
|
||||
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
|
||||
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
|
||||
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
|
||||
}
|
||||
cached.shades[tile] = shades
|
||||
end
|
||||
row[#row + 1] = shades[1]
|
||||
detailTop[#detailTop + 1] = shades[2] .. shades[3]
|
||||
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
detailRows[#detailRows + 1] = table.concat(detailTop)
|
||||
detailRows[#detailRows + 1] = table.concat(detailBottom)
|
||||
end
|
||||
return rows, detailRows
|
||||
end
|
||||
|
||||
function MapOverview.build(map, markers)
|
||||
local rows = {}
|
||||
for y = 0, map.heightCells - 1 do
|
||||
local row = {}
|
||||
for x = 0, map.widthCells - 1 do
|
||||
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
|
||||
or map:isWaterCell(x, y) and "~"
|
||||
or map:isWalkableCell(x, y) and "." or " "
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
end
|
||||
local tiles, detail = tileRows(map)
|
||||
return { mapId = map.id, width = map.widthCells,
|
||||
height = map.heightCells, rows = rows, markers = markers,
|
||||
tileRows = tiles,
|
||||
tileWidth = tiles and map.widthCells * 2,
|
||||
tileHeight = tiles and map.heightCells * 2,
|
||||
tileDetailRows = detail,
|
||||
tileDetailWidth = detail and map.widthCells * 4,
|
||||
tileDetailHeight = detail and map.heightCells * 4 }
|
||||
end
|
||||
|
||||
return MapOverview
|
||||
+2
-1
@@ -128,7 +128,8 @@ end
|
||||
|
||||
function NPC:draw(camX, camY)
|
||||
local sprite, px, py, facing, phase, flip = self:pose()
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip, nil, nil,
|
||||
self.frameOverride)
|
||||
end
|
||||
|
||||
return NPC
|
||||
|
||||
@@ -1087,6 +1087,7 @@ function OverworldState:update(dt)
|
||||
-- the player lands on desk Oak.
|
||||
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive
|
||||
if not scripted and not self.transitioning then
|
||||
self:checkTrainerSight()
|
||||
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
||||
@@ -1095,6 +1096,7 @@ function OverworldState:update(dt)
|
||||
-- the player can never start another step after being spotted.
|
||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive
|
||||
end
|
||||
if not scripted and not self.transitioning then
|
||||
self:handleInput()
|
||||
@@ -1397,14 +1399,16 @@ function OverworldState:checkLedgeHop(dir)
|
||||
return false
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
||||
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
|
||||
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
|
||||
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
|
||||
return true
|
||||
end
|
||||
if not Collision.occupied(self.entities, lx, ly, p)
|
||||
and self.map:isWalkableCell(lx, ly) then
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
||||
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
|
||||
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
|
||||
self:scriptMove(p, dir, 2)
|
||||
return true
|
||||
end
|
||||
@@ -1662,6 +1666,10 @@ end
|
||||
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
|
||||
-- (no group means "Not even a nibble!").
|
||||
function OverworldState:goFishing(rod)
|
||||
if GameVersion.isYellow() then
|
||||
Game.save.pikachuEmotionModifier = 2
|
||||
Game.save.pikachuMood = 0x81
|
||||
end
|
||||
local pool, always = fishingPool(Game.data, rod, self.map.id)
|
||||
local enc
|
||||
if Runtime.wantsHook("encounter.fishing") then
|
||||
@@ -2903,6 +2911,11 @@ end
|
||||
-- POKéMON" and "fighting fit".
|
||||
function OverworldState:nurseHeal(onDone, npc)
|
||||
local t = Game.data.text
|
||||
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone))
|
||||
return
|
||||
end
|
||||
local bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "We hope to see\nyou again!")
|
||||
local hello = t._PokemonCenterWelcomeText
|
||||
or Strings("Welcome to our\nPOKéMON CENTER!")
|
||||
@@ -2976,9 +2989,13 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
|
||||
end))
|
||||
end
|
||||
if not npc then farewell() return end
|
||||
npc.facing = "up"
|
||||
npc.frameOverride = 3
|
||||
-- bubble = false is the silent world hold, this port's DelayFrames
|
||||
self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell }
|
||||
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
|
||||
npc.frameOverride = nil
|
||||
npc:facePlayer(self.player)
|
||||
farewell()
|
||||
end }
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -2990,6 +3007,11 @@ end
|
||||
-- for the original serial handshake; declining prints "Please come again!"
|
||||
function OverworldState:cableClubReceptionist(onDone)
|
||||
local t = Game.data.text
|
||||
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone))
|
||||
return
|
||||
end
|
||||
local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!")
|
||||
if not Game.save.flags.EVENT_GOT_POKEDEX then
|
||||
-- CableClubNPC .didNotConnect path before the pokedex
|
||||
@@ -3515,10 +3537,21 @@ end
|
||||
|
||||
function OverworldState:onStepComplete()
|
||||
local p = self.player
|
||||
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
|
||||
if suppressWildEncounter then
|
||||
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
|
||||
-- Defaulted: a state built without the constructor (a mod harness, a test
|
||||
-- fixture) reaches this before :234 ever ran, and nil > 0 threw the step.
|
||||
local grace = self.wildEncounterGraceSteps or 0
|
||||
if grace > 0 then
|
||||
self.wildEncounterGraceSteps = grace - 1
|
||||
end
|
||||
-- TryDoWildEncounter's first guard is `ld a, [wNPCMovementScriptPointerTable
|
||||
-- Num] / and a / ret nz` (engine/battle/wild_encounters.asm:3-9): a step the
|
||||
-- player did not take never rolls, which is why Oak's escort walks to the lab
|
||||
-- through Pallet's grass without being jumped.
|
||||
local runner = self.runner
|
||||
local scripted = (runner and runner.isRunning and runner:isRunning())
|
||||
or #(self.scriptMoves or {}) > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
local suppressWildEncounter = grace > 0 or scripted and true or false
|
||||
self.todSteps = (self.todSteps or 0) + 1
|
||||
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
||||
require("src.world.PikachuFollower").onStep(Game.save)
|
||||
|
||||
@@ -65,6 +65,17 @@ function PikachuFollower.starterInParty(save, needHealthy)
|
||||
return nil
|
||||
end
|
||||
|
||||
function PikachuFollower.isStarterPikachu(save, mon)
|
||||
if not (mon and mon.species == "PIKACHU") then return false end
|
||||
local player = save.player or {}
|
||||
return mon.otId == player.id and mon.ot == player.name
|
||||
end
|
||||
|
||||
function PikachuFollower.isFollowingDisabled(ow)
|
||||
return ow and (ow.pikachuBillsScene or ow.pikachuFanClubScene
|
||||
or ow.pikachuPewterSleepScene) and true or false
|
||||
end
|
||||
|
||||
-- ModifyPikachuHappiness. mon is the party mon the event applied to for
|
||||
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
|
||||
-- WALKING instead require any healthy starter in the party
|
||||
@@ -199,6 +210,7 @@ function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad)
|
||||
-- Bill's House owns a short scripted scene that deliberately keeps
|
||||
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
||||
ow.pikachuBillsScene = nil
|
||||
ow.pikachuFanClubScene = nil
|
||||
remove(ow)
|
||||
if not shouldSpawn(game, ow) then return end
|
||||
-- opts.keepPikachu is the follower a connection crossing kept alive:
|
||||
@@ -410,7 +422,8 @@ end
|
||||
-- (pikachu_follow.asm keeps it one walk step behind)
|
||||
function PikachuFollower.update(game, ow)
|
||||
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
|
||||
if ow.pikachuBillsScene then return end
|
||||
if ow.pikachuBillsScene or ow.pikachuFanClubScene
|
||||
or ow.pikachuPewterSleepScene then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then
|
||||
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
|
||||
@@ -713,6 +726,13 @@ function PikachuFollower.talk(game, ow, npc, done)
|
||||
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
|
||||
local save = game.save
|
||||
local emotion = selectEmotion(game, ow, save)
|
||||
if ow.pikachuPewterSleepScene then
|
||||
local finish = done
|
||||
done = function()
|
||||
ow.pikachuPewterSleepScene = nil
|
||||
if finish then finish() end
|
||||
end
|
||||
end
|
||||
local e = EMOTIONS[emotion] or EMOTIONS[1]
|
||||
if e.turnAway then
|
||||
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
|
||||
@@ -798,6 +818,28 @@ local function movePikachu(ow, npc, steps, onDone)
|
||||
nextStep(1)
|
||||
end
|
||||
|
||||
function PikachuFollower.onFanClubEntered(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map
|
||||
and ow.map.id == "POKEMON_FAN_CLUB") then return end
|
||||
local starter = PikachuFollower.starterInParty(game.save)
|
||||
local npc = findFollower(ow)
|
||||
if not npc or (starter and starter.status) then return end
|
||||
ow.pikachuFanClubScene = true
|
||||
ow.pikachuMapScriptActive = true
|
||||
ow.player.facing = "down"
|
||||
for _, other in ipairs(ow.npcs or {}) do
|
||||
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
|
||||
other.movementStatus = 2
|
||||
other.facing = "down"
|
||||
break
|
||||
end
|
||||
end
|
||||
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
|
||||
movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function()
|
||||
npc.facing = "up"
|
||||
end)
|
||||
end
|
||||
|
||||
function PikachuFollower.onBillsHouseEnter(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
|
||||
return
|
||||
|
||||
+3
-73
@@ -6,8 +6,8 @@
|
||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Assets = require("src.render.Assets")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
@@ -15,59 +15,6 @@ local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local overviewShades = {}
|
||||
|
||||
Assets.register(function() overviewShades = {} end)
|
||||
|
||||
local function shadeDigit(sum, pixelCount)
|
||||
return tostring(math.max(0, math.min(3,
|
||||
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
|
||||
end
|
||||
|
||||
local function mapTileRows(map)
|
||||
local tileset = map.tileset
|
||||
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
||||
local cached = overviewShades[tileset.image]
|
||||
if not cached then
|
||||
local ok, pixels = pcall(Assets.imageData, tileset.image)
|
||||
if not ok then return nil end
|
||||
cached = { pixels = pixels, shades = {} }
|
||||
overviewShades[tileset.image] = cached
|
||||
end
|
||||
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
|
||||
for ty = 0, map.heightCells * 2 - 1 do
|
||||
local row, detailTop, detailBottom = {}, {}, {}
|
||||
for tx = 0, map.widthCells * 2 - 1 do
|
||||
local tile = map:tileAt(tx, ty)
|
||||
local shades = cached.shades[tile]
|
||||
if shades == nil then
|
||||
local sums = { 0, 0, 0, 0 }
|
||||
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
||||
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
|
||||
sums[quadrant] = sums[quadrant]
|
||||
+ r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
end
|
||||
end
|
||||
shades = {
|
||||
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
|
||||
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
|
||||
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
|
||||
}
|
||||
cached.shades[tile] = shades
|
||||
end
|
||||
row[#row + 1] = shades[1]
|
||||
detailTop[#detailTop + 1] = shades[2] .. shades[3]
|
||||
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
detailRows[#detailRows + 1] = table.concat(detailTop)
|
||||
detailRows[#detailRows + 1] = table.concat(detailBottom)
|
||||
end
|
||||
return rows, detailRows
|
||||
end
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
local stack = game and game.stack
|
||||
@@ -147,16 +94,7 @@ end
|
||||
function WorldAPI:mapOverview()
|
||||
local ow = self:overworld()
|
||||
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||
local map, rows, markers = ow.map, {}, {}
|
||||
for y = 0, map.heightCells - 1 do
|
||||
local row = {}
|
||||
for x = 0, map.widthCells - 1 do
|
||||
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
|
||||
or map:isWaterCell(x, y) and "~"
|
||||
or map:isWalkableCell(x, y) and "." or " "
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
end
|
||||
local map, markers = ow.map, {}
|
||||
local def = map.def or {}
|
||||
for _, warp in ipairs(def.warps or {}) do
|
||||
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
|
||||
@@ -175,15 +113,7 @@ function WorldAPI:mapOverview()
|
||||
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
|
||||
end
|
||||
end
|
||||
local tileRows, tileDetailRows = mapTileRows(map)
|
||||
return { mapId = map.id, width = map.widthCells,
|
||||
height = map.heightCells, rows = rows, markers = markers,
|
||||
tileRows = tileRows,
|
||||
tileWidth = tileRows and map.widthCells * 2,
|
||||
tileHeight = tileRows and map.heightCells * 2,
|
||||
tileDetailRows = tileDetailRows,
|
||||
tileDetailWidth = tileDetailRows and map.widthCells * 4,
|
||||
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
|
||||
return MapOverview.build(map, markers)
|
||||
end
|
||||
|
||||
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
||||
|
||||
@@ -3946,7 +3946,8 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
|
||||
local ctx = {
|
||||
mapId = map and map.id,
|
||||
terrain = terrain,
|
||||
rng = love.math.random,
|
||||
-- Same guard World:rockRandom uses: a headless suite has no love global.
|
||||
rng = (love and love.math and love.math.random) or math.random,
|
||||
kind = kind,
|
||||
daytime = self.daytime,
|
||||
environment = map and map.def and map.def.environment,
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
local Logger = require("src.core.Logger")
|
||||
local Movement = require("src.script.gen2.Movement")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
|
||||
local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
@@ -50,6 +52,31 @@ function WorldAPI:current()
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
-- The same read-only minimap contract as Gen 1, with Gold's object/event
|
||||
-- visibility rules supplying the semantic markers.
|
||||
function WorldAPI:mapOverview()
|
||||
local world = self:overworld()
|
||||
if not world or not world.map then return nil, NO_OVERWORLD end
|
||||
local map, def, markers = world.map, world.map.def or {}, {}
|
||||
for _, warp in ipairs(def.warps or {}) do
|
||||
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
|
||||
end
|
||||
local visible = {}
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def then visible[npc.def] = true end
|
||||
end
|
||||
for _, obj in ipairs(def.objects or {}) do
|
||||
local item = obj.itemball and obj.itemball.item
|
||||
if item and item ~= "0" and item ~= 0 and visible[obj] then
|
||||
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
|
||||
end
|
||||
end
|
||||
for _, item in ipairs(HiddenItems.unfound(def, world.events)) do
|
||||
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
|
||||
end
|
||||
return MapOverview.build(map, markers)
|
||||
end
|
||||
|
||||
-- opts is accepted for signature parity with the Gen 1 arm; Gold's arrival FX
|
||||
-- come from the map setup method, so opts.arrive has nothing to select yet.
|
||||
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- #1091: does GAME SPEED change the wild encounter rate per STEP?
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1091_test.lua love .
|
||||
local Encounter = require("src.battle.gen2.Encounter")
|
||||
local Player = require("src.world.gen2.Player")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local rolls, hits, steps = 0, 0, 0
|
||||
|
||||
local realTriggers = Encounter.triggers
|
||||
Encounter.triggers = function(rate, random)
|
||||
rolls = rolls + 1
|
||||
if realTriggers(rate, random) then hits = hits + 1 end
|
||||
return false
|
||||
end
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
local ticks = 0
|
||||
local realWorldStep = World.step
|
||||
World.step = function(self)
|
||||
ticks = ticks + 1
|
||||
return realWorldStep(self)
|
||||
end
|
||||
|
||||
local realUpdate = Player.update
|
||||
Player.update = function(self)
|
||||
local landed = realUpdate(self)
|
||||
if landed then steps = steps + 1 end
|
||||
return landed
|
||||
end
|
||||
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function clearDirs()
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
game.input.state[d] = false
|
||||
end
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
|
||||
local function measure(speed, yields)
|
||||
world:setMap("ROUTE_29", 44, 12, "right")
|
||||
wait(20)
|
||||
world.wildCooldown = 0
|
||||
game.speedOverride = speed
|
||||
rolls, hits, steps, ticks = 0, 0, 0, 0
|
||||
local dir = "right"
|
||||
for i = 1, yields do
|
||||
if i % 8 == 0 then dir = (dir == "right") and "left" or "right" end
|
||||
clearDirs()
|
||||
game.input.state[dir] = true
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
coroutine.yield()
|
||||
end
|
||||
clearDirs()
|
||||
game.speedOverride = 1
|
||||
wait(5)
|
||||
return rolls, hits, steps, ticks
|
||||
end
|
||||
|
||||
local r1, h1, s1, t1 = measure(1, 900)
|
||||
local r2, h2, s2, t2 = measure(200, 60)
|
||||
print(("[driver] 1X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
|
||||
:format(t1, s1, r1, h1, s1 > 0 and r1 / s1 or 0))
|
||||
print(("[driver] 200X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
|
||||
:format(t2, s2, r2, h2, s2 > 0 and r2 / s2 or 0))
|
||||
|
||||
Encounter.triggers = realTriggers
|
||||
Player.update = realUpdate
|
||||
World.step = realWorldStep
|
||||
local rate1 = s1 > 0 and r1 / s1 or 0
|
||||
local rate2 = s2 > 0 and r2 / s2 or 0
|
||||
assert(s1 > 20 and s2 > 20, "not enough walked cells to judge")
|
||||
assert(math.abs(rate1 - rate2) < 0.05,
|
||||
("rolls per cell changed with GAME SPEED: %.3f vs %.3f"):format(rate1, rate2))
|
||||
print("[driver] PASS encounter rolls are one per walked cell at every speed")
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
-- #1097 (and the absorbed #1116): walking out of Dark Cave and pressing UP
|
||||
-- puts the player inside the mountain on Route 31.
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1097_test.lua love .
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function clearDirs()
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
game.input.state[d] = false
|
||||
end
|
||||
end
|
||||
local function hold(dir, frames, trace)
|
||||
local last
|
||||
for _ = 1, frames do
|
||||
clearDirs()
|
||||
game.input.state[dir] = true
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
coroutine.yield()
|
||||
local w = game.world
|
||||
local at = ("%s (%d,%d)"):format(w.map.id, w.player.cellX, w.player.cellY)
|
||||
if trace and at ~= last then print(" " .. at); last = at end
|
||||
end
|
||||
clearDirs()
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
|
||||
-- Route31CheckMomCallCallback (maps/Route31.asm:14) fires on every NEWMAP
|
||||
-- arrival until the errand is over; set its event so the walk is not spent
|
||||
-- behind a phone call. tests/drivers/gold/flag_names.lua:1132.
|
||||
if world.events then world.events:set(64, true) end
|
||||
|
||||
-- maps/DarkCaveVioletEntrance.asm warp 1 is (3,15) -> ROUTE_31 warp 3.
|
||||
world:setMap("DARK_CAVE_VIOLET_ENTRANCE", 3, 14, "down")
|
||||
wait(20)
|
||||
hold("down", 40)
|
||||
-- the ROUTE_31 arrival runs Route31CheckMomCallCallback (maps/Route31.asm:14)
|
||||
for _ = 1, 900 do
|
||||
if not world:busy() then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
wait(20)
|
||||
print(("[driver] out of the cave at %s (%d,%d)")
|
||||
:format(world.map.id, world.player.cellX, world.player.cellY))
|
||||
assert(world.map.id == "ROUTE_31", "did not reach ROUTE_31")
|
||||
|
||||
print("[driver] holding UP:")
|
||||
hold("up", 120, true)
|
||||
wait(30)
|
||||
local m, x, y = world.map.id, world.player.cellX, world.player.cellY
|
||||
print(("[driver] ended at %s (%d,%d)"):format(m, x, y))
|
||||
-- Anything above y=5 on Route 31 in that column is the inside of the
|
||||
-- mountain; the honest outcomes are re-entering the cave or bumping.
|
||||
local inside = (m == "ROUTE_31" and y < 5)
|
||||
assert(not inside, ("walked into the mountain at (%d,%d)"):format(x, y))
|
||||
-- The other two Dark Cave mouths, same shape: maps/Route46.asm warp 3 is
|
||||
-- (14,5) and maps/Route45.asm warp 1 is (2,5), both COLL_CAVE tiles with
|
||||
-- ordinary FLOOR above them in the collision data.
|
||||
for _, c in ipairs({ { "ROUTE_46", 14, 5 }, { "ROUTE_45", 2, 5 } }) do
|
||||
local mapId, wx, wy = c[1], c[2], c[3]
|
||||
world:setMap(mapId, wx, wy, "up")
|
||||
wait(20)
|
||||
hold("up", 90)
|
||||
wait(20)
|
||||
if world.map.id == mapId then
|
||||
assert(world.player.cellY >= wy,
|
||||
("%s: walked above the cave mouth to (%d,%d)")
|
||||
:format(mapId, world.player.cellX, world.player.cellY))
|
||||
end
|
||||
print(("[driver] %s mouth ended at %s (%d,%d)"):format(
|
||||
mapId, world.map.id, world.player.cellX, world.player.cellY))
|
||||
end
|
||||
|
||||
print("[driver] PASS the cave mouth cannot be walked through")
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- #1104: the Ruins of Alph ladders warp somewhere else.
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1104_test.lua love .
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- maps/RuinsOfAlphInnerChamber.asm:76, maps/RuinsOfAlphOutside.asm:126,
|
||||
-- maps/RuinsOfAlphKabutoChamber.asm and its three siblings.
|
||||
local CASES = {
|
||||
{ "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13, "RUINS_OF_ALPH_OUTSIDE", 10, 13 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 10, 13, "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 2, 17, "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 14, 7, "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 2, 29, "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 16, 33, "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 17, 11, "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7 },
|
||||
{ "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 14, 7, "down" },
|
||||
{ "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 17, "down" },
|
||||
{ "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 29, "down" },
|
||||
{ "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 16, 33, "down" },
|
||||
{ "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7, "RUINS_OF_ALPH_OUTSIDE", 17, 11, "down" },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function clearDirs()
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
game.input.state[d] = false
|
||||
end
|
||||
end
|
||||
-- Hold until the map changes, then let the load settle and STOP: holding
|
||||
-- past the arrival would walk the player off the landing tile.
|
||||
local function walkUntilWarp(dir, frames, fromMap)
|
||||
for _ = 1, frames do
|
||||
clearDirs()
|
||||
game.input.state[dir] = true
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
coroutine.yield()
|
||||
if game.world.map and game.world.map.id ~= fromMap then
|
||||
clearDirs()
|
||||
-- The ARRIVAL cell is the one the warp table names. Standing on a
|
||||
-- door/cave/staircase tile then forces one step DOWN off it
|
||||
-- (engine/overworld/player_movement.asm:201-213), so the settled cell
|
||||
-- is legitimately one row lower on an outdoor mouth.
|
||||
local ax, ay = game.world.player.cellX, game.world.player.cellY
|
||||
for _ = 1, 45 do coroutine.yield() end
|
||||
return game.world.map.id, ax, ay,
|
||||
game.world.player.cellX, game.world.player.cellY
|
||||
end
|
||||
end
|
||||
clearDirs()
|
||||
return game.world.map and game.world.map.id, nil, nil
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
|
||||
local bad = 0
|
||||
for _, c in ipairs(CASES) do
|
||||
local src, wx, wy, wantMap, wantX, wantY = c[1], c[2], c[3], c[4], c[5], c[6]
|
||||
-- default approach is from below, the way the report describes facing the
|
||||
-- ladder from below; a chamber's exit sits on the bottom row instead
|
||||
local dir = c[7] or "up"
|
||||
local sy = (dir == "up") and wy + 1 or wy - 1
|
||||
world:setMap(src, wx, sy, dir)
|
||||
wait(12)
|
||||
local from = ("%s (%d,%d)"):format(src, world.player.cellX, world.player.cellY)
|
||||
local m, ax, ay, sx, sy = walkUntilWarp(dir, 90, src)
|
||||
local ok = (m == wantMap and ax == wantX and ay == wantY)
|
||||
if not ok then bad = bad + 1 end
|
||||
print(("[driver] %-12s %s -> %s (%s,%s) settled (%s,%s) want %s (%d,%d)")
|
||||
:format(ok and "OK" or "MISMATCH", from, tostring(m),
|
||||
tostring(ax), tostring(ay), tostring(sx), tostring(sy),
|
||||
wantMap, wantX, wantY))
|
||||
end
|
||||
print(("[driver] %s ruins warps: %d mismatches")
|
||||
:format(bad == 0 and "PASS" or "FAIL", bad))
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
-- #1155: "MAGNITUDE always rolls a 4".
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1155_test.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local ROLLS = 15
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1155"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
local caster = Mon.new(game.data, "DUNSPARCE", 50)
|
||||
local def = assert(game.data.moves.MAGNITUDE, "MAGNITUDE is not in moves.lua")
|
||||
caster.moves = { { id = "MAGNITUDE", pp = def.pp, maxPp = def.pp } }
|
||||
print("[driver] MAGNITUDE stored effect = " .. tostring(def.effect)
|
||||
.. ", stored power = " .. tostring(def.power))
|
||||
game.save.party = { caster }
|
||||
|
||||
local seen, damages, shown = {}, {}, 0
|
||||
local screen
|
||||
for i = 1, ROLLS do
|
||||
if not (screen and game.stack:top() == screen and not screen.battle.over) then
|
||||
local dummy = Mon.new(game.data, "SNORLAX", 60)
|
||||
dummy.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = dummy }), "startBattle failed")
|
||||
screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
end
|
||||
local foe = screen.battle.enemy
|
||||
local before = foe.hp
|
||||
caster.moves[1].pp = def.pp
|
||||
screen:submit({ kind = "move", move = "MAGNITUDE" })
|
||||
for _, event in ipairs(screen.queue) do
|
||||
local number = event.text and event.text:match("^Magnitude (%d+)")
|
||||
if number then
|
||||
seen[number] = (seen[number] or 0) + 1
|
||||
if shown < 1 then
|
||||
U.wait(1)
|
||||
U.shot(game, out .. "/01-magnitude-text.png")
|
||||
shown = 1
|
||||
end
|
||||
end
|
||||
end
|
||||
drain(game, screen, 400)
|
||||
local dealt = before - foe.hp
|
||||
if dealt > 0 then damages[dealt] = true end
|
||||
if i % 10 == 0 then U.wait(5) end
|
||||
end
|
||||
|
||||
local numbers = {}
|
||||
for number, count in pairs(seen) do
|
||||
numbers[#numbers + 1] = number .. "x" .. count
|
||||
end
|
||||
table.sort(numbers)
|
||||
print("[driver] magnitudes over " .. ROLLS .. " uses: "
|
||||
.. table.concat(numbers, " "))
|
||||
local distinctPowers = 0
|
||||
for _ in pairs(damages) do distinctPowers = distinctPowers + 1 end
|
||||
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
check(#numbers > 1, "MAGNITUDE rolls more than one number")
|
||||
check(seen["4"] == nil or seen["4"] < ROLLS,
|
||||
"and is not stuck on MAGNITUDE 4")
|
||||
check(distinctPowers > 1, "the rolled power reaches the damage (" ..
|
||||
distinctPowers .. " distinct damage figures)")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " magnitude checks failed")
|
||||
end
|
||||
print("[driver] #1155 clear; shot in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
-- #1157: SONIC BOOM has to deal a flat 20, not a rolled 8-10.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1157_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1157"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
|
||||
-- The reporter's own matchup, three times over.
|
||||
local caster = Mon.new(game.data, "MAGNEMITE", 20)
|
||||
local boom = assert(game.data.moves.SONICBOOM, "SONICBOOM missing")
|
||||
print("[driver] SONICBOOM effect = " .. tostring(boom.effect)
|
||||
.. ", stored power = " .. tostring(boom.power))
|
||||
caster.moves = { { id = "SONICBOOM", pp = boom.pp, maxPp = boom.pp } }
|
||||
game.save.party = { caster }
|
||||
|
||||
-- SONIC BOOM is a 90% move, so a miss (0) is legal and simply does not
|
||||
-- count: what matters is that every use that CONNECTS is 20.
|
||||
local dealt, hits, wrong = {}, 0, 0
|
||||
for i = 1, 6 do
|
||||
local rattata = Mon.new(game.data, "RATTATA", 11)
|
||||
rattata.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = rattata }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
local before = rattata.hp
|
||||
caster.hp = caster.maxHp or caster.hp
|
||||
caster.moves[1].pp = boom.pp
|
||||
screen:submit({ kind = "move", move = "SONICBOOM" })
|
||||
drain(game, screen, 400)
|
||||
dealt[i] = before - rattata.hp
|
||||
if dealt[i] > 0 then
|
||||
hits = hits + 1
|
||||
if dealt[i] ~= 20 then wrong = wrong + 1 end
|
||||
end
|
||||
if i == 1 then U.shot(game, out .. "/01-sonicboom.png") end
|
||||
for _ = 1, 400 do
|
||||
if not game.stack:top() or not game.stack:top().battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.wait(45)
|
||||
end
|
||||
print("[driver] SONIC BOOM dealt " .. table.concat(dealt, ", ")
|
||||
.. " (0 is a miss)")
|
||||
check(hits >= 3, "SONIC BOOM connected at least three times")
|
||||
check(wrong == 0, "and every hit was exactly 20")
|
||||
|
||||
-- The two siblings on the same command.
|
||||
local dragon = Mon.new(game.data, "DRATINI", 25)
|
||||
local rage = assert(game.data.moves.DRAGON_RAGE, "DRAGON_RAGE missing")
|
||||
local toss = assert(game.data.moves.SEISMIC_TOSS, "SEISMIC_TOSS missing")
|
||||
dragon.moves = {
|
||||
{ id = "DRAGON_RAGE", pp = rage.pp, maxPp = rage.pp },
|
||||
{ id = "SEISMIC_TOSS", pp = toss.pp, maxPp = toss.pp },
|
||||
}
|
||||
game.save.party = { dragon }
|
||||
local target = Mon.new(game.data, "SNORLAX", 60)
|
||||
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = target }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
local before = target.hp
|
||||
screen:submit({ kind = "move", move = "DRAGON_RAGE" })
|
||||
drain(game, screen, 400)
|
||||
local rageDealt = before - target.hp
|
||||
before = target.hp
|
||||
screen:submit({ kind = "move", move = "SEISMIC_TOSS" })
|
||||
drain(game, screen, 400)
|
||||
local tossDealt = before - target.hp
|
||||
U.shot(game, out .. "/02-siblings.png")
|
||||
print(("[driver] DRAGON RAGE %d, SEISMIC TOSS %d (user is L%d)")
|
||||
:format(rageDealt, tossDealt, dragon.level))
|
||||
check(rageDealt == 40, "DRAGON RAGE deals exactly 40")
|
||||
check(tossDealt == dragon.level, "SEISMIC TOSS deals the user's level")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " constant-damage checks failed")
|
||||
end
|
||||
print("[driver] #1157 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
-- #1158: you could not see the trainer's incoming mon before the switch offer.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1158_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function runToPhase(game, screen, phase, frames)
|
||||
for _ = 1, (frames or 600) do
|
||||
if screen.phase == phase then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1158"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
|
||||
-- SHIFT is the only style that offers at all (CheckWhetherToAskSwitch).
|
||||
game.options = game.options or {}
|
||||
game.options.battleStyle = "SHIFT"
|
||||
|
||||
local lead = Mon.new(game.data, "TYPHLOSION", 40)
|
||||
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local bench = Mon.new(game.data, "FERALIGATR", 40)
|
||||
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
game.save.party = { lead, bench }
|
||||
|
||||
local foe1 = Mon.new(game.data, "PIDGEY", 5)
|
||||
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local foe2 = Mon.new(game.data, "PIDGEOTTO", 20)
|
||||
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
assert(world:startBattle({ trainer = { class = "FALKNER", name = "FALKNER",
|
||||
party = { foe1, foe2 } } }), "trainer startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
runToPhase(game, screen, "menu", 400)
|
||||
|
||||
foe1.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
|
||||
-- Page by page, collecting what the box actually showed.
|
||||
local pages, sawBoxWithName = {}, false
|
||||
local incoming = foe2.nickname or foe2.species or "PIDGEOTTO"
|
||||
for _ = 1, 900 do
|
||||
local message = screen.message
|
||||
if message and pages[#pages] ~= message
|
||||
and (screen.phase == "shift-intro" or screen.phase == "ask-shift") then
|
||||
pages[#pages + 1] = message
|
||||
if #pages == 1 then U.shot(game, out .. "/01-first-page.png") end
|
||||
if message:find(incoming, 1, true) then
|
||||
U.shot(game, out .. "/02-names-the-mon.png")
|
||||
sawBoxWithName = true
|
||||
end
|
||||
end
|
||||
if screen.phase == "ask-shift" and (screen.messageTimer or 0) <= 0 then
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
U.shot(game, out .. "/03-yes-no.png")
|
||||
|
||||
for i, page in ipairs(pages) do
|
||||
print(("[driver] page %d: %s"):format(i, (page:gsub("\n", " / "))))
|
||||
end
|
||||
check(sawBoxWithName,
|
||||
"the incoming " .. incoming .. " is named in the box before the yes/no")
|
||||
check(screen.phase == "ask-shift",
|
||||
"and the prompt still ends on the YES/NO question")
|
||||
check(#pages > 1, "the offer runs as pages, not one over-long line")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " send-out order checks failed")
|
||||
end
|
||||
print("[driver] #1158 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,131 @@
|
||||
-- #1159 (Fly / Dig leave the user on the field) and #1160 (64x64 front pics).
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1159_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- One drawPic call with love.graphics.draw intercepted: how many blits it made
|
||||
-- and where the first one landed.
|
||||
local function probePic(screen, mon, back)
|
||||
local G = love.graphics
|
||||
local real = G.draw
|
||||
local count, x, y = 0, nil, nil
|
||||
G.draw = function(image, a, b, ...)
|
||||
count = count + 1
|
||||
if count == 1 then
|
||||
-- draw(image, x, y, ...) or draw(image, quad, x, y, ...)
|
||||
if type(a) == "number" then x, y = a, b else x, y = b, (...) end
|
||||
end
|
||||
end
|
||||
local ok, err = pcall(screen.drawPic, screen, mon, back)
|
||||
G.draw = real
|
||||
if not ok then error(err) end
|
||||
return count, x, y
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1159"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------ #1159
|
||||
local digger = Mon.new(game.data, "SANDSHREW", 30)
|
||||
local dig = assert(game.data.moves.DIG, "DIG missing from the Gold cache")
|
||||
digger.moves = { { id = "DIG", pp = dig.pp, maxPp = dig.pp } }
|
||||
game.save.party = { digger }
|
||||
local target = Mon.new(game.data, "SNORLAX", 40)
|
||||
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = target }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
|
||||
local before = probePic(screen, screen.battle.player, true)
|
||||
check(before > 0, "the back pic is on the field before DIG")
|
||||
U.shot(game, out .. "/01-before-dig.png")
|
||||
|
||||
screen:submit({ kind = "move", move = "DIG" })
|
||||
drain(game, screen, 600)
|
||||
|
||||
check(BattleState.isVanished(screen.battle.player) == true,
|
||||
"SUBSTATUS_UNDERGROUND is set after the charge turn")
|
||||
local underground = probePic(screen, screen.battle.player, true)
|
||||
print("[driver] back-pic draws while underground: " .. underground)
|
||||
check(underground == 0, "the back pic is GONE for the charge turn")
|
||||
U.shot(game, out .. "/02-underground.png")
|
||||
|
||||
-- The enemy front pic is untouched by the player's own dig.
|
||||
local foe = probePic(screen, screen.battle.enemy, false)
|
||||
check(foe > 0, "the enemy front pic is unaffected")
|
||||
|
||||
-- Turn two: CheckCharge clears the bit and AppearUserRaiseSub redraws it.
|
||||
screen:submit({ kind = "move", move = "DIG" })
|
||||
drain(game, screen, 600)
|
||||
check(BattleState.isVanished(screen.battle.player) == false,
|
||||
"the substatus is cleared when the stored attack lands")
|
||||
local after = probePic(screen, screen.battle.player, true)
|
||||
print("[driver] back-pic draws after the attack: " .. after)
|
||||
check(after > 0, "the back pic is back on the field")
|
||||
U.shot(game, out .. "/03-resurfaced.png")
|
||||
|
||||
------------------------------------------------------------------ #1160
|
||||
-- A stubbed pic of each size through the same placement code. trueColor is
|
||||
-- set so the probe never enters the GBC palette shader.
|
||||
local realPic = screen.pic
|
||||
local function place(size, back)
|
||||
local data = love.image.newImageData(size, size)
|
||||
local image = love.graphics.newImage(data)
|
||||
screen.pic = function() return image, true, "mods/front" .. size .. ".png" end
|
||||
local n, x, y = probePic(screen, screen.battle.enemy, back)
|
||||
screen.pic = realPic
|
||||
return n, x, y
|
||||
end
|
||||
|
||||
local _, x56, y56 = place(56, false)
|
||||
print(("[driver] 56x56 front at (%s, %s)"):format(tostring(x56), tostring(y56)))
|
||||
check(x56 == 96 and y56 == 0, "vanilla 56x56 front pic is unmoved at (96, 0)")
|
||||
|
||||
local _, x40, y40 = place(40, false)
|
||||
print(("[driver] 40x40 front at (%s, %s)"):format(tostring(x40), tostring(y40)))
|
||||
check(x40 == 104 and y40 == 16,
|
||||
"a small 40x40 front pic still stands on the box's ground line")
|
||||
|
||||
local _, x64, y64 = place(64, false)
|
||||
print(("[driver] 64x64 front at (%s, %s)"):format(tostring(x64), tostring(y64)))
|
||||
check(x64 == 96 and y64 == 0,
|
||||
"a 64x64 front pic pins to the box corner and overlaps the HUD")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " checks failed")
|
||||
end
|
||||
print("[driver] #1159 / #1160 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
-- #1167: the Ilex Forest hidden Ether and hidden Super Potion.
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1167_test.lua love .
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
|
||||
local CASES = {
|
||||
{ name = "ETHER", x = 27, y = 1, event = 136 },
|
||||
{ name = "SUPER_POTION", x = 17, y = 7, event = 137 },
|
||||
{ name = "FULL_HEAL", x = 9, y = 17, event = 138 },
|
||||
}
|
||||
|
||||
local FACE = {
|
||||
up = { 0, 1 }, down = { 0, -1 }, left = { 1, 0 }, right = { -1, 0 },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function tap(btn)
|
||||
table.insert(game.input.pressQueue, btn)
|
||||
game.input.state[btn] = true
|
||||
coroutine.yield()
|
||||
game.input.state[btn] = false
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
world:setMap("ILEX_FOREST", 9, 18, "up")
|
||||
wait(30)
|
||||
assert(world.map.id == "ILEX_FOREST", "not in ILEX_FOREST")
|
||||
|
||||
-- what the engine itself thinks it has for this map
|
||||
local listed = HiddenItems.unfound(world.map.def, world.events)
|
||||
print("[driver] engine hidden items on ILEX_FOREST:")
|
||||
for _, row in ipairs(listed) do
|
||||
print((" (%2d,%2d) item=%s flag=%d")
|
||||
:format(row.x, row.y, tostring(row.item), row.event))
|
||||
end
|
||||
|
||||
local function bagCount(name)
|
||||
local n = 0
|
||||
for _, row in ipairs((game.save.bag and game.save.bag.items) or {}) do
|
||||
if row.item == name or row.id == name then n = n + (row.count or 1) end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local bad = 0
|
||||
for _, c in ipairs(CASES) do
|
||||
local got = false
|
||||
for dir, d in pairs(FACE) do
|
||||
local sx, sy = c.x + d[1], c.y + d[2]
|
||||
if world.map:isWalkable(sx, sy) then
|
||||
world:setMap("ILEX_FOREST", sx, sy, dir)
|
||||
wait(20)
|
||||
world.player.facing = dir
|
||||
local before = bagCount(c.name)
|
||||
tap("a")
|
||||
wait(60)
|
||||
for _ = 1, 240 do
|
||||
if not world:busy() then break end
|
||||
tap("a")
|
||||
end
|
||||
wait(20)
|
||||
if world.events:get(c.event) or bagCount(c.name) > before then
|
||||
got = true
|
||||
print(("[driver] OK %-13s at (%2d,%2d) picked up facing %s")
|
||||
:format(c.name, c.x, c.y, dir))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not got then
|
||||
bad = bad + 1
|
||||
print(("[driver] MISSING %-13s at (%2d,%2d)"):format(c.name, c.x, c.y))
|
||||
end
|
||||
end
|
||||
print(("[driver] %s ilex hidden items: %d missing")
|
||||
:format(bad == 0 and "PASS" or "FAIL", bad))
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
-- #1208: the launcher's save-slot line read a Gold save with Gen 1 eyes
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1208_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GoldSave = require("src.core.gen2.Save")
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local save = game.save
|
||||
assert(save and save.player, "gold save did not boot")
|
||||
|
||||
save.player.badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
|
||||
STORM = true, MINERAL = true, GLACIER = true, RISING = true }
|
||||
save.player.kantoBadges = { BOULDER = true, CASCADE = true }
|
||||
save.pokedex = save.pokedex or {}
|
||||
save.pokedex.caught = { [155] = true, [158] = true, [152] = true }
|
||||
|
||||
local ok, err = game:writeSave()
|
||||
if not ok then
|
||||
U.log("FAIL gold writeSave:", tostring(err))
|
||||
else
|
||||
local want = GoldSave.summary(save)
|
||||
local active = SaveData.activeSlot("gold")
|
||||
local row
|
||||
for _, slot in ipairs(SaveData.listSlots("gold")) do
|
||||
if slot.id == active then row = slot end
|
||||
end
|
||||
if not (row and row.meta) then
|
||||
U.log("FAIL launcher has no slot row for", tostring(active))
|
||||
else
|
||||
U.log(("launcher: %d badges - %s - %d caught"):format(
|
||||
row.meta.badges, row.meta.timeText, row.meta.dexCount))
|
||||
U.log(("continue: %d badges - %d:%02d - %d caught"):format(
|
||||
want.badges, want.hours, want.minutes, want.caught))
|
||||
if row.meta.badges == want.badges and row.meta.dexCount == want.caught then
|
||||
U.log("PASS launcher summary matches CONTINUE for slot", tostring(active))
|
||||
else
|
||||
U.log("FAIL launcher summary disagrees with CONTINUE")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
-- GameActivity exposes an optional Android host seam without requiring any
|
||||
-- host implementation (or its native libraries) in the stock build.
|
||||
-- Self-contained: luajit tests/engine/android_host_extension_test.lua
|
||||
local path = "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java"
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local source = file:read("*a")
|
||||
file:close()
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local function position(text)
|
||||
local p = source:find(text, 1, true)
|
||||
check(p, "missing: " .. text)
|
||||
return p
|
||||
end
|
||||
|
||||
check(source:find("protected String[] getHostLibraries()", 1, true),
|
||||
"host libraries are an overridable protected extension")
|
||||
check(source:find("return new String[0];", 1, true),
|
||||
"the vanilla host library list is empty")
|
||||
|
||||
local cpp = position('libraries[0] = "c++_shared";')
|
||||
local mpg = position('libraries[1] = "mpg123";')
|
||||
local openal = position('libraries[2] = "openal";')
|
||||
local host = position("System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);")
|
||||
local love = position('libraries[libraries.length - 1] = "love";')
|
||||
check(cpp < mpg and mpg < openal and openal < host and host < love,
|
||||
"optional libraries load after dependencies while liblove remains last")
|
||||
|
||||
for _, hook in ipairs({
|
||||
"onHostCreateBeforeSDL", "onHostCreateAfterSDL", "onHostResume",
|
||||
"onHostPause", "onHostDestroy",
|
||||
}) do
|
||||
check(source:find("protected void " .. hook, 1, true),
|
||||
hook .. " is a protected extension hook")
|
||||
end
|
||||
|
||||
check(position("onHostCreateBeforeSDL(savedInstanceState);") <
|
||||
position("super.onCreate(savedInstanceState);") and
|
||||
position("super.onCreate(savedInstanceState);") <
|
||||
position("onHostCreateAfterSDL(savedInstanceState);"),
|
||||
"create hooks bracket SDL creation")
|
||||
check(position("super.onResume();") < position("onHostResume();"),
|
||||
"resume hook runs after SDL resumes")
|
||||
check(position("onHostPause();") < position("super.onPause();"),
|
||||
"pause hook runs before SDL pauses")
|
||||
check(position("onHostDestroy();") < position("super.onDestroy();"),
|
||||
"destroy hook runs before SDL destruction")
|
||||
|
||||
check(not source:lower():find("openxr", 1, true),
|
||||
"generic Android activity must not require OpenXR")
|
||||
check(not source:find("QuestActivity", 1, true) and
|
||||
not source:find("QuestBridge", 1, true),
|
||||
"generic Android activity must not require Quest classes")
|
||||
|
||||
print("android_host_extension_test: ok")
|
||||
@@ -0,0 +1,82 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||
local Game2 = require("src.core.Game2")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local EvolutionAnim = require("src.ui.gen2.EvolutionAnim")
|
||||
|
||||
local data = {
|
||||
pokemon = {
|
||||
SUNKERN = {
|
||||
evolutions = {
|
||||
{ method = "EVOLVE_ITEM", item = "SUN_STONE", into = "SUNFLORA" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
local mon = { species = "SUNKERN", item = nil }
|
||||
|
||||
T.eq(ItemEffects.partyAction("SUN_STONE"), "stone",
|
||||
"SUN STONE opens the party target flow")
|
||||
local result = ItemEffects.useOnMon("SUN_STONE", mon, data)
|
||||
T.check(result.used, "SUN STONE succeeds on SUNKERN")
|
||||
T.eq(result.evolution and result.evolution.into, "SUNFLORA",
|
||||
"SUN STONE selects SUNFLORA")
|
||||
|
||||
mon.item = "EVERSTONE"
|
||||
result = ItemEffects.useOnMon("SUN_STONE", mon, data)
|
||||
T.check(not result.used, "held EVERSTONE refuses the evolution")
|
||||
|
||||
local function startStone()
|
||||
local partyOptions, evolutionOptions
|
||||
local game = setmetatable({
|
||||
data = {
|
||||
pokemon = data.pokemon,
|
||||
screens = {
|
||||
Gen2PartyMenu = function(_, options)
|
||||
partyOptions = options
|
||||
return {}
|
||||
end,
|
||||
Gen2EvolutionAnim = function(_, options)
|
||||
evolutionOptions = options
|
||||
return {}
|
||||
end,
|
||||
},
|
||||
},
|
||||
save = {
|
||||
party = { { species = "SUNKERN", item = nil } },
|
||||
inventory = { SUN_STONE = 1 },
|
||||
},
|
||||
stack = { pop = function() end, push = function() end },
|
||||
}, Game2)
|
||||
Screens.invalidate()
|
||||
game:usePartyItem("SUN_STONE")
|
||||
partyOptions.onChoose(nil, game.save.party[1])
|
||||
return game, evolutionOptions
|
||||
end
|
||||
|
||||
local game, evolution = startStone()
|
||||
T.check(evolution.force, "SUN STONE evolution sets wForceEvolution")
|
||||
T.eq(game.save.inventory.SUN_STONE, 1,
|
||||
"SUN STONE remains until evolution succeeds")
|
||||
|
||||
local animation = EvolutionAnim.new({ data = { pokemon = data.pokemon } }, {
|
||||
mon = game.save.party[1], entry = evolution and evolution.entry,
|
||||
force = evolution and evolution.force,
|
||||
})
|
||||
T.check(not animation:cancelPressed({ wasPressed = function(_, key)
|
||||
return key == "b"
|
||||
end }), "B cannot cancel a forced stone evolution")
|
||||
|
||||
evolution.onDone({ canceled = true })
|
||||
T.eq(game.save.inventory.SUN_STONE, 1,
|
||||
"a canceled evolution does not consume SUN STONE")
|
||||
|
||||
game, evolution = startStone()
|
||||
evolution.onDone({ evolved = { species = "SUNFLORA" } })
|
||||
T.eq(game.save.inventory.SUN_STONE, nil,
|
||||
"a completed evolution consumes SUN STONE")
|
||||
Screens.invalidate()
|
||||
|
||||
T.finish("gen2 sun stone bug 1219")
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Optional native-host display lifecycle. The default path is inert; a fake
|
||||
-- backend proves callback order and arguments without graphics or a ROM.
|
||||
-- luajit tests/engine/host_display_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
|
||||
-- Vanilla: no backend, no state requirement, and no invented return value.
|
||||
HostDisplay.setBackend(nil)
|
||||
eq(HostDisplay.update(1 / 60), nil, "default update is a no-op")
|
||||
eq(HostDisplay.beginFrame("game", {}), nil, "default beginFrame is a no-op")
|
||||
eq(HostDisplay.endFrame("game", {}), nil, "default endFrame is a no-op")
|
||||
|
||||
-- Bad installation fails at the boundary instead of producing a later frame
|
||||
-- error whose source is difficult for a native host to diagnose.
|
||||
local ok, err = pcall(HostDisplay.setBackend, function() end)
|
||||
check(not ok, "non-table backend is rejected")
|
||||
check(tostring(err):find("table or nil", 1, true) ~= nil,
|
||||
"backend type error explains the accepted shape")
|
||||
|
||||
local calls = {}
|
||||
local subject = { tag = "launcher-instance" }
|
||||
local fake = {}
|
||||
function fake:update(dt)
|
||||
calls[#calls + 1] = { "update", self, dt }
|
||||
return "updated"
|
||||
end
|
||||
function fake:beginFrame(kind, gotSubject)
|
||||
calls[#calls + 1] = { "begin", self, kind, gotSubject }
|
||||
return "begun"
|
||||
end
|
||||
function fake:endFrame(kind, gotSubject)
|
||||
calls[#calls + 1] = { "end", self, kind, gotSubject }
|
||||
return "ended"
|
||||
end
|
||||
|
||||
HostDisplay.setBackend(fake)
|
||||
eq(HostDisplay.update(0.25), "updated", "update return is forwarded")
|
||||
eq(HostDisplay.beginFrame("launcher", subject), "begun",
|
||||
"beginFrame return is forwarded")
|
||||
eq(HostDisplay.endFrame("launcher", subject), "ended",
|
||||
"endFrame return is forwarded")
|
||||
eq(#calls, 3, "each lifecycle callback fires exactly once")
|
||||
eq(calls[1][1], "update", "update is first")
|
||||
eq(calls[1][2], fake, "update receives the backend as self")
|
||||
eq(calls[1][3], 0.25, "update receives dt")
|
||||
eq(calls[2][1], "begin", "beginFrame precedes endFrame")
|
||||
eq(calls[2][3], "launcher", "beginFrame receives the frame kind")
|
||||
eq(calls[2][4], subject, "beginFrame receives the drawn subject")
|
||||
eq(calls[3][1], "end", "endFrame is last")
|
||||
eq(calls[3][3], "launcher", "endFrame receives the frame kind")
|
||||
eq(calls[3][4], subject, "endFrame receives the drawn subject")
|
||||
|
||||
-- Every callback is optional. Replacing and clearing a backend must not retain
|
||||
-- callbacks from the old host across a restart or test process.
|
||||
local partialCalls = 0
|
||||
HostDisplay.setBackend({
|
||||
endFrame = function() partialCalls = partialCalls + 1 end,
|
||||
})
|
||||
eq(HostDisplay.update(1), nil, "missing optional update remains a no-op")
|
||||
eq(HostDisplay.beginFrame("editor", {}), nil,
|
||||
"missing optional beginFrame remains a no-op")
|
||||
HostDisplay.endFrame("editor", {})
|
||||
eq(partialCalls, 1, "present optional callback still runs")
|
||||
|
||||
HostDisplay.setBackend(nil)
|
||||
HostDisplay.endFrame("game", {})
|
||||
eq(partialCalls, 1, "clearing backend detaches old callbacks")
|
||||
|
||||
T.finish("host display")
|
||||
@@ -0,0 +1,114 @@
|
||||
-- #1100 / #1135: the launcher gear offered TOUCH PAD, VIBRATION and the
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- The rows are mobile-gated the same way OptionsMenu's are, so the suite has
|
||||
-- to look like a phone; POKEPORT_TOUCH=1 is the other way in.
|
||||
local realGetOS = love.system.getOS
|
||||
love.system.getOS = function() return "Android" end
|
||||
|
||||
local LauncherSettings = require("src.import.LauncherSettings")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local function labels(model)
|
||||
local out = {}
|
||||
for _, section in ipairs(model.sections) do
|
||||
for _, row in ipairs(section.rows) do out[#out + 1] = row.label end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function findRow(model, label)
|
||||
for _, section in ipairs(model.sections) do
|
||||
for _, row in ipairs(section.rows) do
|
||||
if row.label == label then return row end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function has(model, label)
|
||||
for _, l in ipairs(labels(model)) do
|
||||
if l == label then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local edited = 0
|
||||
local hooks = { editTouchControls = function() edited = edited + 1 end }
|
||||
|
||||
local gold = LauncherSettings.open(hooks, "gold")
|
||||
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
|
||||
check(has(gold, "VIBRATION"), "and VIBRATION")
|
||||
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
|
||||
|
||||
-- Every write has to land in the gold block: the flat keys beside it are
|
||||
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
|
||||
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
|
||||
-- rows leave them exactly as they found them.
|
||||
local flatPad = gold.opts.touchControls
|
||||
local flatBuzz = gold.opts.haptics
|
||||
|
||||
local pad = findRow(gold, "TOUCH PAD")
|
||||
local before = pad.value()
|
||||
pad.step(1)
|
||||
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
|
||||
check(type(gold.opts.gold) == "table", "into the gold block")
|
||||
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
|
||||
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
|
||||
|
||||
local buzz = findRow(gold, "VIBRATION")
|
||||
local buzzBefore = buzz.value()
|
||||
buzz.step(1)
|
||||
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
|
||||
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
|
||||
"VIBRATION stores a level the shared module knows")
|
||||
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
|
||||
|
||||
findRow(gold, "TOUCH CONTROLS").action()
|
||||
eq(edited, 1, "the editor row reaches the host hook")
|
||||
|
||||
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
|
||||
-- the flat table.
|
||||
local red = LauncherSettings.open(hooks, "red")
|
||||
check(has(red, "TOUCH PAD"), "Red still offers TOUCH PAD")
|
||||
check(has(red, "VIBRATION"), "and VIBRATION")
|
||||
check(has(red, "TOUCH CONTROLS"), "and the layout editor")
|
||||
findRow(red, "TOUCH PAD").step(1)
|
||||
eq(red.opts.touchControls.enabled, false, "writing to the flat key")
|
||||
eq(red.opts.gold, nil, "with no gold block invented")
|
||||
eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
|
||||
|
||||
-- With no hook (the standalone save editor's shape) the editor row is gone
|
||||
-- rather than dead, on both sides.
|
||||
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
|
||||
"no hook, no editor row on Gold")
|
||||
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
|
||||
"nor on Red")
|
||||
-- The Edit row hands the screen to the host, and the host has to know WHICH
|
||||
-- game's block to write the dragged layout into: TouchControlsEditor persists
|
||||
-- to opts.gold only when it was loaded with version = "gold". Source-shape
|
||||
-- checks, the same way tests/engine/touch_controls_pad_cursor_test.lua pins
|
||||
-- the handoff either side of this one.
|
||||
local function read(path)
|
||||
local f = assert(io.open(path, "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
return src
|
||||
end
|
||||
|
||||
local importerSrc = read("src/import/RomImporter.lua")
|
||||
check(importerSrc:find("self.onEditTouchControls(version)", 1, true) ~= nil,
|
||||
"the gear hands the launcher tab to the host")
|
||||
|
||||
local mainSrc = read("main.lua")
|
||||
local body = mainSrc:match("local function openTouchControlsEditor%((.-)%)")
|
||||
eq(body, "version", "main.lua's opener takes that tab")
|
||||
check(mainSrc:match("TouchEditor%.load%({%s*version = version") ~= nil,
|
||||
"and loads the editor with it, so Gold's layout lands in the gold block")
|
||||
|
||||
love.system.getOS = realGetOS
|
||||
|
||||
T.finish("launcher gold touch rows")
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Launcher modal focus routing. The immediate-mode focus ring is shared by
|
||||
-- the main launcher and its mod dialogs, so arrows and an armed confirmation
|
||||
-- must be handled before the modal key guard returns.
|
||||
-- luajit tests/engine/launcher_modal_focus_input_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local function resetFocus()
|
||||
Kit.focusId = "modal-primary"
|
||||
Kit._navQueue = nil
|
||||
Kit._activateId = nil
|
||||
end
|
||||
|
||||
local function importer(field)
|
||||
return setmetatable({ _flex = true, [field] = {} }, RomImporter)
|
||||
end
|
||||
|
||||
local modalFields = {
|
||||
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
|
||||
}
|
||||
|
||||
for _, field in ipairs(modalFields) do
|
||||
resetFocus()
|
||||
local imp = importer(field)
|
||||
imp:keypressed("right")
|
||||
eq(Kit._navQueue, "right", field .. " accepts focus navigation")
|
||||
check(imp._ringArmed, field .. " navigation arms the focus ring")
|
||||
|
||||
imp:keypressed("return")
|
||||
eq(Kit._activateId, "modal-primary",
|
||||
field .. " activates the focused modal control")
|
||||
check(imp[field] ~= nil, field .. " remains open until its button dispatches")
|
||||
end
|
||||
|
||||
-- Enter without prior focus navigation must not invent an activation. This is
|
||||
-- the vanilla compatibility contract behind the launcher's legacy Enter-to-
|
||||
-- play shortcut; a modal simply absorbs that otherwise-unhandled key.
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modConfirm")
|
||||
imp:keypressed("return")
|
||||
eq(Kit._activateId, nil, "unarmed modal Enter does not activate focus")
|
||||
check(not imp._ringArmed, "unarmed modal Enter does not arm focus")
|
||||
end
|
||||
|
||||
-- A focused text field remains exclusive. The modal fix must not move the
|
||||
-- ring when an arrow is intended for a launcher text-input state.
|
||||
do
|
||||
resetFocus()
|
||||
local imp = setmetatable({
|
||||
_flex = true,
|
||||
_findSearchFocus = true,
|
||||
findQuery = "PIKA",
|
||||
}, RomImporter)
|
||||
imp:keypressed("right")
|
||||
eq(Kit._navQueue, nil, "focused search field does not navigate the ring")
|
||||
check(not imp._ringArmed, "focused search field does not arm the ring")
|
||||
end
|
||||
|
||||
-- Escape remains owned by the existing modal close path.
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_findDetails")
|
||||
imp:keypressed("escape")
|
||||
eq(imp._findDetails, nil, "Escape closes details")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modReleaseNotes")
|
||||
imp:keypressed("escape")
|
||||
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modVersions")
|
||||
imp._modConfirm = {}
|
||||
imp:keypressed("escape")
|
||||
eq(imp._modConfirm, nil, "Escape closes confirmation")
|
||||
eq(imp._modVersions, nil, "Escape clears versions behind confirmation")
|
||||
end
|
||||
|
||||
resetFocus()
|
||||
T.finish("launcher modal focus input")
|
||||
@@ -56,10 +56,24 @@ do
|
||||
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
|
||||
check(m.aaa.enabled, "a mod with no options entry defaults to enabled")
|
||||
check(not m.bbb.enabled, "an explicit false disables the mod")
|
||||
check(m.aaa.enabledByVersion.red and m.aaa.enabledByVersion.gold,
|
||||
"every row exposes its enabled answer for each game")
|
||||
eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
|
||||
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
|
||||
end
|
||||
|
||||
do
|
||||
local manifests = {
|
||||
mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }),
|
||||
}
|
||||
local row = byId(LauncherMods.deriveList(manifests, {
|
||||
mods = { one = true }, modsByVersion = { gold = { one = false } },
|
||||
}, "gold")).one
|
||||
check(row.enabledByVersion.red, "the Red checkbox keeps the shared answer")
|
||||
check(not row.enabledByVersion.gold, "the Gold checkbox reads Gold's answer")
|
||||
check(not row.enabled, "the selected game's row state matches its checkbox")
|
||||
end
|
||||
|
||||
-- ------- experimental defaults to disabled; github surfaces on the row
|
||||
|
||||
do
|
||||
@@ -291,10 +305,9 @@ end
|
||||
|
||||
-- ------- enable flags: the panel reads exactly what the switch writes
|
||||
--
|
||||
-- One scope for both halves (SaveData.modScope). While per-game flags are a
|
||||
-- preview the shared flag is the whole answer, so a modsByVersion overlay --
|
||||
-- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can
|
||||
-- never leave the switch showing an answer no writer can reach.
|
||||
-- One scope for both halves (SaveData.modScope). Per-game flags are live, so
|
||||
-- a modsByVersion answer -- including one restored from a .g1rmodlist -- is
|
||||
-- both what the launcher shows and what the next boot reads.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
@@ -309,9 +322,8 @@ do
|
||||
}
|
||||
local planted = { mods = { one = true },
|
||||
modsByVersion = { gold = { one = false } } }
|
||||
local expected = SaveData.PER_VERSION_MODS and false or true
|
||||
eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled,
|
||||
expected, "the overlay is read exactly when a write can reach it")
|
||||
false, "the overlay is read exactly when a write can reach it")
|
||||
|
||||
-- the round trip, the thing the dead switch failed: flip it, re-derive
|
||||
local options = { mods = {} }
|
||||
|
||||
@@ -162,6 +162,32 @@ do
|
||||
"modScope follows the PER_VERSION_MODS switch")
|
||||
end
|
||||
|
||||
do
|
||||
-- First launch after per-game controls shipped: preserve every old answer
|
||||
-- and make the old implicit defaults explicit for the installed set.
|
||||
local opts = {
|
||||
mods = { old_on = true, old_off = false },
|
||||
modsByVersion = { gold = { old_on = false } },
|
||||
}
|
||||
check(SaveData.migrateModEnablement(opts, {
|
||||
{ id = "old_on" }, { id = "old_off" }, { id = "implicit" },
|
||||
{ id = "lab", experimental = true },
|
||||
}), "legacy mod state is migrated once")
|
||||
check(opts.modsByVersionMigrated, "the migration is marked complete")
|
||||
eq(SaveData.modEnabled(opts, "old_on", "red"), true,
|
||||
"an old enabled mod is enabled for Red")
|
||||
eq(SaveData.modEnabled(opts, "old_on", "gold"), false,
|
||||
"an already-stored preview answer is preserved")
|
||||
eq(SaveData.modEnabled(opts, "old_off", "blue"), false,
|
||||
"an old disabled mod stays disabled for every game")
|
||||
eq(SaveData.modEnabled(opts, "implicit", "yellow"), true,
|
||||
"an old implicit default is enabled for every game")
|
||||
eq(SaveData.modEnabled(opts, "lab", "red"), false,
|
||||
"an experimental mod keeps its existing opt-in default")
|
||||
check(not SaveData.migrateModEnablement(opts, { { id = "newer" } }),
|
||||
"a later mod install does not rerun the legacy migration")
|
||||
end
|
||||
|
||||
-- ------- a profile carries the per-game half of a setup
|
||||
|
||||
do
|
||||
|
||||
@@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil,
|
||||
|
||||
pushed[1].onDone()
|
||||
T.eq(#pushed, 1, "the farewell waits for the bow")
|
||||
T.eq(nurse.facing, "up", "image index $14: the nurse bows")
|
||||
T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows")
|
||||
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
|
||||
local hold = fakeSelf.emote or {}
|
||||
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
|
||||
@@ -79,11 +79,12 @@ if hold.onDone then hold.onDone() end
|
||||
T.eq(#pushed, 2, "the farewell follows the bow")
|
||||
local farewell = pushed[2] or {}
|
||||
T.eq(farewell.text, BYE, "second box is the farewell text")
|
||||
T.eq(nurse.facing, "up", "she is still bowed while the farewell prints")
|
||||
T.eq(nurse.frameOverride, nil, "the bow ends before the farewell prints")
|
||||
T.eq(nurse.facing, "down", "the nurse faces the player for the farewell")
|
||||
|
||||
if farewell.onDone then farewell.onDone() end
|
||||
T.eq(nurse.facing, "down", "the trailing UpdateSprites faces her back")
|
||||
T.eq(faced, 1, "she is turned back exactly once")
|
||||
T.eq(nurse.facing, "down", "the trailing UpdateSprites keeps her facing")
|
||||
T.eq(faced, 2, "she is turned back before and after the farewell")
|
||||
T.eq(finished, 1, "control returns to the player once, after the farewell")
|
||||
|
||||
-- === no nurse sprite (the Yellow/rest-stop callers): no bow, same text
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user