mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 21:46:51 +02:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d54f9a02e0 | |||
| e094b73536 | |||
| 478e3bf8eb | |||
| f009fc856c | |||
| 3abc6ceb15 | |||
| b93d070abc | |||
| 921f565f1d | |||
| 6e624724ca | |||
| 51bc4c7437 | |||
| e13271fc85 | |||
| c23f82efdd | |||
| 15fc04e067 | |||
| 61f42cea2b | |||
| 2c1e411e3c | |||
| c0f4315cd8 | |||
| 8c65e76145 | |||
| 5ec9ce5f88 | |||
| 087a275189 | |||
| 06e06e305b | |||
| 0e407dca7a | |||
| b0ff1552f2 | |||
| d191aaa34d | |||
| a1a70540b8 | |||
| 5c1837b1eb | |||
| bb0f156497 | |||
| 4b0496bad1 | |||
| a56add6d17 | |||
| 48b39c8519 | |||
| decaa006b2 | |||
| c0bd4a35be | |||
| 8c0d0ace4d | |||
| c11c762f15 | |||
| c465f58006 | |||
| 780246c4f6 | |||
| 5714555847 | |||
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| 4bdb9435a4 | |||
| ada0d8abe1 | |||
| dbecc345e3 | |||
| 0f8f6d0e4f | |||
| 911e11a372 | |||
| 2d28d18bf6 | |||
| 0b70d6c535 | |||
| 83463a5a59 | |||
| d66a72ac95 | |||
| 1b659dab01 | |||
| 9e01fe2c2c | |||
| 5fa5005786 | |||
| 1ac5b867bb | |||
| 69ef1bfc77 | |||
| 72592665d7 | |||
| 8f88d01cf2 | |||
| 2279617b29 | |||
| 17fbf6cec4 | |||
| 34c4481f96 | |||
| bff40a5d90 | |||
| 354a8b476d |
@@ -468,3 +468,24 @@ jobs:
|
|||||||
if [ "$found" = "0" ]; then
|
if [ "$found" = "0" ]; then
|
||||||
echo "no committed mods to lint"
|
echo "no committed mods to lint"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
luacheck:
|
||||||
|
name: engine lint (luacheck)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: install luacheck
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
||||||
|
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
||||||
|
luacheck --version
|
||||||
|
|
||||||
|
- name: luacheck gate (undefined globals, unreachable code)
|
||||||
|
run: ./scripts/lint.sh --gate
|
||||||
|
|
||||||
|
- name: luacheck full report (advisory)
|
||||||
|
continue-on-error: true
|
||||||
|
run: ./scripts/lint.sh
|
||||||
|
|||||||
@@ -373,10 +373,31 @@ jobs:
|
|||||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||||
|
|
||||||
- name: Build Android
|
- name: Materialize Android release signing key
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
[ -n "$KEYSTORE_B64" ] || {
|
||||||
|
echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
python3 - <<'PY'
|
||||||
|
import base64, os, pathlib
|
||||||
|
encoded = os.environ["KEYSTORE_B64"]
|
||||||
|
path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore"
|
||||||
|
path.write_bytes(base64.b64decode(encoded, validate=True))
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Build Android
|
||||||
|
env:
|
||||||
|
GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore
|
||||||
|
GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
|
||||||
|
GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||||
|
GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Install xcbeautify
|
- name: Install xcbeautify
|
||||||
run: |
|
run: |
|
||||||
@@ -498,8 +519,8 @@ jobs:
|
|||||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
|
|
||||||
ipa="dist/ios/gen1recomp++.ipa"
|
ipa="dist/ios/gen1recomp++.ipa"
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ read_globals = {
|
|||||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||||
table = { fields = { "unpack" } },
|
table = { fields = { "unpack" } },
|
||||||
|
"POKEPORT_DISPLAY_COMPANION",
|
||||||
|
"POKEPORT_EDITOR_MODE",
|
||||||
|
"rawlen",
|
||||||
|
package = { fields = { "searchers" } },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Vendored/native trees and the test suites have their own conventions.
|
-- Vendored/native trees and the test suites have their own conventions.
|
||||||
@@ -30,6 +34,7 @@ exclude_files = {
|
|||||||
"mobile/",
|
"mobile/",
|
||||||
"tests/",
|
"tests/",
|
||||||
"tools/save-editor/",
|
"tools/save-editor/",
|
||||||
|
"tools/save_convert/vendor/",
|
||||||
}
|
}
|
||||||
|
|
||||||
ignore = {
|
ignore = {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
|
|||||||
when you mean everywhere.
|
when you mean everywhere.
|
||||||
|
|
||||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||||
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1,
|
||||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||||
writes with a report, and which hooks and events are still to come.
|
writes with a report, and which hooks and events are still to come.
|
||||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||||
|
|||||||
+20
-2
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The badge line + its jingle, armed for the battle screen the way
|
||||||
|
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
|
||||||
|
local function badgeEndBattleText(game, victoryKey)
|
||||||
|
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
|
||||||
|
if not (reward and reward.dialogue) then return nil end
|
||||||
|
local text = game.data.text or {}
|
||||||
|
local pages = {}
|
||||||
|
for _, label in ipairs(reward.dialogue) do
|
||||||
|
if text[label] and text[label] ~= "" then
|
||||||
|
pages[#pages + 1] = text[label]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #pages == 0 then return nil end
|
||||||
|
return table.concat(pages, "\f"), reward.badgeSound
|
||||||
|
end
|
||||||
|
|
||||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||||
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
|
|||||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
||||||
|
ow:engageTrainer(npc, done, text, nil, sound)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[adviceLabel] or fallback, finish))
|
game.data.text[adviceLabel] or fallback, finish))
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
local text, sound = badgeEndBattleText(game, victoryKey)
|
||||||
|
ow:engageTrainer(npc, done, text, nil, sound)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local back = function(text)
|
local back = function(text)
|
||||||
game.stack:push(TextBox.new(game, text, function()
|
game.stack:push(TextBox.new(game, text, function()
|
||||||
ow:scriptMove(ow.player, "down", 1, done)
|
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
|||||||
if shipLeft then
|
if shipLeft then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||||
@@ -377,7 +377,7 @@ M.VERMILION_CITY = {
|
|||||||
ask .. "\f"
|
ask .. "\f"
|
||||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||||
or "You need a ticket\nto get aboard."),
|
or "You need a ticket\nto get aboard."),
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
@@ -837,13 +837,14 @@ M.SILPH_CO_11F = {
|
|||||||
-- every Silph rocket leaves off-screen (the street rockets are
|
-- every Silph rocket leaves off-screen (the street rockets are
|
||||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||||
-- run here: the battle's own callbacks are still unwinding, so
|
-- run here: the battle's own callbacks are still unwinding, so
|
||||||
-- queueScript starts it on the first idle overworld frame --
|
-- queueScript starts it on the first idle overworld frame (#722).
|
||||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
|
||||||
-- pushes (#722).
|
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
ow:queueScript(silphAftermathRows())
|
ow:queueScript(silphAftermathRows())
|
||||||
end
|
end
|
||||||
end, nil, true)
|
end,
|
||||||
|
-- "Arrgh!!" is armed for the battle screen, not the map
|
||||||
|
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
|
||||||
|
game.data.text._SilphCo10FGiovanniILostAgainText, true)
|
||||||
end)
|
end)
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||||
function()
|
function()
|
||||||
ow:scriptMove(ow.player, back, 1)
|
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
@@ -769,7 +769,7 @@ M.MUSEUM_1F = {
|
|||||||
if y == 4 and (x == 9 or x == 10)
|
if y == 4 and (x == 9 or x == 10)
|
||||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
museumClerk(game, ow, nil, function()
|
museumClerk(game, ow, nil, function()
|
||||||
ow:scriptMove(ow.player, "down", 1)
|
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ M.POKEMON_TOWER_6F = {
|
|||||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||||
-- so fleeing does not leave you standing on a cell that
|
-- so fleeing does not leave you standing on a cell that
|
||||||
-- immediately re-fires.
|
-- immediately re-fires.
|
||||||
ow:scriptMove(ow.player, "right", 1)
|
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
ow:afterBattle(result, battle)
|
ow:afterBattle(result, battle)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
|
|||||||
if not master or ow:trainerDefeated(master) then return false end
|
if not master or ow:trainerDefeated(master) then return false end
|
||||||
ow.player.facing = "right"
|
ow.player.facing = "right"
|
||||||
master:facePlayer(ow.player)
|
master:facePlayer(ow.player)
|
||||||
ow:engageTrainer(master)
|
-- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
|
||||||
|
ow:engageTrainer(master, nil,
|
||||||
|
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
|
||||||
|
nil, nil, false)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -731,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[dontRunText] or "Don't run away!", function()
|
game.data.text[dontRunText] or "Don't run away!", function()
|
||||||
ow:scriptMove(ow.player, "up", 1)
|
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
|
|||||||
+22
-19
@@ -240,7 +240,7 @@ local function stepGate(opts)
|
|||||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||||
ow.player.facing = opts.push
|
ow.player.facing = opts.push
|
||||||
if not ow:checkLedgeHop(opts.push) then
|
if not ow:checkLedgeHop(opts.push) then
|
||||||
ow:scriptMove(ow.player, opts.push, 1)
|
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
@@ -647,27 +647,29 @@ end
|
|||||||
local rocketRows = {
|
local rocketRows = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||||
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
|
||||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||||
{ "jump_if_true", 9 }, -- 5
|
{ "jump_if_true", 10 }, -- 5
|
||||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
||||||
{ "jump_if_false", "end" }, -- 8
|
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
{ "jump_if_false", "end" }, -- 9
|
||||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||||
|
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||||
|
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||||
-- the same in story.lua; either route is enough).
|
-- the same in story.lua; either route is enough).
|
||||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
@@ -818,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
|||||||
-- on the west-side cells and walks you back
|
-- on the west-side cells and walks you back
|
||||||
local function bikeGateGuard(coords, stopText, explainText)
|
local function bikeGateGuard(coords, stopText, explainText)
|
||||||
return function(game, ow, x, y)
|
return function(game, ow, x, y)
|
||||||
if game.save.inventory.BICYCLE then return false end
|
local bike = game.save.inventory.BICYCLE
|
||||||
|
if bike and bike ~= 0 then return false end
|
||||||
if not inCoords(coords, x, y) then return false end
|
if not inCoords(coords, x, y) then return false end
|
||||||
-- walk the player up to the tile beside the counter, no further:
|
-- walk the player up to the tile beside the counter, no further:
|
||||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||||
@@ -840,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
|||||||
-- (PlayerMovingRightScript). Without it the player was left
|
-- (PlayerMovingRightScript). Without it the player was left
|
||||||
-- parked beside the guard's counter with no way past. #518
|
-- parked beside the guard's counter with no way past. #518
|
||||||
local function shoveRight()
|
local function shoveRight()
|
||||||
ow:scriptMove(ow.player, "right", 1)
|
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
if dist > 0 then
|
if dist > 0 then
|
||||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||||
else
|
else
|
||||||
shoveRight()
|
shoveRight()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
|
|||||||
merged.** The write is taken, dropped, and named once per mod in the same
|
merged.** The write is taken, dropped, and named once per mod in the same
|
||||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
- **40 event names and 44 hook names have a call site in both generations**, so
|
||||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||||
reads those names back out of the source and fails if a site is renamed or
|
reads those names back out of the source and fails if a site is renamed or
|
||||||
deleted on either side, and fails again if a new shared site appears without
|
deleted on either side, and fails again if a new shared site appears without
|
||||||
@@ -539,7 +539,8 @@ gains a field instead of the name gaining a prefix.
|
|||||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
`battle.crit`, `battle.accuracy`, `battle.charge_required`,
|
||||||
|
`battle.turn_order`,
|
||||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||||
@@ -549,6 +550,17 @@ gains a field instead of the name gaining a prefix.
|
|||||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||||
`ctx.battle.data` instead gets nil on Gold.
|
`ctx.battle.data` instead gets nil on Gold.
|
||||||
|
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
||||||
|
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
||||||
|
it silently, so one mod source can print a single summary line for a
|
||||||
|
party-wide award instead of a box per recipient. Gold honours it **only when
|
||||||
|
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
||||||
|
against a seam that always announced on Gold and keeps announcing there,
|
||||||
|
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
||||||
|
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
||||||
|
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
||||||
|
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
||||||
|
happen either way.
|
||||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||||
`pokemon.caught` once the mon is in the party or the box, and
|
`pokemon.caught` once the mon is in the party or the box, and
|
||||||
|
|||||||
+57
-5
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
|
|||||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||||
`description` gives players dump or region guidance in the import panel.
|
`description` gives players dump or region guidance in the import panel.
|
||||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||||
per-import ceiling when an exact size is not appropriate. Every import also
|
per-import ceiling when an exact size is not appropriate. The engine hard limit
|
||||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
|
||||||
filesystem reports an invalid size.
|
use the launcher's streaming large-file path rather than being materialized as
|
||||||
|
one Lua string.
|
||||||
|
|
||||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||||
mod: the launcher never scans or copies another mod's imported files merely
|
mod: the launcher never scans or copies another mod's imported files merely
|
||||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
because its manifest names the same digest. Small sources can still be read
|
||||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
with the existing scoped `mod:read` API, for example
|
||||||
|
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
|
||||||
|
`mod.imports` facade described below; no host path or new general filesystem
|
||||||
permission is exposed. Missing `required_imports` block the mod before its
|
permission is exposed. Missing `required_imports` block the mod before its
|
||||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||||
launcher panel but do not block loading.
|
launcher panel but do not block loading.
|
||||||
|
|
||||||
|
#### Bounded access to validated imports
|
||||||
|
|
||||||
|
A loaded mod can address only ids declared by its own `required_imports` or
|
||||||
|
`optional_imports` arrays:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local info, err = mod.imports:info("stadium2")
|
||||||
|
local header, err = mod.imports:read("stadium2", 0, 4096)
|
||||||
|
```
|
||||||
|
|
||||||
|
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
|
||||||
|
rechecks the stored import before exposing it, seeks into the engine-owned
|
||||||
|
copy, and never gives the mod a host path or file handle. This is intended for
|
||||||
|
large source formats whose table/index can be parsed with small reads before
|
||||||
|
selectively reading the payloads a transform actually needs.
|
||||||
|
|
||||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||||
@@ -441,6 +460,28 @@ default** (1x front, 2x back).
|
|||||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||||
mon still grows into place from the ball, grounded the whole way.
|
mon still grows into place from the ball, grounded the whole way.
|
||||||
|
|
||||||
|
## Installation-scoped generated cache
|
||||||
|
|
||||||
|
Generated data derived from a validated user source often belongs to the mod
|
||||||
|
installation rather than to one Pokémon save. `mod.cache` is that namespace:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
|
||||||
|
local bytes, err = mod.cache:read("extract/v1/arena.bin")
|
||||||
|
local info = mod.cache:info("extract/v1/arena.bin")
|
||||||
|
mod.cache:delete("extract/v1/arena.bin")
|
||||||
|
```
|
||||||
|
|
||||||
|
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
|
||||||
|
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
|
||||||
|
The cache does not rewind with checkpoints and is not scoped to game version,
|
||||||
|
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
|
||||||
|
policy, and completion marker; the engine treats the bytes as opaque data.
|
||||||
|
|
||||||
|
Use `mod.storage` instead when the data belongs to one playthrough. Use
|
||||||
|
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
|
||||||
|
from a declared user source.
|
||||||
|
|
||||||
## Durable tool storage and runtime checkpoints
|
## Durable tool storage and runtime checkpoints
|
||||||
|
|
||||||
`mod.save` remains the right place for state that should travel with the next
|
`mod.save` remains the right place for state that should travel with the next
|
||||||
@@ -633,6 +674,17 @@ the selected indices. Mods remain responsible for selection policy and should
|
|||||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||||
contract and compatibility guarantees.
|
contract and compatibility guarantees.
|
||||||
|
|
||||||
|
Both battle engines expose the guarded `battle.charge_required` hook when a
|
||||||
|
charge-capable move is selected for its initial turn and the active ruleset
|
||||||
|
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
|
||||||
|
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
|
||||||
|
skip only that initial charge and continue through the ordinary move pipeline;
|
||||||
|
call `next(ctx)` to keep it. The hook does not run for the release turn or when
|
||||||
|
the active ruleset already skips charging (for example, Gold Solarbeam in
|
||||||
|
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
|
||||||
|
by the engine. With no subscriber, the vanilla decision runs without building
|
||||||
|
the hook context.
|
||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
||||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
|
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
|
||||||
|
the existing launcher and public mod API still assume imported bytes are small:
|
||||||
|
|
||||||
|
* the Windows desktop picker stages a selected required import through a fixed
|
||||||
|
`%TEMP%/pokeport_required_import.bin` path before validation;
|
||||||
|
* the fallback import path materializes the selected file as one Lua string;
|
||||||
|
* after validation a mod can only use `mod:read("baseroms/...")`, which also
|
||||||
|
materializes the whole file;
|
||||||
|
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
|
||||||
|
not an appropriate home for a one-time generated asset cache shared by every
|
||||||
|
save using the same installed mod.
|
||||||
|
|
||||||
|
This makes optical-disc-sized user sources impractical even though the manifest
|
||||||
|
schema already accepts them. A failed temporary staging copy can also turn a
|
||||||
|
valid large source into a smaller temporary file and produce a misleading
|
||||||
|
"wrong file size" rejection.
|
||||||
|
|
||||||
|
A mod should be able to consume its own already-validated source incrementally
|
||||||
|
and compile derived runtime data once, without receiving a host path or general
|
||||||
|
filesystem access.
|
||||||
|
|
||||||
|
## Decision being extended
|
||||||
|
|
||||||
|
This extends the same legal/sandbox direction as **D11 asset transforms**
|
||||||
|
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
|
||||||
|
user-owned sources rather than shipping ROM-derived data. It also follows the
|
||||||
|
**D14 parity-gate** contract referenced by `tests/harness.lua` and
|
||||||
|
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
|
||||||
|
extension points ship public-API coverage, no-mod parity coverage, and docs in
|
||||||
|
the same change.
|
||||||
|
|
||||||
|
The historical D11 plan document is referenced by source comments but is not
|
||||||
|
present in the current repository tree; this RFC is the checked-in design
|
||||||
|
record for the new surface.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
No manifest field changes. Existing `required_imports` and `optional_imports`
|
||||||
|
remain the declaration/validation authority.
|
||||||
|
|
||||||
|
Two additive facades are added to the `mod` object.
|
||||||
|
|
||||||
|
### `mod.imports`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local info, err = mod.imports:info("source_id")
|
||||||
|
local bytes, err = mod.imports:read("source_id", offset, length)
|
||||||
|
```
|
||||||
|
|
||||||
|
* `source_id` must name an import declared by the calling mod.
|
||||||
|
* the import is rechecked through `RequiredImports.validateStored` before it is
|
||||||
|
exposed, so missing, replaced, or invalid optional imports are not readable;
|
||||||
|
* `offset` and `length` are zero-based byte coordinates;
|
||||||
|
* one read is capped at 8 MiB;
|
||||||
|
* no host path or file handle is returned;
|
||||||
|
* production reads seek into the engine-owned stored copy instead of reading
|
||||||
|
the whole source.
|
||||||
|
|
||||||
|
`info()` returns declaration metadata plus stored size. It does not expose a
|
||||||
|
host path.
|
||||||
|
|
||||||
|
### `mod.cache`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.cache:write("extract/v1/model.bin", bytes)
|
||||||
|
local bytes = mod.cache:read("extract/v1/model.bin")
|
||||||
|
local info = mod.cache:info("extract/v1/model.bin")
|
||||||
|
mod.cache:delete("extract/v1/model.bin")
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
|
||||||
|
backend, and is independent of game version, launcher slot, and playthrough.
|
||||||
|
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
|
||||||
|
escapes remain unavailable. A single cache write is capped at 64 MiB so large
|
||||||
|
generated datasets are naturally split into independently replaceable files.
|
||||||
|
|
||||||
|
The engine does not interpret cache bytes. Mods own generated-format versioning,
|
||||||
|
fingerprints, transactional completion markers, and rebuild policy.
|
||||||
|
|
||||||
|
## Launcher/import transport delta
|
||||||
|
|
||||||
|
For large raw required imports:
|
||||||
|
|
||||||
|
1. desktop pickers return the original selected path instead of staging it
|
||||||
|
through a fixed temporary file;
|
||||||
|
2. the engine opens that source itself;
|
||||||
|
3. bytes are copied directly to the existing engine-owned
|
||||||
|
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
|
||||||
|
4. MD5 is updated incrementally during the copy;
|
||||||
|
5. the normal size/MD5 validation receipt is written only after the complete
|
||||||
|
destination passes validation;
|
||||||
|
6. partial destinations are removed on short reads, write failure, size
|
||||||
|
mismatch, or digest mismatch.
|
||||||
|
|
||||||
|
N64 imports stay on the existing canonicalization path because byte-order and
|
||||||
|
copier-header normalization require transformation rather than a raw copy.
|
||||||
|
|
||||||
|
If a validation receipt for an already-stored large raw import is missing, the
|
||||||
|
engine rebuilds it with streaming MD5 rather than a whole-file read.
|
||||||
|
|
||||||
|
## Backward compatibility / migration
|
||||||
|
|
||||||
|
**Existing mods do nothing.** This is additive.
|
||||||
|
|
||||||
|
* manifest v1/v2 fields are unchanged;
|
||||||
|
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
|
||||||
|
retain their existing behavior;
|
||||||
|
* small required imports retain the existing in-memory validation path;
|
||||||
|
* N64 imports retain canonicalization and existing accepted byte orders;
|
||||||
|
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
|
||||||
|
files and observes no new behavior.
|
||||||
|
|
||||||
|
The mod API integer is not bumped because no existing member changes meaning or
|
||||||
|
shape.
|
||||||
|
|
||||||
|
## Security and legal posture
|
||||||
|
|
||||||
|
The launcher remains the authority that validates user-supplied bytes. The new
|
||||||
|
facade narrows access rather than widening it: a mod can read only ids declared
|
||||||
|
in its own manifest, only after validation, and only in bounded ranges. It does
|
||||||
|
not receive host paths, `io`, or a raw filesystem handle.
|
||||||
|
|
||||||
|
`mod.cache` is writable only beneath the calling mod's generated-cache root.
|
||||||
|
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
|
||||||
|
continue to enforce the existing legal posture.
|
||||||
|
|
||||||
|
## Parity guarantee
|
||||||
|
|
||||||
|
The change ships with:
|
||||||
|
|
||||||
|
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
|
||||||
|
`mod:read` load do not create cache data or change the old surface;
|
||||||
|
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
|
||||||
|
real `Loader` load, including bounded reads, undeclared/missing imports,
|
||||||
|
cache isolation, and traversal rejection;
|
||||||
|
* incremental MD5 vectors and a large-import streaming regression test;
|
||||||
|
* the existing engine suite, required-import suite, and mod lint gates.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# RFC 0011: Charge-required battle hook
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A battle-mechanics mod can change damage through `battle.damage` and register
|
||||||
|
move effects, but it cannot conditionally skip the first turn of an existing
|
||||||
|
charge move. In Gen 1, the engine decides and stores the charge continuation
|
||||||
|
before any public effect callback can run. Reaching into `user.charging`,
|
||||||
|
`user.chargeReady`, or generation-specific volatile state is private,
|
||||||
|
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
|
||||||
|
|
||||||
|
Weather is the immediate example: a portable sun rule needs Solarbeam to
|
||||||
|
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
|
||||||
|
and secondary effects to the engine. The capability is generic and useful to
|
||||||
|
other ruleset and move-mechanics mods.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-002: charge-stage policy remains mod authority through a
|
||||||
|
generic guarded engine decision seam**. The consuming design is tracked in the
|
||||||
|
Adaptive Trainers implementation plan,
|
||||||
|
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||||
|
Task 8. The delta follows the additive, guarded hook convention documented by
|
||||||
|
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
|
||||||
|
Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.charge_required", function(next, ctx)
|
||||||
|
-- ctx = {
|
||||||
|
-- battle = live battle controller,
|
||||||
|
-- user = attacking battler,
|
||||||
|
-- target = defending battler,
|
||||||
|
-- move = merged move record,
|
||||||
|
-- charge = true,
|
||||||
|
-- isCalled = false,
|
||||||
|
-- }
|
||||||
|
if should_resolve_now(ctx) then return false end
|
||||||
|
return next(ctx)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The call site is the initial-use charge decision, after announcement and PP
|
||||||
|
handling but before charge state, invulnerability, charge animation, or charge
|
||||||
|
text is created. It runs only when the active engine rules would otherwise
|
||||||
|
require a charge. It does not run on the release turn. Returning exactly
|
||||||
|
`false` skips that initial charge and continues through the engine-owned move
|
||||||
|
pipeline. Any other downstream return preserves the charge. `isCalled` is true
|
||||||
|
when Metronome or Mirror Move selected the move.
|
||||||
|
|
||||||
|
Gold keeps its native sun decision first, so Solarbeam in native sun already
|
||||||
|
requires no charge and does not invoke the hook. Gen 1 link battles use the
|
||||||
|
shared Gen 1 move pipeline and therefore receive the same seam; normal link
|
||||||
|
mod-compatibility rules continue to govern deterministic peers.
|
||||||
|
|
||||||
|
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
|
||||||
|
subscriber, no hook payload table is allocated and the existing branch runs
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. The hook name and payload are additive. With no
|
||||||
|
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
|
||||||
|
charge state, PP use, text, animation, accuracy, damage, and native weather
|
||||||
|
behavior. Existing charge-move data and effect records require no migration.
|
||||||
|
|
||||||
|
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
|
||||||
|
skip this charge. It should not mutate private charge fields or re-run the move.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
|
||||||
|
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
|
||||||
|
release-turn behavior, called-move PP semantics, shared payload shape, and
|
||||||
|
native Gold sun behavior.
|
||||||
|
- The same test proves no-mod charge/release parity and replaces
|
||||||
|
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
|
||||||
|
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
|
||||||
|
chains preserve vanilla values and allocation behavior.
|
||||||
|
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
|
||||||
|
generations and keeps the compatibility reference list complete.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
||||||
|
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
||||||
|
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
||||||
|
a mod that pays the whole party prints **one** summary line instead of a box
|
||||||
|
per recipient.
|
||||||
|
|
||||||
|
Gold accepts the argument and ignores it, as its own comment above the hook
|
||||||
|
call says. So the same mod source, running the same code, prints one line on
|
||||||
|
Red and six on Gold — one for the participant plus one for every bench mon it
|
||||||
|
paid.
|
||||||
|
|
||||||
|
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
||||||
|
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
||||||
|
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
||||||
|
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
||||||
|
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
||||||
|
into six boxes to click through.
|
||||||
|
|
||||||
|
There is no mod-side fix. The announcement is emitted inside
|
||||||
|
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
||||||
|
because the argument that means "quietly" is discarded. The only workaround is
|
||||||
|
to intercept the battle's event queue afterwards and delete the boxes, which is
|
||||||
|
what a mod written for this had to do — a mod reaching into engine internals to
|
||||||
|
undo something the public seam should never have done.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
||||||
|
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
||||||
|
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
||||||
|
The third `applyShare` argument is the one part of that ctx whose meaning did
|
||||||
|
not survive the crossing, so the promise the catalog already makes is what this
|
||||||
|
change delivers.
|
||||||
|
|
||||||
|
The delta follows Route B's additive, guarded convention in
|
||||||
|
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
||||||
|
exists today changes behaviour.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
||||||
|
|
||||||
|
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
||||||
|
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
||||||
|
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
||||||
|
| `applyShare(mon, split, true)` | announces | announces | announces |
|
||||||
|
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
||||||
|
|
||||||
|
The argument is honoured **only when it is actually passed**, decided by
|
||||||
|
argument count rather than by value:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local function applyShare(mon, split, ...)
|
||||||
|
local announce = ...
|
||||||
|
local silent = select("#", ...) > 0 and not announce
|
||||||
|
...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
||||||
|
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
||||||
|
the first is a Gen 2-era call written against a seam that always announced, and
|
||||||
|
the second is a deliberate "pay this one quietly".
|
||||||
|
|
||||||
|
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
||||||
|
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
||||||
|
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
||||||
|
happen exactly as before, in the same order.
|
||||||
|
|
||||||
|
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
||||||
|
defaults to announcing, so both of the cart's own passes are untouched.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
||||||
|
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
||||||
|
file is modified. The v1 surface — `content.X:register/override/get`,
|
||||||
|
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||||
|
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
||||||
|
calls this seam nor loads differently.
|
||||||
|
|
||||||
|
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
||||||
|
seam has always documented. Exp Share already does, and needs no edit to get
|
||||||
|
its own README's behaviour on Gold.
|
||||||
|
|
||||||
|
One residual difference is deliberate and now documented rather than silent: an
|
||||||
|
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
||||||
|
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
||||||
|
B rejects. Passing the argument makes the two generations agree, so the rule an
|
||||||
|
author needs is one sentence: *say what you mean and both games do the same
|
||||||
|
thing.*
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
||||||
|
checks it already had are unchanged — which is itself the vanilla-parity
|
||||||
|
evidence for this file.
|
||||||
|
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
||||||
|
participant still prints one line and the EXP.SHARE double pass still
|
||||||
|
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
||||||
|
the ctx allocation, and `vanillaAward` never passes `silent`.
|
||||||
|
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
||||||
|
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
||||||
|
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
||||||
|
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
||||||
|
are identical either way, and no `experience` event leaks into the queue on
|
||||||
|
a silent pass.
|
||||||
|
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
||||||
|
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
||||||
|
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
||||||
|
gate list moves.
|
||||||
|
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
||||||
|
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
||||||
|
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
||||||
|
existing `battle.low_health_alarm` payload note, in the same section that lists
|
||||||
|
`battle.exp_award` as shared — including the argument-count rule and the
|
||||||
|
residual difference above.
|
||||||
|
|
||||||
|
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
||||||
|
`tools/gen_registry_docs.lua` has nothing new to emit.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
||||||
|
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
||||||
|
docs name the explicit form as the one that behaves the same on both games.
|
||||||
+64
-25
@@ -1,11 +1,11 @@
|
|||||||
# Touch skins and the Skin Studio
|
# Touch skins and the Skin Studio
|
||||||
|
|
||||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
control layout, and a screen-placement anchor. Engine:
|
||||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
(draw and input), `src/render/Renderer.lua` (screen placement),
|
||||||
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
|
||||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||||
`tests/engine/skin_studio_ux.lua`,
|
`tests/engine/skin_studio_ux.lua`,
|
||||||
`tests/engine/skin_studio_image_import.lua`,
|
`tests/engine/skin_studio_image_import.lua`,
|
||||||
@@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
|||||||
`tests/engine/launcher_skins_tab.lua`,
|
`tests/engine/launcher_skins_tab.lua`,
|
||||||
`tests/engine/launcher_skins_ux.lua`.
|
`tests/engine/launcher_skins_ux.lua`.
|
||||||
|
|
||||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
|
||||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
and is the one place that turns skin use off. **My Skins** holds the visual
|
||||||
|
grid, pagination, edit, delete and per-skin export actions.
|
||||||
|
`options.touchControls.skin` holds the folder name.
|
||||||
|
|
||||||
## Formats
|
## Formats
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ as-is. Supported keys:
|
|||||||
| `overlays` | page count |
|
| `overlays` | page count |
|
||||||
| `overlayN_name` | page name, the target of `next_target` |
|
| `overlayN_name` | page name, the target of `next_target` |
|
||||||
| `overlayN_overlay` | bezel image |
|
| `overlayN_overlay` | bezel image |
|
||||||
| `overlayN_full_screen` | stretch the page to the window |
|
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
|
||||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||||
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||||
@@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
|
|||||||
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||||
window, and puts the Game Boy picture in the leftover space above -- the
|
window, and puts the Game Boy picture in the leftover space above -- the
|
||||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
|
||||||
stretch to the window the way Delta does. Host functions map to
|
game into it. Host functions map to
|
||||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||||
@@ -135,7 +137,7 @@ to decoration and never captures a touch.
|
|||||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||||
how a skin button reaches a mod hotkey.
|
how a skin button reaches a mod hotkey.
|
||||||
|
|
||||||
## The screen viewport
|
## Screen placement
|
||||||
|
|
||||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||||
@@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
|
|||||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||||
|
|
||||||
|
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
|
||||||
|
the world, and the start menu stays at the hole's fit scale instead of
|
||||||
|
shrinking with the map.
|
||||||
|
|
||||||
|
An image-backed portrait overlay that has no explicit vertical anchor is treated
|
||||||
|
as a controller deck: it is contained without deformation and pinned to the
|
||||||
|
bottom on taller screens. The spare space belongs to the game above it.
|
||||||
|
|
||||||
|
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
|
||||||
|
skin rather than the normal Center / Upper / Top setting.
|
||||||
|
|
||||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||||
channel and writes the rect.
|
channel and writes the rect.
|
||||||
@@ -153,10 +166,8 @@ channel and writes the rect.
|
|||||||
## Bezels versus pads
|
## Bezels versus pads
|
||||||
|
|
||||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||||
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
|
||||||
as well, where the touch overlay itself does not, and a gamepad does not hide
|
**desktop** as well as mobile; a gamepad does not hide them.
|
||||||
them. Anything that binds a button still follows the usual mobile /
|
|
||||||
`POKEPORT_TOUCH` rule.
|
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -190,9 +201,32 @@ shipping branding.
|
|||||||
|
|
||||||
## The studio
|
## The studio
|
||||||
|
|
||||||
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
|
||||||
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
mobile. **My Skins** is the only visual grid: real bezel previews plus create
|
||||||
because it wants a mouse, typed coordinates and room for an inspector.
|
and import actions, with each card owning Edit, Export and (for installed
|
||||||
|
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
|
||||||
|
New/Load workspace controls are deliberately not duplicated inside that editor.
|
||||||
|
|
||||||
|
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
||||||
|
compact lower tray: add/control binding, button and bezel artwork, pages,
|
||||||
|
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
|
||||||
|
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
|
||||||
|
**Detect this screen** (also on the tray) sets the mock device to the live
|
||||||
|
window size so a phone skin is authored at that phone's form factor rather
|
||||||
|
than a generic 1080x1920 16:9. **Zoom −** shrinks the mock device inside the
|
||||||
|
workspace so the screen hole can be dragged larger than the bezel while the
|
||||||
|
handles stay grabable; **Fit** restores contain. The mouse wheel over the
|
||||||
|
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
|
||||||
|
same controls that a mouse edits on desktop.
|
||||||
|
|
||||||
|
My Skins and the editor chrome sit inside the platform safe area (notch,
|
||||||
|
status bar, home indicator), the same inset the launcher uses. The mock
|
||||||
|
device still represents the full window, because a skin covers the whole
|
||||||
|
screen at play time.
|
||||||
|
|
||||||
|
The launcher’s **Turn skins off** button clears the selected skin and disables
|
||||||
|
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
||||||
|
is not itself a skin card.
|
||||||
|
|
||||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||||
phone proportions on a desktop monitor.
|
phone proportions on a desktop monitor.
|
||||||
@@ -200,6 +234,7 @@ phone proportions on a desktop monitor.
|
|||||||
| Preset | Size |
|
| Preset | Size |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||||
|
| This screen | the live window, so a phone is authored at its own height |
|
||||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||||
| Steam Deck | 1280x800 |
|
| Steam Deck | 1280x800 |
|
||||||
| Desktop 1080p | 1920x1080 |
|
| Desktop 1080p | 1920x1080 |
|
||||||
@@ -216,13 +251,16 @@ and of the page itself when it comes within a few pixels, and the guide it
|
|||||||
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||||
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||||
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
pressed images are per control; the bezel, the pages and the screen anchor are
|
||||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
per page. The SCREEN anchor is itself draggable and resizable; its default
|
||||||
|
shape is freeform, with an optional 10:9 lock.
|
||||||
|
|
||||||
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||||
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
|
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
|
||||||
decoration. The COMBINE chips at the top toggle one part at a time, which is
|
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
|
||||||
how a pipe bind like `left|down` is built without typing it.
|
as `key:` controls, so a mobile button invokes the exact same game path as
|
||||||
|
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
|
||||||
|
which is how a pipe bind like `left|down` is built without typing it.
|
||||||
|
|
||||||
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||||
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||||
@@ -247,12 +285,13 @@ the **Import** button there and beside each row opens the host file picker (`src
|
|||||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||||
window does the same for whichever slot was last touched. A new bezel does not
|
window does the same for whichever slot was last touched. A new bezel does not
|
||||||
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
move the screen anchor: press **Detect screen from bezel** to measure it out of
|
||||||
the art's alpha.
|
the art's alpha.
|
||||||
|
|
||||||
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
**Testing.** **Test** renders a game-composition preview behind the live overlay:
|
||||||
buttons and the footer reports what is held. **Play** saves the skin, selects
|
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
|
||||||
it, and boots the game with it.
|
Clicking presses real Game Boy buttons and the footer reports what is held.
|
||||||
|
**Play** saves the skin, selects it, and boots the game with it.
|
||||||
|
|
||||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||||
|
|||||||
+26
-4
@@ -64,7 +64,8 @@ mounted or deleted as stale; the launcher directs the player to a full package.
|
|||||||
|
|
||||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||||
`-android.apk`) plus two assets the updater itself consumes:
|
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||||
|
PortMaster archives) plus two assets the updater itself consumes:
|
||||||
|
|
||||||
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||||
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||||
@@ -75,8 +76,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
|||||||
filename otherwise to match the asset name exactly.
|
filename otherwise to match the asset name exactly.
|
||||||
|
|
||||||
A release missing either asset is treated as "no in-place update available":
|
A release missing either asset is treated as "no in-place update available":
|
||||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
`Check` reports `needs_full`. It also selects the exact current platform asset
|
||||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
from the same release and persists the requirement, so it is visible again on
|
||||||
|
every launch, including offline launches.
|
||||||
|
|
||||||
## Save-directory layout
|
## Save-directory layout
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
|
|||||||
```
|
```
|
||||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||||
updates/pending.txt crash-guard marker
|
updates/pending.txt crash-guard marker
|
||||||
|
updates/full-update.json persistent native-package requirement
|
||||||
```
|
```
|
||||||
|
|
||||||
`pending.txt` holds the filename of the payload currently being chainloaded.
|
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||||
@@ -106,7 +109,8 @@ bundled game, in that case.
|
|||||||
against the GitHub releases API; safe to call every frame, it is a no-op
|
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||||
once a check is in flight or has reached a terminal state. `Check.state()`
|
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||||
reports `idle | checking | uptodate | available | downloading | ready |
|
reports `idle | checking | uptodate | available | downloading | ready |
|
||||||
needs_full | error` plus the latest version and download progress.
|
needs_full | full_downloading | full_ready | error` plus the latest version,
|
||||||
|
download progress, and (when applicable) the selected full-package asset.
|
||||||
3. **Download + verify**: on `available`, `Check.download()` tells the
|
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||||
worker to fetch the payload, polling the growing `.part` file for
|
worker to fetch the payload, polling the growing `.part` file for
|
||||||
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||||
@@ -117,6 +121,17 @@ bundled game, in that case.
|
|||||||
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
||||||
player relaunches; the next launch's Boot step (1) is what actually
|
player relaunches; the next launch's Boot step (1) is what actually
|
||||||
mounts and runs it. There is no in-session hot-swap.
|
mounts and runs it. There is no in-session hot-swap.
|
||||||
|
5. **Native-package requirement**: when `minShell` or `payloadHost` is
|
||||||
|
incompatible, the worker writes `full-update.json` and surfaces a
|
||||||
|
persistent launcher control. Android downloads the release APK, verifies
|
||||||
|
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||||
|
Installer. The installer asks the user for consent and enforces package,
|
||||||
|
version-code, and signing-certificate compatibility. A legacy APK without
|
||||||
|
the installer bridge links its full package for one manual bootstrap
|
||||||
|
update, including when its downloaded payload already reports the latest
|
||||||
|
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
||||||
|
desktop, and PortMaster builds link their correctly named full package.
|
||||||
|
Switch keeps its native OTA flow.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@@ -140,6 +155,13 @@ bundled game, in that case.
|
|||||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||||
|
- **Android full updates are user-confirmed and certificate-bound.** The app
|
||||||
|
uses a private `FileProvider` cache path plus
|
||||||
|
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
|
||||||
|
"install unknown apps" setting, and never requests a silent install. The
|
||||||
|
release job must use the original long-lived Android signing key; a new key
|
||||||
|
causes Android to reject an in-place update and requires a one-time manual
|
||||||
|
reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md).
|
||||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
|||||||
|
|
||||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||||
do
|
do
|
||||||
local defaultErrorHandler = love.errorhandler
|
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||||
function love.errorhandler(msg)
|
function love.errorhandler(msg)
|
||||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||||
if hint and type(msg) == "string" then
|
if ok and hint and type(msg) == "string" then
|
||||||
msg = msg .. "\n\n" .. hint
|
msg = msg .. "\n\n" .. hint
|
||||||
end
|
end
|
||||||
if defaultErrorHandler then
|
if defaultErrorHandler then
|
||||||
@@ -302,6 +302,8 @@ local function makeLauncher()
|
|||||||
forceImport = forceImport,
|
forceImport = forceImport,
|
||||||
onEditSave = openEditor,
|
onEditSave = openEditor,
|
||||||
onEditTouchControls = openTouchControlsEditor,
|
onEditTouchControls = openTouchControlsEditor,
|
||||||
|
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
|
||||||
|
-- Keep the compatibility predicate so external hosts using it still work.
|
||||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||||
and openSkinStudio or nil,
|
and openSkinStudio or nil,
|
||||||
})
|
})
|
||||||
@@ -327,6 +329,7 @@ local function returnToLauncher()
|
|||||||
if love.audio and love.audio.stop then
|
if love.audio and love.audio.stop then
|
||||||
pcall(love.audio.stop)
|
pcall(love.audio.stop)
|
||||||
end
|
end
|
||||||
|
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local currentVersion = GameVersion.get()
|
local currentVersion = GameVersion.get()
|
||||||
@@ -876,6 +879,8 @@ love.handlers = love.handlers or {}
|
|||||||
function love.handlers.audiosuspend()
|
function love.handlers.audiosuspend()
|
||||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||||
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
||||||
|
local Sound = package.loaded["src.core.Sound"]
|
||||||
|
if Sound then pcall(Sound.onDeviceReset) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.handlers.audioreset()
|
function love.handlers.audioreset()
|
||||||
@@ -928,7 +933,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchpressed(id, x, y)
|
return TouchEditor.touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.touchpressed(id, x, y) end
|
||||||
if Importer then
|
if Importer then
|
||||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||||
@@ -945,7 +950,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchmoved(id, x, y)
|
return TouchEditor.touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.touchmoved(id, x, y) end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
@@ -959,7 +964,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchreleased(id, x, y)
|
return TouchEditor.touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.touchreleased(id, x, y) end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
@@ -1012,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousepressed(x, y, button)
|
return TouchEditor.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.mousepressed(x, y, button) end
|
if Studio then
|
||||||
|
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
|
||||||
|
-- Studio consumes the real finger stream above, so discard the twin.
|
||||||
|
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||||
|
return Studio.mousepressed(x, y, button)
|
||||||
|
end
|
||||||
if Importer then
|
if Importer then
|
||||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||||
@@ -1047,7 +1057,10 @@ function love.mousereleased(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousereleased(x, y, button)
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.mousereleased(x, y, button) end
|
if Studio then
|
||||||
|
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||||
|
return Studio.mousereleased(x, y, button)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
@@ -1065,7 +1078,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousemoved(x, y)
|
return TouchEditor.mousemoved(x, y)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.mousemoved(x, y) end
|
if Studio then
|
||||||
|
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||||
|
return Studio.mousemoved(x, y)
|
||||||
|
end
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch then
|
if mouseTouch then
|
||||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||||
|
|||||||
+15
-5
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
|
|||||||
love-android 11.5a expects:
|
love-android 11.5a expects:
|
||||||
|
|
||||||
- **JDK 17**
|
- **JDK 17**
|
||||||
- Android SDK with **API 34**
|
- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
|
||||||
- NDK **25.2.9519653** (Apple Silicon host supported)
|
- NDK **25.2.9519653** (Apple Silicon host supported)
|
||||||
|
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
||||||
|
|
||||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||||
@@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded.
|
|||||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||||
| `app.name` | Pokemon Red |
|
| `app.name` | Pokemon Red |
|
||||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
|
||||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
|
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||||
`PokemonRed-<version>-android.apk`.
|
`gen1recomp-<version>-android.apk`.
|
||||||
|
|
||||||
## Signing
|
## Signing
|
||||||
|
|
||||||
Signed with the default Android keystore (no setup required).
|
Production APKs are built with `scripts/build_android.sh --release`. They must
|
||||||
|
be signed with the same long-lived certificate as the currently installed app:
|
||||||
|
Android's Package Installer rejects an update with a different signing
|
||||||
|
certificate. Store that keystore and its passwords only in CI secrets, expose
|
||||||
|
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
|
||||||
|
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
|
||||||
|
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
|
||||||
|
created certificate cannot update users who have an APK signed by a different
|
||||||
|
legacy key; those users need one final manual reinstall before in-app updates
|
||||||
|
can take over.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Quick Start:
|
|||||||
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
||||||
Android Studio bundles its own JDK 17.
|
Android Studio bundles its own JDK 17.
|
||||||
|
|
||||||
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable
|
Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
|
||||||
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ android {
|
|||||||
applicationId project.properties["app.application_id"]
|
applicationId project.properties["app.application_id"]
|
||||||
versionCode project.properties["app.version_code"].toInteger()
|
versionCode project.properties["app.version_code"].toInteger()
|
||||||
versionName project.properties["app.version_name"]
|
versionName project.properties["app.version_name"]
|
||||||
minSdk 16
|
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
||||||
compileSdk 34
|
// the native toolchain and package-installer bridge on a supported ABI.
|
||||||
targetSdk 34
|
minSdk 19
|
||||||
|
// Android 16 / API 36: current Android distribution target.
|
||||||
|
compileSdk 36
|
||||||
|
targetSdk 36
|
||||||
|
|
||||||
def getAppName = {
|
def getAppName = {
|
||||||
def nameArray = project.properties["app.name_byte_array"]
|
def nameArray = project.properties["app.name_byte_array"]
|
||||||
@@ -38,10 +41,31 @@ android {
|
|||||||
ORIENTATION:project.properties["app.orientation"],
|
ORIENTATION:project.properties["app.orientation"],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
// Release signing lives outside the repository. The release build script
|
||||||
|
// requires all five values below, while debug builds intentionally remain
|
||||||
|
// usable without them.
|
||||||
|
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
|
||||||
|
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
|
||||||
|
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
|
||||||
|
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
|
||||||
|
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file(releaseStore)
|
||||||
|
storePassword releaseStorePassword
|
||||||
|
keyAlias releaseKeyAlias
|
||||||
|
keyPassword releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
if (hasReleaseSigning) signingConfig signingConfigs.release
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flavorDimensions = ['mode', 'recording']
|
flavorDimensions = ['mode', 'recording']
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||||
scripts/build_android.sh must not strip this again. -->
|
scripts/build_android.sh must not strip this again. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Required only to hand a checksum-verified, user-selected GitHub release
|
||||||
|
APK to Android's own Package Installer. Android still shows the install
|
||||||
|
confirmation and enforces package/signing-key/version compatibility. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||||
counter, which Android 10+ gates behind this runtime permission.
|
counter, which Android 10+ gates behind this runtime permission.
|
||||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||||
@@ -35,10 +39,22 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.allow_multiple_resumed_activities"
|
android:name="android.allow_multiple_resumed_activities"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
|
<!-- The full-update APK is copied into this small cache subdirectory
|
||||||
|
before it is handed to Package Installer. Keep the provider private
|
||||||
|
and expose only that directory, never a storage root. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.full_update_provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/full_update_paths" />
|
||||||
|
</provider>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity"
|
android:name="org.love2d.android.GameActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||||
android:label="${NAME}"
|
android:label="${NAME}"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:screenOrientation="${ORIENTATION}"
|
android:screenOrientation="${ORIENTATION}"
|
||||||
@@ -55,7 +71,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
|
||||||
|
arbitrary app, external, or shared storage. -->
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="full_update" path="full-update/" />
|
||||||
|
</paths>
|
||||||
@@ -18,7 +18,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
// Android 16 / API 36 requires Android Gradle Plugin 8.9+.
|
||||||
|
classpath 'com.android.tools.build:gradle:8.9.2'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ app.version_name=11.5a
|
|||||||
# No need to modify anything past this line!
|
# No need to modify anything past this line!
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.nonFinalResIds=true
|
android.nonFinalResIds=true
|
||||||
app.name=gen1recomp
|
app.name=gen1recomp
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ android {
|
|||||||
ndkVersion '25.2.9519653'
|
ndkVersion '25.2.9519653'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk 16
|
minSdk 19
|
||||||
compileSdk 34
|
compileSdk 36
|
||||||
targetSdk 34
|
targetSdk 36
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||||
@@ -63,8 +63,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
@@ -283,6 +283,40 @@ bool restartApp()
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool installApk(const char *path)
|
||||||
|
{
|
||||||
|
if (path == nullptr || path[0] == '\0')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
// This may be called from Lua's main thread, but use the activity object
|
||||||
|
// class just like httpDownload so a future worker caller does not depend on
|
||||||
|
// the system JNI class loader finding the app class.
|
||||||
|
void *rawActivity = SDL_AndroidGetActivity();
|
||||||
|
if (rawActivity == nullptr)
|
||||||
|
return false;
|
||||||
|
jobject activityObj = (jobject) rawActivity;
|
||||||
|
jclass activity = env->GetObjectClass(activityObj);
|
||||||
|
env->DeleteLocalRef(activityObj);
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "installApk",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jpath = env->NewStringUTF(path);
|
||||||
|
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
|
||||||
|
env->DeleteLocalRef(jroot);
|
||||||
|
env->DeleteLocalRef(jpath);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||||
{
|
{
|
||||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ bool syncHealthSteps();
|
|||||||
**/
|
**/
|
||||||
bool restartApp();
|
bool restartApp();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a checksum-verified APK from the current save directory and starts
|
||||||
|
* Android's user-confirmed Package Installer flow. Android-only.
|
||||||
|
**/
|
||||||
|
bool installApk(const char *path);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||||
**/
|
**/
|
||||||
|
|||||||
@@ -245,6 +245,16 @@ bool System::restartApp() const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::installApk(const char *path) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::installApk(path);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(path);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ public:
|
|||||||
**/
|
**/
|
||||||
virtual bool restartApp() const;
|
virtual bool restartApp() const;
|
||||||
|
|
||||||
|
/** Starts Android's user-confirmed install flow for a verified APK. */
|
||||||
|
virtual bool installApk(const char *path) const;
|
||||||
|
|
||||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||||
virtual std::string getLaunchGame() const;
|
virtual std::string getLaunchGame() const;
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ int w_restartApp(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_installApk(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *path = luaL_checkstring(L, 1);
|
||||||
|
luax_pushboolean(L, instance()->installApk(path));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_httpDownload(lua_State *L)
|
int w_httpDownload(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *url = luaL_checkstring(L, 1);
|
const char *url = luaL_checkstring(L, 1);
|
||||||
@@ -325,6 +332,7 @@ static const luaL_Reg functions[] =
|
|||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
|
{ "installApk", w_installApk },
|
||||||
{ "updateShortcuts", w_updateShortcuts },
|
{ "updateShortcuts", w_updateShortcuts },
|
||||||
{ "getLaunchGame", w_getLaunchGame },
|
{ "getLaunchGame", w_getLaunchGame },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import android.app.AlarmManager;
|
|||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.ClipData;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
@@ -77,6 +78,7 @@ import android.view.*;
|
|||||||
|
|
||||||
import androidx.annotation.Keep;
|
import androidx.annotation.Keep;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.content.FileProvider;
|
||||||
|
|
||||||
public class GameActivity extends SDLActivity {
|
public class GameActivity extends SDLActivity {
|
||||||
private static DisplayMetrics metrics = null;
|
private static DisplayMetrics metrics = null;
|
||||||
@@ -398,11 +400,15 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
unregisterSecondaryDisplayListener();
|
unregisterSecondaryDisplayListener();
|
||||||
|
teardownSecondaryDisplay();
|
||||||
|
secondaryEnabled = false;
|
||||||
|
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||||
unregisterAudioDeviceCallback();
|
unregisterAudioDeviceCallback();
|
||||||
abandonAudioFocus();
|
abandonAudioFocus();
|
||||||
onHostDestroy();
|
onHostDestroy();
|
||||||
@@ -411,6 +417,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
@@ -426,6 +433,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
secondaryHostResumed = true;
|
||||||
onHostResume();
|
onHostResume();
|
||||||
requestGameAudioFocus();
|
requestGameAudioFocus();
|
||||||
registerAudioDeviceCallback();
|
registerAudioDeviceCallback();
|
||||||
@@ -690,6 +698,103 @@ public class GameActivity extends SDLActivity {
|
|||||||
return true; // unreachable, but keeps the JNI signature honest
|
return true; // unreachable, but keeps the JNI signature honest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a verified release APK in cache and asks Android's Package
|
||||||
|
* Installer to update this package. This never silently installs an APK:
|
||||||
|
* the platform owns both the unknown-sources consent and final install
|
||||||
|
* confirmation. `updateRoot` comes from the native save directory and is
|
||||||
|
* checked before any file is read, so a Lua caller cannot turn this into a
|
||||||
|
* general-purpose local-file sharing bridge.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean installApk(final String sourcePath, final String updateRoot) {
|
||||||
|
final GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null || sourcePath == null || updateRoot == null) return false;
|
||||||
|
final File source;
|
||||||
|
try {
|
||||||
|
source = new File(sourcePath).getCanonicalFile();
|
||||||
|
File root = new File(updateRoot, "updates").getCanonicalFile();
|
||||||
|
String rootPath = root.getPath() + File.separator;
|
||||||
|
if (!source.getPath().startsWith(rootPath)
|
||||||
|
|| !source.isFile() || source.length() == 0
|
||||||
|
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 8+ lets the user decide whether this app is trusted to
|
||||||
|
// request package installs. Send them to the per-app setting first;
|
||||||
|
// they deliberately tap Install again after granting it.
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 26
|
||||||
|
&& !self.getPackageManager().canRequestPackageInstalls()) {
|
||||||
|
try {
|
||||||
|
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:" + self.getPackageName()));
|
||||||
|
self.startActivity(settings);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copying an APK can be large; keep both I/O and checksum-verified
|
||||||
|
// source access off the UI thread. The FileProvider exposes this cache
|
||||||
|
// child only after it has been fully written and renamed.
|
||||||
|
new Thread(new Runnable() {
|
||||||
|
@Override public void run() {
|
||||||
|
File stagedDir = new File(self.getCacheDir(), "full-update");
|
||||||
|
File partial = new File(stagedDir, "update.apk.part");
|
||||||
|
File staged = new File(stagedDir, "update.apk");
|
||||||
|
try {
|
||||||
|
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
|
||||||
|
copyFile(source, partial);
|
||||||
|
if (staged.exists() && !staged.delete()) return;
|
||||||
|
if (!partial.renameTo(staged)) return;
|
||||||
|
self.runOnUiThread(new Runnable() {
|
||||||
|
@Override public void run() { launchPackageInstaller(self, staged); }
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (partial.exists()) partial.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "gen1recomp-apk-stage").start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copyFile(File source, File destination) throws IOException {
|
||||||
|
InputStream in = new BufferedInputStream(new FileInputStream(source));
|
||||||
|
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
|
||||||
|
try {
|
||||||
|
byte[] buffer = new byte[32768];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
|
||||||
|
} finally {
|
||||||
|
try { out.close(); } catch (IOException ignored) {}
|
||||||
|
try { in.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchPackageInstaller(GameActivity activity, File apk) {
|
||||||
|
try {
|
||||||
|
Context context = activity.getApplicationContext();
|
||||||
|
Uri uri = FileProvider.getUriForFile(context,
|
||||||
|
context.getPackageName() + ".full_update_provider", apk);
|
||||||
|
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
|
||||||
|
install.setData(uri);
|
||||||
|
install.setClipData(ClipData.newRawUri("apk", uri));
|
||||||
|
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
activity.startActivity(install);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public static String getLaunchGame() {
|
public static String getLaunchGame() {
|
||||||
return initialGame != null ? initialGame : "";
|
return initialGame != null ? initialGame : "";
|
||||||
@@ -1933,6 +2038,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
private static volatile long secondaryRetryAfter;
|
private static volatile long secondaryRetryAfter;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
|
private static volatile boolean secondaryHostResumed = false;
|
||||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||||
private static volatile int dualScreenDisplayMode = -1;
|
private static volatile int dualScreenDisplayMode = -1;
|
||||||
private static volatile byte[] secondaryFrame;
|
private static volatile byte[] secondaryFrame;
|
||||||
@@ -1963,7 +2069,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
self.runOnUiThread(new Runnable() {
|
self.runOnUiThread(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (on) {
|
if (on && secondaryHostResumed) {
|
||||||
self.refreshDualScreenDisplayMode();
|
self.refreshDualScreenDisplayMode();
|
||||||
self.registerSecondaryDisplayListener();
|
self.registerSecondaryDisplayListener();
|
||||||
rebindSecondaryDisplay();
|
rebindSecondaryDisplay();
|
||||||
@@ -2035,9 +2141,11 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void rebindSecondaryDisplay() {
|
private static void rebindSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
self.runOnUiThread(() -> {
|
self.runOnUiThread(() -> {
|
||||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (!secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
});
|
});
|
||||||
@@ -2045,7 +2153,8 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void setupSecondaryDisplay() {
|
private static void setupSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryPresentation != null
|
||||||
|| secondaryActivity != null || secondaryActivityPending
|
|| secondaryActivity != null || secondaryActivityPending
|
||||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.11",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13735190,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.2.10",
|
"version": "0.2.10",
|
||||||
"date": "2026-08-19",
|
"date": "2026-08-19",
|
||||||
|
|||||||
+39
-10
@@ -1,19 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||||
#
|
#
|
||||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
|
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
||||||
#
|
#
|
||||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||||
|
# --release build the production-signed release APK (requires the
|
||||||
|
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||||
# --package-only zip game.love + apply branding; skip gradle
|
# --package-only zip game.love + apply branding; skip gradle
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||||
# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653)
|
# - Android SDK + NDK (SDK API 36, NDK 25.2.9519653)
|
||||||
# - JDK 17
|
# - JDK 17
|
||||||
#
|
#
|
||||||
# Output (after gradle):
|
# Output (after gradle):
|
||||||
# dist/android/debug/*.apk (convenience copy)
|
# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ APP_NAME="gen1recomp"
|
|||||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_ANDROID_VERSION="11.5a"
|
LOVE_ANDROID_VERSION="11.5a"
|
||||||
NDK_VERSION="25.2.9519653"
|
NDK_VERSION="25.2.9519653"
|
||||||
|
ANDROID_API="36"
|
||||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||||
@@ -35,6 +37,7 @@ SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/br
|
|||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
|
RELEASE=false
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -44,11 +47,12 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--version) VERSION="$2"; shift ;;
|
--version) VERSION="$2"; shift ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
|
--release) RELEASE=true ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,20p' "$0"
|
sed -n '2,20p' "$0"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
|
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
@@ -62,7 +66,22 @@ if [ -n "$VERSION" ]; then
|
|||||||
rest="${VERSION#*.}"
|
rest="${VERSION#*.}"
|
||||||
minor="${rest%%.*}"
|
minor="${rest%%.*}"
|
||||||
patch="${rest##*.}"
|
patch="${rest##*.}"
|
||||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
# Reserve three digits for each lower component. This stays monotonic across
|
||||||
|
# 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside
|
||||||
|
# Android's signed 32-bit versionCode range for normal release versions.
|
||||||
|
if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then
|
||||||
|
fail "--version components exceed Android versionCode limits"
|
||||||
|
fi
|
||||||
|
VERSION_CODE=$((major * 1000000 + minor * 1000 + patch))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $RELEASE; then
|
||||||
|
for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \
|
||||||
|
GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do
|
||||||
|
[ -n "${!var:-}" ] || fail "--release requires $var"
|
||||||
|
done
|
||||||
|
[ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \
|
||||||
|
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- preconditions
|
# --------------------------------------------------------------- preconditions
|
||||||
@@ -389,13 +408,18 @@ require_android_sdk() {
|
|||||||
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
||||||
or create mobile/android/local.properties with:
|
or create mobile/android/local.properties with:
|
||||||
sdk.dir=/path/to/Android/sdk
|
sdk.dir=/path/to/Android/sdk
|
||||||
love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION
|
love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION
|
||||||
(see mobile/ANDROID.md)."
|
(see mobile/ANDROID.md)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export ANDROID_SDK_ROOT="$sdk"
|
export ANDROID_SDK_ROOT="$sdk"
|
||||||
export ANDROID_HOME="$sdk"
|
export ANDROID_HOME="$sdk"
|
||||||
|
|
||||||
|
if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then
|
||||||
|
fail "Android SDK platform android-$ANDROID_API is not installed.
|
||||||
|
Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager."
|
||||||
|
fi
|
||||||
|
|
||||||
local props="$ANDROID_DIR/local.properties"
|
local props="$ANDROID_DIR/local.properties"
|
||||||
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
||||||
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
||||||
@@ -412,7 +436,12 @@ require_android_sdk() {
|
|||||||
|
|
||||||
# --------------------------------------------------------------- gradle
|
# --------------------------------------------------------------- gradle
|
||||||
run_gradle() {
|
run_gradle() {
|
||||||
local task="assembleEmbedNoRecordDebug"
|
local variant="debug"
|
||||||
|
$RELEASE && variant="release"
|
||||||
|
# Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}).
|
||||||
|
local variant_title="Debug"
|
||||||
|
$RELEASE && variant_title="Release"
|
||||||
|
local task="assembleEmbedNoRecord$variant_title"
|
||||||
local build_dir="$ANDROID_DIR"
|
local build_dir="$ANDROID_DIR"
|
||||||
|
|
||||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||||
@@ -447,12 +476,12 @@ run_gradle() {
|
|||||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant"
|
||||||
if [ -d "$out_dir" ]; then
|
if [ -d "$out_dir" ]; then
|
||||||
say "APK output:"
|
say "APK output:"
|
||||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||||
|
|
||||||
local dist_dir="$DIST/debug"
|
local dist_dir="$DIST/$variant"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
mkdir -p "$dist_dir"
|
mkdir -p "$dist_dir"
|
||||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||||
|
|||||||
+21
-2
@@ -7,7 +7,8 @@
|
|||||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||||
#
|
#
|
||||||
# scripts/lint.sh lint src/
|
# scripts/lint.sh full advisory report over every shipped tree
|
||||||
|
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
|
||||||
# scripts/lint.sh src tools lint specific paths
|
# scripts/lint.sh src tools lint specific paths
|
||||||
#
|
#
|
||||||
# Install once with: luarocks install luacheck
|
# Install once with: luarocks install luacheck
|
||||||
@@ -15,9 +16,27 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
|
||||||
|
|
||||||
|
GATE=0
|
||||||
|
if [ "${1:-}" = "--gate" ]; then
|
||||||
|
GATE=1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v luacheck >/dev/null 2>&1; then
|
if ! command -v luacheck >/dev/null 2>&1; then
|
||||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
luacheck "${@:-src}"
|
if [ "$#" -gt 0 ]; then
|
||||||
|
PATHS=("$@")
|
||||||
|
else
|
||||||
|
PATHS=("${DEFAULT_PATHS[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$GATE" = "1" ]; then
|
||||||
|
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
|
||||||
|
fi
|
||||||
|
|
||||||
|
luacheck "${PATHS[@]}"
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ run_tier() {
|
|||||||
|
|
||||||
# ------- ROM-free tiers: these are what CI runs
|
# ------- ROM-free tiers: these are what CI runs
|
||||||
|
|
||||||
|
if command -v luacheck >/dev/null 2>&1; then
|
||||||
|
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
|
||||||
|
./scripts/lint.sh --gate
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
|
||||||
|
echo " luarocks install luacheck; CI installs and gates on it regardless)"
|
||||||
|
fi
|
||||||
|
|
||||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ local BattleSafety = {}
|
|||||||
local BATTLE_BUSY_FIELDS = {
|
local BATTLE_BUSY_FIELDS = {
|
||||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||||
|
"shrinkOut",
|
||||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+178
-39
@@ -538,6 +538,7 @@ end
|
|||||||
|
|
||||||
local function makeBattler(data, mon, isPlayer, save)
|
local function makeBattler(data, mon, isPlayer, save)
|
||||||
local def = data.pokemon[mon.species]
|
local def = data.pokemon[mon.species]
|
||||||
|
require("src.pokemon.Stats").ensure(def, mon)
|
||||||
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
||||||
local badges = nil
|
local badges = nil
|
||||||
if isPlayer and save then
|
if isPlayer and save then
|
||||||
@@ -1364,7 +1365,7 @@ function BattleState:updateQueue()
|
|||||||
-- subanimation (or just the coarse fx when animations are off).
|
-- subanimation (or just the coarse fx when animations are off).
|
||||||
-- item.hit carries the target's blink + damage sound, applied when
|
-- item.hit carries the target's blink + damage sound, applied when
|
||||||
-- the animation ends (hitRow rows carry a hit with no animation --
|
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||||
-- thrash/rage continuation turns that skip the announcement).
|
-- Mimic, whose animation waits on a successful copy).
|
||||||
if item.anim or item.hitRow then
|
if item.anim or item.hitRow then
|
||||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||||
@@ -1718,9 +1719,14 @@ function BattleState:enter()
|
|||||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||||
-- playerMonFainted queues on the battle screen; there is no battle
|
-- playerMonFainted queues on the battle screen; there is no battle
|
||||||
-- screen to queue them on here, so they print over the map.
|
-- screen to queue them on here, so they print over the map.
|
||||||
|
-- _PlayerBlackedOutText (no "2") extracts to the identical wording from
|
||||||
|
-- a different ROM address and is unused anywhere in this engine -- not
|
||||||
|
-- a fallback for this one, just pokered printing the same paragraph
|
||||||
|
-- from a second call site elsewhere.
|
||||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
self:romText("_PlayerBlackedOutText2",
|
||||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
|
||||||
|
blackedOut))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.musicKind = self:computeMusicKind()
|
self.musicKind = self:computeMusicKind()
|
||||||
@@ -1857,7 +1863,8 @@ function BattleState:enter()
|
|||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
end)
|
||||||
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||||
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
foeName, self.enemy.name))
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||||
@@ -2489,9 +2496,12 @@ function BattleState:openOldManBag()
|
|||||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||||
-- the Viridian tutorial and Oak's catch, has one.
|
-- the Viridian tutorial and Oak's catch, has one.
|
||||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||||
|
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
|
||||||
|
-- screen (engine/battle/core.asm:2210)
|
||||||
list = ListMenu.new(game, "ITEMS", {
|
list = ListMenu.new(game, "ITEMS", {
|
||||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||||
}, {
|
}, {
|
||||||
|
itemBox = true,
|
||||||
script = function(l)
|
script = function(l)
|
||||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||||
if l.scriptTimer == 81 then
|
if l.scriptTimer == 81 then
|
||||||
@@ -2702,9 +2712,11 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||||
-- outgoing pic and holds 50 frames before the mon is recalled
|
-- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
|
||||||
|
-- runs before the mon is recalled
|
||||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
Timing.SWITCH_PLAYER_MON)
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:queueRetreatAnim()
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
@@ -3316,6 +3328,26 @@ function BattleState:queueSendOutAnim(append)
|
|||||||
if append then self:act(fn) else self:actNext(fn) end
|
if append then self:act(fn) else self:actNext(fn) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
|
||||||
|
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
|
||||||
|
function BattleState:queueRetreatAnim()
|
||||||
|
if self:starterPikachuSendOut() then
|
||||||
|
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
|
||||||
|
self:waitNext(24)
|
||||||
|
self:actNext(function()
|
||||||
|
-- .clearScreenArea keeps the 7x7 area blank until the swap
|
||||||
|
-- (pokeyellow core.asm:1867-1871) (#1545)
|
||||||
|
self.sendingOut = true
|
||||||
|
self:slidePic("playerMon")
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
self:actNext(function()
|
||||||
|
self.shrinkOut = { battler = self.player, frame = 0 }
|
||||||
|
end)
|
||||||
|
self:waitNext(7)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||||
@@ -3532,6 +3564,12 @@ function BattleState:updateFx()
|
|||||||
self.growIn.frame = self.growIn.frame + 1
|
self.growIn.frame = self.growIn.frame + 1
|
||||||
if self.growIn.frame >= 12 then self.growIn = nil end
|
if self.growIn.frame >= 12 then self.growIn = nil end
|
||||||
end
|
end
|
||||||
|
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
|
||||||
|
-- 7x7 area holds cleared (scale 0) until the swap replaces the battler
|
||||||
|
if self.shrinkOut then
|
||||||
|
self.shrinkOut.frame = self.shrinkOut.frame + 1
|
||||||
|
if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end
|
||||||
|
end
|
||||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
@@ -3638,12 +3676,13 @@ function BattleState:executeAction(user, target, action)
|
|||||||
})
|
})
|
||||||
self.aiUses = self:aiUsesFor()
|
self.aiUses = self:aiUsesFor()
|
||||||
markSeen(self.game, self.enemy.mon.species)
|
markSeen(self.game, self.enemy.mon.species)
|
||||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
self.trainer.name, oldName))
|
||||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -3795,6 +3834,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
|||||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||||
screens = target })
|
screens = target })
|
||||||
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
||||||
|
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
|
||||||
|
self:animNext("POUND", not user.isPlayer)
|
||||||
self:clearVolatiles(user, true)
|
self:clearVolatiles(user, true)
|
||||||
self:applyDamage(user, dmg)
|
self:applyDamage(user, dmg)
|
||||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||||
@@ -3887,25 +3928,38 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.moveAnimRow = nil
|
self.moveAnimRow = nil
|
||||||
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
local thrashing = user.thrashTurns and moveInst == user.thrashMove
|
||||||
|
and user.thrashAnnounced or false
|
||||||
|
if thrashing then
|
||||||
|
-- .ThrashingAboutCheck (core.asm:3531-3552)
|
||||||
|
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
|
||||||
|
displayName(user)))
|
||||||
|
user.thrashTurns = user.thrashTurns - 1
|
||||||
|
if user.thrashTurns <= 0 then
|
||||||
|
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||||
|
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
|
||||||
|
end
|
||||||
|
else
|
||||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||||
-- the move's animation plays right after the announcement; the
|
end
|
||||||
-- damage path attaches the target's hit blink to this row so the
|
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
||||||
-- blink follows the animation (pokered's order). Mimic is the
|
-- = false) queues it from applyMimic after a successful copy
|
||||||
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
|
||||||
-- only after a successful copy, never on a miss -- applyMimic queues it
|
|
||||||
if not (record and record.announceAnim == false) then
|
if not (record and record.announceAnim == false) then
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
-- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577
|
||||||
|
self.moveAnimRow = { anim = thrashing and "THRASH" or move.id,
|
||||||
|
attackerIsPlayer = user.isPlayer }
|
||||||
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
Runtime.emit("battle.move_used", {
|
Runtime.emit("battle.move_used", {
|
||||||
battle = self, user = user, target = target, move = move,
|
battle = self, user = user, target = target, move = move,
|
||||||
isCalled = isCalled or false,
|
isCalled = isCalled or false,
|
||||||
})
|
})
|
||||||
|
|
||||||
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
||||||
|
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
|
||||||
|
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
|
||||||
|
ctx.thrashing = thrashing
|
||||||
|
|
||||||
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
||||||
-- already said its failure text
|
-- already said its failure text
|
||||||
@@ -3927,7 +3981,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
||||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||||
-- ChargeEffect sets INVULNERABLE for both)
|
-- ChargeEffect sets INVULNERABLE for both)
|
||||||
if record and record.charge and not releasing then
|
local chargeRequired = record and record.charge ~= nil and not releasing
|
||||||
|
if chargeRequired and Runtime.wantsHook("battle.charge_required") then
|
||||||
|
local required = Runtime.call("battle.charge_required", function(c)
|
||||||
|
return c.charge
|
||||||
|
end, {
|
||||||
|
battle = self, user = user, target = target, move = move,
|
||||||
|
charge = true, isCalled = isCalled or false,
|
||||||
|
})
|
||||||
|
chargeRequired = required ~= false
|
||||||
|
end
|
||||||
|
if chargeRequired then
|
||||||
self:cancelMoveAnim()
|
self:cancelMoveAnim()
|
||||||
user.charging = moveInst
|
user.charging = moveInst
|
||||||
user.chargeReady = true
|
user.chargeReady = true
|
||||||
@@ -4142,8 +4206,12 @@ function BattleState:onFaint(battler)
|
|||||||
-- acknowledged core.asm:797-798 bug.)
|
-- acknowledged core.asm:797-798 bug.)
|
||||||
self:actNext(function() self:playVictoryMusic() end)
|
self:actNext(function() self:playVictoryMusic() end)
|
||||||
end
|
end
|
||||||
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
|
||||||
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
-- passes the raw name -- displayName's separate Strings("Enemy %s", ...)
|
||||||
|
-- would double it up
|
||||||
|
self:sayNext(battler.isPlayer
|
||||||
|
and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name)
|
||||||
|
or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name))
|
||||||
if battler.isPlayer then
|
if battler.isPlayer then
|
||||||
self:act(function() self:playerMonFainted() end)
|
self:act(function() self:playerMonFainted() end)
|
||||||
else
|
else
|
||||||
@@ -4174,8 +4242,11 @@ function BattleState:awardExp()
|
|||||||
end
|
end
|
||||||
local function applyShare(mon, split, announce)
|
local function applyShare(mon, split, announce)
|
||||||
local playerId = self.game.save.player and self.game.save.player.id
|
local playerId = self.game.save.player and self.game.save.player.id
|
||||||
local traded = mon.traded == true
|
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
||||||
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
-- stored MON_OTID against wPlayerID every award; no persistent flag
|
||||||
|
-- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488
|
||||||
|
local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId)
|
||||||
|
or (mon.otId == nil and mon.traded == true))
|
||||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, traded)
|
split, traded)
|
||||||
@@ -4319,22 +4390,44 @@ function BattleState:enemyMonFainted()
|
|||||||
-- "X is" off so "about to use" stays above the name, instead of the
|
-- "X is" off so "about to use" stays above the name, instead of the
|
||||||
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
||||||
-- "change POKéMON?" with YES/NO.
|
-- "change POKéMON?" with YES/NO.
|
||||||
|
--
|
||||||
|
-- _TrainerAboutToUseText combines both \f-paged, but unlike
|
||||||
|
-- _ItemUseBallText00's say()+say() merge above, this is say()+
|
||||||
|
-- sayChoice(): tried merging into one romText/sayChoice call and
|
||||||
|
-- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that
|
||||||
|
-- the battle queue's own \f handling (not TextBox.lua's) does not
|
||||||
|
-- page a sayChoice string the same way -- left as two calls.
|
||||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||||
|
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
||||||
|
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
||||||
self:sayChoice(
|
self:sayChoice(
|
||||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||||
function(yes)
|
function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
local game = self.game
|
local game = self.game
|
||||||
Screens.push(game, "PartyMenu", {
|
local shiftOpts, reopenShift
|
||||||
|
reopenShift = function(text)
|
||||||
|
table.insert(self.queue, 1, { ui = function()
|
||||||
|
return self:buildScreen("PartyMenu", shiftOpts)
|
||||||
|
end })
|
||||||
|
table.insert(self.queue, 1, { text = text })
|
||||||
|
end
|
||||||
|
shiftOpts = {
|
||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon ~= self.player.mon and mon.hp > 0 then
|
if mon == self.player.mon then
|
||||||
|
reopenShift(self:romText("_AlreadyOutText",
|
||||||
|
"%s is\nalready out!", self.player.name))
|
||||||
|
elseif mon.hp <= 0 then
|
||||||
|
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||||
|
else
|
||||||
shiftSwitchMon = mon
|
shiftSwitchMon = mon
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
}
|
||||||
|
Screens.push(game, "PartyMenu", shiftOpts)
|
||||||
end, { box = Theme.trainerSwitchBox })
|
end, { box = Theme.trainerSwitchBox })
|
||||||
end
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
@@ -4362,7 +4455,8 @@ function BattleState:enemyMonFainted()
|
|||||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -4376,10 +4470,11 @@ function BattleState:enemyMonFainted()
|
|||||||
local mon = shiftSwitchMon
|
local mon = shiftSwitchMon
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||||
-- hold, then the recall and the send-out
|
-- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
|
||||||
self.nextInsert = 0
|
self.nextInsert = 0
|
||||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
Timing.SWITCH_PLAYER_MON)
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:queueRetreatAnim()
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||||
@@ -4451,9 +4546,22 @@ function BattleState:enemyMonFainted()
|
|||||||
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||||
-- prints once, so a `para` page carries no second copy (#566).
|
-- prints once, so a `para` page carries no second copy (#566).
|
||||||
local tag = self.trainer and self.trainer.name
|
local tag = self.trainer and self.trainer.name
|
||||||
|
-- the badge jingle (sound_get_item_1 and friends) rides the armed
|
||||||
|
-- line's first page, as the script's text command would (#1606)
|
||||||
|
local sfx = self.endBattleSound
|
||||||
|
local data = self.data
|
||||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||||
if page ~= "" then
|
if page ~= "" then
|
||||||
self:sayNext(tag and (tag .. ": " .. page) or page)
|
local line = tag and (tag .. ": " .. page) or page
|
||||||
|
if sfx then
|
||||||
|
local id = sfx
|
||||||
|
self:sayNextWaitSfx(line, function()
|
||||||
|
return require("src.core.Sound").play(data, id)
|
||||||
|
end)
|
||||||
|
sfx = nil
|
||||||
|
else
|
||||||
|
self:sayNext(line)
|
||||||
|
end
|
||||||
tag = nil
|
tag = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4878,7 +4986,8 @@ function BattleState:storeCaughtMon()
|
|||||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||||
-- the box rather than firing when the dex bit is set
|
-- the box rather than firing when the dex bit is set
|
||||||
self:sayNextWaitSfx(
|
self:sayNextWaitSfx(
|
||||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
self:romText("_ItemUseBallText06",
|
||||||
|
"New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||||
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
return self:buildScreen("DexEntryMenu", species)
|
return self:buildScreen("DexEntryMenu", species)
|
||||||
@@ -4899,9 +5008,12 @@ function BattleState:storeCaughtMon()
|
|||||||
if boxNum then
|
if boxNum then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||||
and "BILL's PC" or Strings("someone's PC")
|
self:sayNext(self:romText(
|
||||||
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
||||||
|
metBill and "%s was\ntransferred to\nBILL's PC!"
|
||||||
|
or "%s was\ntransferred to\nsomeone's PC!",
|
||||||
|
self.enemy.name))
|
||||||
else
|
else
|
||||||
self:sayNext(Strings("But every BOX\nis full!"))
|
self:sayNext(Strings("But every BOX\nis full!"))
|
||||||
end
|
end
|
||||||
@@ -5007,8 +5119,18 @@ function BattleState:throwBall(ball)
|
|||||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||||
-- so it is not a ghost battle any more (#444)
|
-- so it is not a ghost battle any more (#444)
|
||||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
|
||||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
-- TextBox.new() (which splits \f itself), the battle queue's own
|
||||||
|
-- startMessage() only splits on \n/\v -- confirmed live: the \f
|
||||||
|
-- landed mid-line and the second sentence overflowed off the box
|
||||||
|
-- instead of starting a fresh page. Resolve the label once, then
|
||||||
|
-- split it the same way TextBox.lua does and queue one sayNext per
|
||||||
|
-- page, so the two ROM sentences still render as two pages.
|
||||||
|
local dodgeText = self:romText("_ItemUseBallText00",
|
||||||
|
"It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!")
|
||||||
|
for page in (dodgeText .. "\f"):gmatch("(.-)\f") do
|
||||||
|
self:sayNext(page)
|
||||||
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
end)
|
end)
|
||||||
@@ -5055,10 +5177,14 @@ function BattleState:openParty()
|
|||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
|
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
||||||
if mon == self.player.mon then
|
if mon == self.player.mon then
|
||||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
self:say(self:romText("_AlreadyOutText",
|
||||||
|
"%s is\nalready out!", self.player.name))
|
||||||
|
self:act(function() self:openParty() end)
|
||||||
elseif mon.hp <= 0 then
|
elseif mon.hp <= 0 then
|
||||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||||
|
self:act(function() self:openParty() end)
|
||||||
else
|
else
|
||||||
self:resolveSwitch(mon)
|
self:resolveSwitch(mon)
|
||||||
end
|
end
|
||||||
@@ -5202,6 +5328,16 @@ function BattleState:growInScale(battler)
|
|||||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
|
||||||
|
-- (core.asm:1769-1796)
|
||||||
|
function BattleState:shrinkOutScale(battler)
|
||||||
|
local shrink = self.shrinkOut
|
||||||
|
if not shrink or shrink.battler ~= battler then return nil end
|
||||||
|
-- scale 0 past Delay3: the area stays cleared until the swap
|
||||||
|
-- (core.asm:1790-1796) (#1563)
|
||||||
|
return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0
|
||||||
|
end
|
||||||
|
|
||||||
-- battler hidden this frame? (damage blink)
|
-- battler hidden this frame? (damage blink)
|
||||||
--
|
--
|
||||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||||
@@ -5838,15 +5974,18 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|||||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||||
imagePathOf(self.player.sprite),
|
imagePathOf(self.player.sprite),
|
||||||
self.player.mon and self.player.mon.species)
|
self.player.mon and self.player.mon.species)
|
||||||
local gs = self:growInScale(self.player)
|
local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
|
||||||
if gs then
|
if gs then
|
||||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
||||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
||||||
-- pinned, mod scale composed with the grow stage
|
|
||||||
local eff = s * gs
|
local eff = s * gs
|
||||||
if eff > 0 then
|
if eff > 0 then
|
||||||
|
-- the retreat stages sit one tile right of the grow-in's
|
||||||
|
-- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563)
|
||||||
|
local shrinkX = self.shrinkOut
|
||||||
|
and self.shrinkOut.battler == self.player and 8 or 0
|
||||||
love.graphics.draw(img,
|
love.graphics.draw(img,
|
||||||
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||||
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -108,13 +108,17 @@ end
|
|||||||
|
|
||||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||||
-- stage keeps the original's exact check order and rng consumption
|
-- stage keeps the original's exact check order and rng consumption
|
||||||
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
-- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy ->
|
||||||
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
||||||
function EffectRegistry.runDamaging(battle, ctx, record)
|
function EffectRegistry.runDamaging(battle, ctx, record)
|
||||||
local user, target = ctx.user, ctx.target
|
local user, target = ctx.user, ctx.target
|
||||||
local move, moveInst = ctx.move, ctx.moveInst
|
local move, moveInst = ctx.move, ctx.moveInst
|
||||||
local neverMiss = record and record.neverMiss
|
local neverMiss = record and record.neverMiss
|
||||||
|
|
||||||
|
-- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before
|
||||||
|
-- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565)
|
||||||
|
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
||||||
|
|
||||||
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
||||||
-- SWIFT_EFFECT before the INVULNERABLE check)
|
-- SWIFT_EFFECT before the INVULNERABLE check)
|
||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
@@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
|
|
||||||
local hits = hitCount(ctx, record)
|
local hits = hitCount(ctx, record)
|
||||||
|
|
||||||
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
|
||||||
|
|
||||||
if not neverMiss then
|
if not neverMiss then
|
||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
@@ -221,7 +223,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
-- Mimic queues no announcement anim (announceAnim = false) -- a bare
|
||||||
-- hitRow carries the blink instead.
|
-- hitRow carries the blink instead.
|
||||||
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
||||||
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
||||||
|
|||||||
@@ -582,30 +582,15 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
THRASH_PETAL_DANCE_EFFECT = {
|
THRASH_PETAL_DANCE_EFFECT = {
|
||||||
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||||
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
-- (data/battle/special_effects.asm:22, core.asm:3531-3552)
|
||||||
beforeAccuracy = function(ctx)
|
beforeAccuracy = function(ctx)
|
||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
if not user.thrashTurns then
|
if ctx.thrashing or user.thrashTurns then return end
|
||||||
ctx.battle:animBeforeMove(
|
|
||||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
afterDamage = function(ctx)
|
|
||||||
local user = ctx.user
|
|
||||||
if not user.thrashTurns then
|
|
||||||
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
||||||
user.thrashMove = ctx.moveInst
|
user.thrashMove = ctx.moveInst
|
||||||
user.thrashAnnounced = true
|
user.thrashAnnounced = true
|
||||||
else
|
ctx.battle:animBeforeMove(
|
||||||
user.thrashTurns = user.thrashTurns - 1
|
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||||
if user.thrashTurns <= 0 then
|
|
||||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
|
||||||
if not user.confusedTurns then
|
|
||||||
user.confusedTurns = ctx.rng(2, 5)
|
|
||||||
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
JUMP_KICK_EFFECT = {
|
JUMP_KICK_EFFECT = {
|
||||||
@@ -660,7 +645,9 @@ MoveEffects.full = {
|
|||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
user.bideDamage = 0
|
||||||
ctx.battle:cancelMoveAnim()
|
ctx.battle:cancelMoveAnim()
|
||||||
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
ctx.battle:animBeforeMove(
|
||||||
|
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
|
||||||
|
user.isPlayer)
|
||||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|||||||
+265
-50
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
|||||||
-- be run from or Roared away.
|
-- be run from or Roared away.
|
||||||
Battle.BATTLETYPE_FORCESHINY = 7
|
Battle.BATTLETYPE_FORCESHINY = 7
|
||||||
Battle.BATTLETYPE_TRAP = 9
|
Battle.BATTLETYPE_TRAP = 9
|
||||||
|
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
||||||
|
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
||||||
|
Battle.BATTLETYPE_CANLOSE = 1
|
||||||
|
|
||||||
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
||||||
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
||||||
@@ -889,16 +892,27 @@ Battle.PRIORITY = {
|
|||||||
EFFECT_ENDURE = 3,
|
EFFECT_ENDURE = 3,
|
||||||
EFFECT_COUNTER = -1,
|
EFFECT_COUNTER = -1,
|
||||||
EFFECT_MIRROR_COAT = -1,
|
EFFECT_MIRROR_COAT = -1,
|
||||||
EFFECT_VITAL_THROW = -1,
|
EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
|
||||||
}
|
}
|
||||||
|
|
||||||
function Battle:movePriority(moveId)
|
function Battle:movePriority(moveId)
|
||||||
|
-- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z`
|
||||||
|
-- (engine/battle/core.asm:787-789).
|
||||||
|
if moveId == "VITAL_THROW" then return -1 end
|
||||||
local def = self:moveDef(moveId)
|
local def = self:moveDef(moveId)
|
||||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
|
||||||
|
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
|
||||||
|
|
||||||
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
||||||
function Battle:canAct(mon)
|
-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
|
||||||
|
local function clearBide(state)
|
||||||
|
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function checkTurn(self, mon, moveId)
|
||||||
local name = self:monName(mon)
|
local name = self:monName(mon)
|
||||||
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
||||||
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
||||||
@@ -919,7 +933,11 @@ function Battle:canAct(mon)
|
|||||||
local beforeMove = record and record.beforeMove
|
local beforeMove = record and record.beforeMove
|
||||||
if beforeMove
|
if beforeMove
|
||||||
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
||||||
return beforeMove(self, mon, name) and true or false
|
-- engine/battle/effect_commands.asm:188-200
|
||||||
|
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
|
||||||
|
local acted = beforeMove(self, mon, name) and true or false
|
||||||
|
if acted or not bypass then return acted end
|
||||||
|
beforeMove = nil
|
||||||
end
|
end
|
||||||
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
||||||
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
||||||
@@ -953,6 +971,14 @@ function Battle:canAct(mon)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- CantMove (engine/battle/effect_commands.asm:344-353) clears BIDE on every
|
||||||
|
-- arm of CheckPlayerTurn / CheckEnemyTurn that spends the turn.
|
||||||
|
function Battle:canAct(mon, moveId)
|
||||||
|
local acted = checkTurn(self, mon, moveId)
|
||||||
|
if not acted then clearBide(self:volatile(mon)) end
|
||||||
|
return acted
|
||||||
|
end
|
||||||
|
|
||||||
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
||||||
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
||||||
-- `.struggle` for the enemy). It lives in the move table like any other move
|
-- `.struggle` for the enemy). It lives in the move table like any other move
|
||||||
@@ -1228,10 +1254,15 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
|||||||
if opts.critical then
|
if opts.critical then
|
||||||
self:emit({ kind = "message", text = "A critical hit!" })
|
self:emit({ kind = "message", text = "A critical hit!" })
|
||||||
end
|
end
|
||||||
|
-- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608).
|
||||||
|
-- The cart breaks both across the box's two lines and hyphenates "super-"
|
||||||
|
-- to do it, and the not-very line ends on the single ellipsis glyph Gold's
|
||||||
|
-- charmap carries at $75, not three periods.
|
||||||
if opts.effectiveness and opts.effectiveness > 10 then
|
if opts.effectiveness and opts.effectiveness > 10 then
|
||||||
self:emit({ kind = "message", text = "It's super effective!" })
|
self:emit({ kind = "message", text = Strings("It's super-\neffective!") })
|
||||||
elseif opts.effectiveness and opts.effectiveness < 10 then
|
elseif opts.effectiveness and opts.effectiveness < 10 then
|
||||||
self:emit({ kind = "message", text = "It's not very effective..." })
|
self:emit({ kind = "message",
|
||||||
|
text = Strings("It's not very\neffective…") })
|
||||||
end
|
end
|
||||||
if endured then
|
if endured then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
@@ -1383,9 +1414,18 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
-- free of PP and obedience in exactly the same way.
|
-- free of PP and obedience in exactly the same way.
|
||||||
local rolling = state.rolloutLock == moveId
|
local rolling = state.rolloutLock == moveId
|
||||||
|
|
||||||
if not (charging or rampaging or rolling) then
|
-- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
|
||||||
|
-- engine/battle/move_effects/bide.asm:62-68
|
||||||
|
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:6222-6234, :949-951
|
||||||
|
local called = (self.copyDepth or 0) > 0
|
||||||
|
|
||||||
|
if not (charging or rampaging or rolling or biding or called) then
|
||||||
if move and (move.pp or 0) <= 0 then
|
if move and (move.pp or 0) <= 0 then
|
||||||
self:emit({ kind = "message", text = "No PP left for this move!" })
|
-- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315).
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = Strings("There's no PP left\nfor this move!") })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if move then move.pp = (move.pp or 1) - 1 end
|
if move then move.pp = (move.pp or 1) - 1 end
|
||||||
@@ -1465,9 +1505,38 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
|
||||||
|
if def.effect == "EFFECT_SLEEP_TALK" then
|
||||||
|
local picked
|
||||||
|
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
|
||||||
|
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
|
||||||
|
local pool = {}
|
||||||
|
for _, own in ipairs(attacker.moves or {}) do
|
||||||
|
local ownDef = self:moveDef(own.id)
|
||||||
|
local effect = ownDef and ownDef.effect
|
||||||
|
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
|
||||||
|
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
|
||||||
|
pool[#pool + 1] = own.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
|
||||||
|
end
|
||||||
|
if not picked then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.lastMove = nil
|
||||||
|
self.copyDepth = (self.copyDepth or 0) + 1
|
||||||
|
self:useMove(attacker, defender, picked)
|
||||||
|
self.copyDepth = self.copyDepth - 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Everything past here counts as "the user's last move" for Mirror Move,
|
-- Everything past here counts as "the user's last move" for Mirror Move,
|
||||||
-- Encore and Disable.
|
-- Encore and Disable. A called move skips the write
|
||||||
state.lastMove = moveId
|
-- (engine/battle/used_move_text.asm:30-36).
|
||||||
|
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
|
||||||
state.turnsTaken = (state.turnsTaken or 0) + 1
|
state.turnsTaken = (state.turnsTaken or 0) + 1
|
||||||
state.usedMoves = state.usedMoves or {}
|
state.usedMoves = state.usedMoves or {}
|
||||||
local seen = false
|
local seen = false
|
||||||
@@ -1488,6 +1557,15 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
||||||
charge = nil
|
charge = nil
|
||||||
end
|
end
|
||||||
|
if charge and not charging and Runtime.wantsHook("battle.charge_required") then
|
||||||
|
local required = Runtime.call("battle.charge_required", function(c)
|
||||||
|
return c.charge
|
||||||
|
end, {
|
||||||
|
battle = self, user = attacker, target = defender, move = def,
|
||||||
|
charge = true, isCalled = (self.copyDepth or 0) > 0,
|
||||||
|
})
|
||||||
|
if required == false then charge = nil end
|
||||||
|
end
|
||||||
if charge and not charging then
|
if charge and not charging then
|
||||||
state.chargeMove = moveId
|
state.chargeMove = moveId
|
||||||
state.vanished = charge.vanish or nil
|
state.vanished = charge.vanish or nil
|
||||||
@@ -1501,6 +1579,13 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
|
||||||
|
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
||||||
-- and fail outright when nothing of the right kind landed.
|
-- and fail outright when nothing of the right kind landed.
|
||||||
local counterKind = Effects.COUNTER[def.effect]
|
local counterKind = Effects.COUNTER[def.effect]
|
||||||
@@ -1732,8 +1817,12 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
landed = landed + 1
|
landed = landed + 1
|
||||||
end
|
end
|
||||||
if landed > 1 then
|
if landed > 1 then
|
||||||
self:emit({ kind = "message",
|
-- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
|
||||||
text = ("Hit %d time(s)!"):format(landed) })
|
-- are "Hit @ times!". Gen 2 has no singular form of this line, so the
|
||||||
|
-- plural stands even at one hit rather than the "(s)" this printed.
|
||||||
|
-- Gen 1 already says it this way (src/battle/EffectRegistry.lua,
|
||||||
|
-- _HitXTimesText).
|
||||||
|
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- move_effects/pay_day.asm:13
|
-- move_effects/pay_day.asm:13
|
||||||
@@ -1953,8 +2042,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
|
|||||||
if mine.perish and theirs.perish then return fail(self) end
|
if mine.perish and theirs.perish then return fail(self) end
|
||||||
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
||||||
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
||||||
|
-- StartPerishText (data/text/battle.asm:986). What shipped here was a
|
||||||
|
-- sentence no cart prints; the Gen 2 line names both sides and counts in
|
||||||
|
-- digits.
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = "All POKéMON hearing the song will faint in three turns!" })
|
text = Strings("Both POKéMON will\nfaint in 3 turns!") })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
||||||
@@ -2084,6 +2176,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
|
|||||||
local side = self:sideOf(defender)
|
local side = self:sideOf(defender)
|
||||||
if self.spikes[side] then return fail(self) end
|
if self.spikes[side] then return fail(self) end
|
||||||
self.spikes[side] = true
|
self.spikes[side] = true
|
||||||
|
-- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled
|
||||||
|
-- (`cont`) and carrying <TARGET>. The battle message path has no `cont`:
|
||||||
|
-- src/ui/gen2/BattleState.lua sets self.message straight from the event and
|
||||||
|
-- printMessage cuts past two rows, so the cart's line cannot be told here
|
||||||
|
-- yet without the name being dropped on screen. Left as it stands.
|
||||||
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2118,6 +2215,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
if not state.bideTurns then
|
if not state.bideTurns then
|
||||||
state.bideTurns = Effects.bideTurns(self.random)
|
state.bideTurns = Effects.bideTurns(self.random)
|
||||||
state.bideStored = 0
|
state.bideStored = 0
|
||||||
|
-- engine/battle/core.asm:574-576
|
||||||
|
state.bideMove = moveId
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " is storing energy!" })
|
text = self:monName(attacker) .. " is storing energy!" })
|
||||||
return
|
return
|
||||||
@@ -2129,7 +2228,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local damage = Effects.bideDamage(state.bideStored)
|
local damage = Effects.bideDamage(state.bideStored)
|
||||||
state.bideTurns, state.bideStored = nil, nil
|
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. " unleashed energy!" })
|
text = self:monName(attacker) .. " unleashed energy!" })
|
||||||
if damage <= 0 then return fail(self) end
|
if damage <= 0 then return fail(self) end
|
||||||
@@ -2319,7 +2418,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
|
|||||||
{ move = def, moveId = def and def.id })
|
{ move = def, moveId = def and def.id })
|
||||||
landed = landed + 1
|
landed = landed + 1
|
||||||
end
|
end
|
||||||
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) })
|
-- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can
|
||||||
|
-- land exactly once, which is the case the cart still prints as "times".
|
||||||
|
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
||||||
@@ -2402,11 +2503,14 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
|||||||
self.enemyIndex = target
|
self.enemyIndex = target
|
||||||
self.enemy = party[target]
|
self.enemy = party[target]
|
||||||
self.enemy.volatile = carried
|
self.enemy.volatile = carried
|
||||||
|
-- engine/battle/move_effects/baton_pass.asm:59
|
||||||
|
self:resetParticipants()
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = side,
|
local sent = side == "player" and self.player or self.enemy
|
||||||
mon = side == "player" and self.player or self.enemy,
|
self:emit({ kind = "send", side = side, mon = sent,
|
||||||
text = "Go! " .. self:monName(side == "player" and self.player
|
hp = sent.hp or 0, status = sent.status or false,
|
||||||
or self.enemy) .. "!" })
|
level = sent.level, experience = sent.experience,
|
||||||
|
text = "Go! " .. self:monName(sent) .. "!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
||||||
@@ -2663,8 +2767,12 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
|||||||
self.enemyIndex = pick
|
self.enemyIndex = pick
|
||||||
self.enemy = incoming
|
self.enemy = incoming
|
||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
|
-- ForceEnemySwitch (engine/battle/core.asm:2937)
|
||||||
|
self:resetParticipants()
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||||
|
hp = incoming.hp or 0, status = incoming.status or false,
|
||||||
|
level = incoming.level, experience = incoming.experience,
|
||||||
text = self:monName(incoming) .. " was dragged out!" })
|
text = self:monName(incoming) .. " was dragged out!" })
|
||||||
self:breakTrapsOnSend(incoming)
|
self:breakTrapsOnSend(incoming)
|
||||||
self:spikesDamage(incoming)
|
self:spikesDamage(incoming)
|
||||||
@@ -3070,6 +3178,12 @@ end
|
|||||||
|
|
||||||
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
||||||
function Battle:resolveFaints()
|
function Battle:resolveFaints()
|
||||||
|
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
|
||||||
|
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
|
||||||
|
self.participantsCleared = self.player
|
||||||
|
if self.playerIndex then self.participants[self.playerIndex] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
if (self.enemy.hp or 0) <= 0 then
|
if (self.enemy.hp or 0) <= 0 then
|
||||||
self:emit({ kind = "faint", side = "enemy",
|
self:emit({ kind = "faint", side = "enemy",
|
||||||
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
||||||
@@ -3084,6 +3198,7 @@ function Battle:resolveFaints()
|
|||||||
if self.trainer then
|
if self.trainer then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
||||||
|
self:printWinLossText("win")
|
||||||
self:awardPrizeMoney()
|
self:awardPrizeMoney()
|
||||||
end
|
end
|
||||||
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
||||||
@@ -3110,6 +3225,8 @@ function Battle:resolveFaints()
|
|||||||
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
replacement = true,
|
replacement = true,
|
||||||
|
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
||||||
|
level = self.enemy.level, experience = self.enemy.experience,
|
||||||
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
@@ -3152,6 +3269,11 @@ function Battle:resolveFaints()
|
|||||||
local nextIndex = Battle.firstHealthy(self.party)
|
local nextIndex = Battle.firstHealthy(self.party)
|
||||||
if not nextIndex then
|
if not nextIndex then
|
||||||
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
||||||
|
-- LostBattle (engine/battle/core.asm:2763-2782): only BATTLETYPE_CANLOSE
|
||||||
|
-- reaches PrintWinLossText on a loss; every other loss whites out.
|
||||||
|
if self.battleType == Battle.BATTLETYPE_CANLOSE then
|
||||||
|
self:printWinLossText("lose")
|
||||||
|
end
|
||||||
self:endBattle("lose")
|
self:endBattle("lose")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -3177,14 +3299,23 @@ function Battle:resolveFaints()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
|
-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
|
||||||
-- and the frontpic slide: the four quarters are dealt between the wallet and
|
-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
|
||||||
-- Mom's savings and then one StdBattleTextbox names the figure.
|
function Battle:printWinLossText(result)
|
||||||
--
|
local trainer = self.trainer
|
||||||
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
|
if not trainer then return end
|
||||||
-- PrintWinLossText is the trainer's own after-battle line, which this port
|
-- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
|
||||||
-- runs from the script on the way out of the battle rather than from here.
|
-- the slide (engine/battle/core.asm:2310, :2320-2323).
|
||||||
-- The payout is not gated on it either way.
|
-- The CANLOSE loss arm runs ClearBox first (:2770-2773).
|
||||||
|
self:emit({ kind = "trainer-return", cleared = result == "lose" or nil })
|
||||||
|
local text = (result == "lose") and trainer.lossText or trainer.winText
|
||||||
|
if type(text) ~= "string" or text == "" then return end
|
||||||
|
-- FarPrintText prints the pointer alone: no trainer-name tag in front of
|
||||||
|
-- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355).
|
||||||
|
self:emit({ kind = "win-text", text = text })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- WinTrainerBattle's money arm (engine/battle/core.asm:2310-2323)
|
||||||
function Battle:awardPrizeMoney()
|
function Battle:awardPrizeMoney()
|
||||||
local save = self.save
|
local save = self.save
|
||||||
if not (save and save.player) then return nil end
|
if not (save and save.player) then return nil end
|
||||||
@@ -3237,7 +3368,16 @@ end
|
|||||||
-- `count` is the pass's own divisor -- the participant count for the first
|
-- `count` is the pass's own divisor -- the participant count for the first
|
||||||
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
||||||
-- any Share holder taxed the whole pool.
|
-- any Share holder taxed the whole pool.
|
||||||
function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
--
|
||||||
|
-- `silent` suppresses only the GainedText line. It exists for the
|
||||||
|
-- battle.exp_award seam below, where a mod paying the bench wants one summary
|
||||||
|
-- line rather than a box per mon; the cart's own two passes never pass it, so
|
||||||
|
-- vanilla prints exactly what it always did. Everything else about the pass
|
||||||
|
-- -- the exp, the stat exp, battle.exp_gained, "grew to level", learned moves
|
||||||
|
-- and the forget prompt -- is unaffected, because a silent award is still an
|
||||||
|
-- award.
|
||||||
|
function Battle:giveExperiencePass(loser, def, recipients, count, halved,
|
||||||
|
silent)
|
||||||
for _, index in ipairs(recipients) do
|
for _, index in ipairs(recipients) do
|
||||||
local mon = self.party[index]
|
local mon = self.party[index]
|
||||||
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
||||||
@@ -3288,10 +3428,12 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
|||||||
index = index,
|
index = index,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
if not silent then
|
||||||
self:emit({ kind = "experience", index = index, amount = amount,
|
self:emit({ kind = "experience", index = index, amount = amount,
|
||||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||||
text = self:monName(mon) .. " gained "
|
text = self:monName(mon) .. " gained "
|
||||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||||
|
end
|
||||||
if result.levels > 0 then
|
if result.levels > 0 then
|
||||||
-- "level up happiness mod", the cart's own comment, sitting right
|
-- "level up happiness mod", the cart's own comment, sitting right
|
||||||
-- after the stat recalc and before the "grew to level" text. It fires
|
-- after the stat recalc and before the "grew to level" text. It fires
|
||||||
@@ -3306,7 +3448,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
|||||||
local moveDef = self:moveDef(moveId)
|
local moveDef = self:moveDef(moveId)
|
||||||
local moveName = (moveDef and moveDef.name) or moveId
|
local moveName = (moveDef and moveDef.name) or moveId
|
||||||
if ok then
|
if ok then
|
||||||
|
-- data/text/common_3.asm:119
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
|
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||||
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
||||||
elseif reason == "full" then
|
elseif reason == "full" then
|
||||||
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
||||||
@@ -3385,22 +3529,38 @@ function Battle:awardExperience(loser)
|
|||||||
|
|
||||||
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
||||||
-- with the same ctx: the participant COUNT, the live participants, and an
|
-- with the same ctx: the participant COUNT, the live participants, and an
|
||||||
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
|
-- applyShare(mon, split, announce) a mod can call to pay one mon its own
|
||||||
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
|
-- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
|
||||||
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
--
|
||||||
-- accepted and ignored rather than changing what is printed. `recipients`,
|
-- `announce` is Gen 1's third argument (src/battle/BattleState.lua
|
||||||
-- `holders` and `halved` are the Gen 2 additions.
|
-- applyShare) and means the same thing here: truthy prints the mon's
|
||||||
|
-- GainedText, falsy pays it silently. That is what lets one mod source
|
||||||
|
-- print ONE summary line for a party-wide award on both generations instead
|
||||||
|
-- of a box per mon -- which is what the Exp Share mod documents and could
|
||||||
|
-- not do on Gold, because this argument used to be accepted and ignored.
|
||||||
|
--
|
||||||
|
-- It is honoured only when it is actually PASSED, by argument count rather
|
||||||
|
-- than by value. A Gen 2-era mod calling applyShare(mon, split) was written
|
||||||
|
-- against a seam that always announced and keeps announcing; a caller that
|
||||||
|
-- passes the argument -- including an explicit nil, which is what a "pay
|
||||||
|
-- this one quietly" call looks like -- gets Gen 1's reading. So no existing
|
||||||
|
-- mod changes behaviour, and a mod that opts in gets parity.
|
||||||
if Runtime.wantsHook("battle.exp_award") then
|
if Runtime.wantsHook("battle.exp_award") then
|
||||||
local alive = {}
|
local alive = {}
|
||||||
for _, index in ipairs(participants) do
|
for _, index in ipairs(participants) do
|
||||||
local mon = self.party[index]
|
local mon = self.party[index]
|
||||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||||
end
|
end
|
||||||
local function applyShare(mon, split)
|
local function applyShare(mon, split, ...)
|
||||||
|
local announce = ...
|
||||||
|
-- select("#") counts an explicit nil; `announce == nil` alone could not
|
||||||
|
-- tell applyShare(mon, split) from applyShare(mon, split, nil), and
|
||||||
|
-- those two have to mean different things here.
|
||||||
|
local silent = select("#", ...) > 0 and not announce
|
||||||
for index, candidate in ipairs(self.party) do
|
for index, candidate in ipairs(self.party) do
|
||||||
if candidate == mon then
|
if candidate == mon then
|
||||||
return self:giveExperiencePass(loser, def, { index },
|
return self:giveExperiencePass(loser, def, { index },
|
||||||
math.max(1, split or 1), halved)
|
math.max(1, split or 1), halved, silent)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3415,8 +3575,7 @@ function Battle:awardExperience(loser)
|
|||||||
|
|
||||||
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||||
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||||
self.participants = {}
|
self:resetParticipants()
|
||||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||||
@@ -3437,7 +3596,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
|
|||||||
end
|
end
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
||||||
|
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
|
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||||
text = self:monName(mon) .. " learned "
|
text = self:monName(mon) .. " learned "
|
||||||
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
||||||
-- The forget path writes the slot itself rather than going through
|
-- The forget path writes the slot itself rather than going through
|
||||||
@@ -3476,6 +3637,13 @@ function Battle:switchLocked()
|
|||||||
return self:volatile(self.enemy).trapsTarget == true
|
return self:volatile(self.enemy).trapsTarget == true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||||
|
-- (engine/battle/core.asm:3033 and :3037).
|
||||||
|
function Battle:resetParticipants()
|
||||||
|
self.participants = {}
|
||||||
|
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||||
|
end
|
||||||
|
|
||||||
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
||||||
-- (engine/battle/core.asm:2959-2961).
|
-- (engine/battle/core.asm:2959-2961).
|
||||||
function Battle:shiftSwitch(index)
|
function Battle:shiftSwitch(index)
|
||||||
@@ -3498,6 +3666,7 @@ function Battle:switch(index)
|
|||||||
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
||||||
-- announce its own faint again; see resolveFaints.
|
-- announce its own faint again; see resolveFaints.
|
||||||
self.faintAnnounced = nil
|
self.faintAnnounced = nil
|
||||||
|
self.participantsCleared = nil
|
||||||
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
||||||
self.pendingSwitch = nil
|
self.pendingSwitch = nil
|
||||||
self.player = mon
|
self.player = mon
|
||||||
@@ -3505,6 +3674,8 @@ function Battle:switch(index)
|
|||||||
self.participants[index] = true
|
self.participants[index] = true
|
||||||
self.stages.player = Battle.newStages()
|
self.stages.player = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "player", mon = mon,
|
self:emit({ kind = "send", side = "player", mon = mon,
|
||||||
|
hp = mon.hp or 0, status = mon.status or false,
|
||||||
|
level = mon.level, experience = mon.experience,
|
||||||
text = "Go! " .. self:monName(mon) .. "!" })
|
text = "Go! " .. self:monName(mon) .. "!" })
|
||||||
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
||||||
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
||||||
@@ -3733,19 +3904,41 @@ function Battle:lockedInMove(mon)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
|
||||||
|
-- at :5650
|
||||||
|
function Battle:fightLockedMove(mon)
|
||||||
|
local state = self:volatile(mon)
|
||||||
|
if state.bideTurns then return state.bideMove end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/battle/core.asm:627-629
|
||||||
|
function Battle:cancelBide(mon)
|
||||||
|
clearBide(self:volatile(mon))
|
||||||
|
end
|
||||||
|
|
||||||
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
||||||
-- (to grey out the move list) and by the enemy's own choice below.
|
-- (to grey out the move list) and by the enemy's own choice below.
|
||||||
|
-- engine/battle/core.asm:561-566
|
||||||
|
local function encoredMove(state, mon)
|
||||||
|
if not state.encore then return nil end
|
||||||
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
|
if move.id == state.encore and (move.pp or 0) > 0 then
|
||||||
|
return state.encore
|
||||||
|
end
|
||||||
|
end
|
||||||
|
state.encore, state.encoreTurns = nil, nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
function Battle:forcedMove(mon)
|
function Battle:forcedMove(mon)
|
||||||
local locked = self:lockedInMove(mon)
|
local locked = self:lockedInMove(mon)
|
||||||
if locked then return locked end
|
if locked then return locked end
|
||||||
local state = self:volatile(mon)
|
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
|
||||||
if not state.encore then return nil end
|
-- (engine/battle/core.asm:561-566).
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
local encored = encoredMove(self:volatile(mon), mon)
|
||||||
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
|
if encored then return encored end
|
||||||
end
|
return self:fightLockedMove(mon)
|
||||||
-- Encore ends early when the move runs out of PP.
|
|
||||||
state.encore, state.encoreTurns = nil, nil
|
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Battle:moveDisabled(mon, moveId)
|
function Battle:moveDisabled(mon, moveId)
|
||||||
@@ -3759,8 +3952,9 @@ function Battle:usableMoves(mon)
|
|||||||
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
||||||
-- that spent its last PP on the opening turn keeps running: no later turn
|
-- that spent its last PP on the opening turn keeps running: no later turn
|
||||||
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
||||||
-- ends it the moment the encored move runs dry.
|
-- ends it the moment the encored move runs dry. Bide is exempt too:
|
||||||
local locked = self:lockedInMove(mon)
|
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
|
||||||
|
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
|
||||||
local out = {}
|
local out = {}
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
||||||
@@ -3962,6 +4156,8 @@ function Battle:enemyTrySwitchOrItem()
|
|||||||
.. self:monName(outgoing) .. "!" })
|
.. self:monName(outgoing) .. "!" })
|
||||||
self.enemyIndex = target
|
self.enemyIndex = target
|
||||||
self.enemy = self.enemyParty[target]
|
self.enemy = self.enemyParty[target]
|
||||||
|
-- AI_Switch (engine/battle/ai/items.asm:697)
|
||||||
|
self:resetParticipants()
|
||||||
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
||||||
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
||||||
-- the mon coming IN starts from an empty area -- the same pair of clears
|
-- the mon coming IN starts from an empty area -- the same pair of clears
|
||||||
@@ -3969,6 +4165,8 @@ function Battle:enemyTrySwitchOrItem()
|
|||||||
self:clearVolatile(self.enemy)
|
self:clearVolatile(self.enemy)
|
||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
|
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
||||||
|
level = self.enemy.level, experience = self.enemy.experience,
|
||||||
text = (self.trainer.name or "TRAINER") .. " sent out "
|
text = (self.trainer.name or "TRAINER") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
@@ -4050,9 +4248,16 @@ function Battle:vanillaEnemyMove()
|
|||||||
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
||||||
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
||||||
-- died there, and no amount of grinding could ever have got past it.
|
-- died there, and no amount of grinding could ever have got past it.
|
||||||
local charged = self:volatile(self.enemy).chargeMove
|
local enemyState = self:volatile(self.enemy)
|
||||||
|
local charged = enemyState.chargeMove
|
||||||
if charged then return charged end
|
if charged then return charged end
|
||||||
|
|
||||||
|
-- engine/battle/core.asm:5524-5533: the encore arm runs ahead of
|
||||||
|
-- CheckEnemyLockedIn (:5650).
|
||||||
|
local encored = encoredMove(enemyState, self.enemy)
|
||||||
|
if encored then return encored end
|
||||||
|
if enemyState.bideTurns then return enemyState.bideMove end
|
||||||
|
|
||||||
-- Encore and Disable narrow the pool before the AI ever scores it.
|
-- Encore and Disable narrow the pool before the AI ever scores it.
|
||||||
local moves = self:usableMoves(self.enemy)
|
local moves = self:usableMoves(self.enemy)
|
||||||
if #moves == 0 then
|
if #moves == 0 then
|
||||||
@@ -4146,6 +4351,7 @@ local function runTurn(self, action)
|
|||||||
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
||||||
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
||||||
if self.runRefused then return self:takeEvents() end
|
if self.runRefused then return self:takeEvents() end
|
||||||
|
self:cancelBide(self.player)
|
||||||
action = { kind = "skip" }
|
action = { kind = "skip" }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4161,6 +4367,9 @@ local function runTurn(self, action)
|
|||||||
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
||||||
Happiness.change(self.player, "USEDXITEM")
|
Happiness.change(self.player, "USEDXITEM")
|
||||||
end
|
end
|
||||||
|
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
|
||||||
|
-- instead and keeps the store.
|
||||||
|
if action.kind == "item" then self:cancelBide(self.player) end
|
||||||
|
|
||||||
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
||||||
-- to rotate or drink a potion spends its whole turn on it.
|
-- to rotate or drink a potion spends its whole turn on it.
|
||||||
@@ -4209,7 +4418,6 @@ local function runTurn(self, action)
|
|||||||
|
|
||||||
local function playerAttack()
|
local function playerAttack()
|
||||||
if action.kind ~= "move" then return end
|
if action.kind ~= "move" then return end
|
||||||
if not self:canAct(self.player) then return end
|
|
||||||
local move = action.move
|
local move = action.move
|
||||||
-- An encored mon has no choice, whatever the menu said.
|
-- An encored mon has no choice, whatever the menu said.
|
||||||
local forced = self:forcedMove(self.player)
|
local forced = self:forcedMove(self.player)
|
||||||
@@ -4229,13 +4437,20 @@ local function runTurn(self, action)
|
|||||||
-- player's own mon spends the rest of the battle underground.
|
-- player's own mon spends the rest of the battle underground.
|
||||||
local stored = self:volatile(self.player).chargeMove
|
local stored = self:volatile(self.player).chargeMove
|
||||||
if stored then move = stored end
|
if stored then move = stored end
|
||||||
|
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
|
||||||
|
-- engine/battle/effect_commands.asm:193 reads it.
|
||||||
|
if not self:canAct(self.player, move) then return end
|
||||||
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
||||||
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
||||||
-- Struggle substitution and the obedience roll the same way the second
|
-- Struggle substitution and the obedience roll the same way the second
|
||||||
-- half of a charge move is.
|
-- half of a charge move is.
|
||||||
local charging = self:volatile(self.player).chargeMove == move
|
local charging = self:volatile(self.player).chargeMove == move
|
||||||
or self:lockedInMove(self.player) == move
|
or self:lockedInMove(self.player) == move
|
||||||
if not charging and not self:hasUsableMoves(self.player) then
|
-- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
|
||||||
|
-- `checkobedience`.
|
||||||
|
local bideLocked = self:fightLockedMove(self.player) == move
|
||||||
|
if not charging and not bideLocked
|
||||||
|
and not self:hasUsableMoves(self.player) then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(self.player) .. " has no moves left!" })
|
text = self:monName(self.player) .. " has no moves left!" })
|
||||||
move = Battle.STRUGGLE
|
move = Battle.STRUGGLE
|
||||||
@@ -4273,7 +4488,7 @@ local function runTurn(self, action)
|
|||||||
-- against a trainer, could not be escaped either.
|
-- against a trainer, could not be escaped either.
|
||||||
enemyMoveId = Battle.STRUGGLE
|
enemyMoveId = Battle.STRUGGLE
|
||||||
end
|
end
|
||||||
if not self:canAct(self.enemy) then return end
|
if not self:canAct(self.enemy, enemyMoveId) then return end
|
||||||
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
||||||
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
||||||
if self:moveDisabled(self.enemy, enemyMoveId) then
|
if self:moveDisabled(self.enemy, enemyMoveId) then
|
||||||
|
|||||||
+22
-16
@@ -609,9 +609,10 @@ function Game:draw()
|
|||||||
-- ...and for the same reason the UI's own scale has to know the world is
|
-- ...and for the same reason the UI's own scale has to know the world is
|
||||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||||
-- gated on this frame's world pass -- which the party menu and the bag end
|
-- gated on this frame's world pass -- which the party menu ends by being
|
||||||
-- by being opaque. Without this hold they lose the step-down and blit at
|
-- opaque (the bag's item box shows the map around it, #1521). Without
|
||||||
-- full fit scale over a battle drawn at the zoomed-out one.
|
-- this hold it loses the step-down and blits at full fit scale over a
|
||||||
|
-- battle drawn at the zoomed-out one.
|
||||||
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
||||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||||
-- instead of letting them dock to the window edge.
|
-- instead of letting them dock to the window edge.
|
||||||
@@ -918,16 +919,7 @@ function Game:gamepadaxis(joystick, axis, value)
|
|||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
local isAccelerometer = GamepadMap.isAccelerometer
|
||||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
|
||||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
|
||||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
|
||||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
|
||||||
-- never as "* Accelerometer".
|
|
||||||
local function isAccelerometer(joystick)
|
|
||||||
local name = joystick and joystick.getName and joystick:getName()
|
|
||||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
||||||
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
||||||
@@ -982,7 +974,11 @@ end
|
|||||||
-- parked the player until every direction was re-pressed (#799).
|
-- parked the player until every direction was re-pressed (#799).
|
||||||
function Game:focus(f)
|
function Game:focus(f)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
if f then Input:reconcile() end
|
if f then
|
||||||
|
Input:reconcile()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
|
end
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
end
|
end
|
||||||
@@ -1002,6 +998,8 @@ function Game:onResume()
|
|||||||
Input:reconcile()
|
Input:reconcile()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||||
@@ -1209,18 +1207,26 @@ end
|
|||||||
|
|
||||||
function Game:syncEngine()
|
function Game:syncEngine()
|
||||||
if self._syncOff then return nil end
|
if self._syncOff then return nil end
|
||||||
if self._syncEngineRef then return self._syncEngineRef end
|
local eng = self._syncEngineRef
|
||||||
|
if not eng then
|
||||||
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||||
if not ok or type(SyncEngine) ~= "table" then
|
if not ok or type(SyncEngine) ~= "table" then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local eng = SyncEngine.shared()
|
eng = SyncEngine.shared()
|
||||||
if not eng then
|
if not eng then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
self._syncEngineRef = eng
|
self._syncEngineRef = eng
|
||||||
|
end
|
||||||
|
if type(eng.protectPlaythrough) == "function" then
|
||||||
|
local meta = self.save and self.save.meta
|
||||||
|
eng:protectPlaythrough(
|
||||||
|
(self.save and self.save.version) or require("src.core.GameVersion").get(),
|
||||||
|
type(meta) == "table" and meta.playthroughId or nil)
|
||||||
|
end
|
||||||
return eng
|
return eng
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+121
-30
@@ -18,6 +18,7 @@ local Chrome = require("src.ui.gen2.Chrome")
|
|||||||
local Clock = require("src.core.gen2.Clock")
|
local Clock = require("src.core.gen2.Clock")
|
||||||
local FixedStep = require("src.core.FixedStep")
|
local FixedStep = require("src.core.FixedStep")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local GamepadMap = require("src.core.GamepadMap")
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Save = require("src.core.gen2.Save")
|
local Save = require("src.core.gen2.Save")
|
||||||
@@ -57,20 +58,6 @@ Game2.__index = Game2
|
|||||||
|
|
||||||
local function noop() end
|
local function noop() end
|
||||||
|
|
||||||
for _, name in ipairs({
|
|
||||||
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
|
||||||
"joystickadded",
|
|
||||||
}) do
|
|
||||||
Game2[name] = noop
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Not a noop, because the overlay has to come back on its own: a player who
|
|
||||||
-- unplugs the only controller would otherwise have to tap a blind screen to
|
|
||||||
-- get the pad back (src/core/Game.lua:869 does the same).
|
|
||||||
function Game2:joystickremoved()
|
|
||||||
TouchControls:joystickremoved()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- THE FRAME AND INPUT SEAMS.
|
-- THE FRAME AND INPUT SEAMS.
|
||||||
--
|
--
|
||||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||||
@@ -496,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
|||||||
if onDone then onDone(learned) end
|
if onDone then onDone(learned) end
|
||||||
end
|
end
|
||||||
if ok then
|
if ok then
|
||||||
|
-- data/text/common_3.asm:119
|
||||||
return self:say(("%s learned\n%s!"):format(name, moveName),
|
return self:say(("%s learned\n%s!"):format(name, moveName),
|
||||||
function() finish(true) end)
|
function() finish(true) end,
|
||||||
|
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||||
end
|
end
|
||||||
if reason ~= "full" then return finish(false) end
|
if reason ~= "full" then return finish(false) end
|
||||||
local askForget, pickMove, askStop
|
local askForget, pickMove, askStop
|
||||||
@@ -552,9 +541,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
|||||||
-- The slot is written here rather than through Mon.learnMove, so
|
-- The slot is written here rather than through Mon.learnMove, so
|
||||||
-- pokemon.move_learned is raised here too.
|
-- pokemon.move_learned is raised here too.
|
||||||
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||||
|
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||||
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
||||||
:format(name, oldName, name, moveName),
|
:format(name, oldName, name, moveName),
|
||||||
function() finish(true) end)
|
function() finish(true) end,
|
||||||
|
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -719,7 +710,9 @@ function Game2:usePartyItem(itemId)
|
|||||||
})
|
})
|
||||||
elseif action == "candy" then
|
elseif action == "candy" then
|
||||||
self:consumeItem(itemId)
|
self:consumeItem(itemId)
|
||||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
-- data/text/common_1.asm:86
|
||||||
|
self:say(result.text, function() self:afterRareCandy(mon, result) end,
|
||||||
|
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
|
||||||
else
|
else
|
||||||
self:consumeItem(itemId)
|
self:consumeItem(itemId)
|
||||||
self:say(result.text)
|
self:say(result.text)
|
||||||
@@ -778,8 +771,10 @@ function Game2:useSelectItem()
|
|||||||
local name = (items[itemId] and items[itemId].name) or itemId
|
local name = (items[itemId] and items[itemId].name) or itemId
|
||||||
self:say(Strings("{PLAYER} used the\n%s.", name))
|
self:say(Strings("{PLAYER} used the\n%s.", name))
|
||||||
elseif outcome == "trophy_sent" then
|
elseif outcome == "trophy_sent" then
|
||||||
|
-- data/text/common_3.asm:372
|
||||||
self:say(Strings(
|
self:say(Strings(
|
||||||
"There was a trophy\ninside!\fThe trophy was\nsent home."))
|
"There was a trophy\ninside!\fThe trophy was\nsent home."),
|
||||||
|
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||||
end
|
end
|
||||||
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
||||||
-- drives its own presentation off World:step -- nothing left to print here.
|
-- drives its own presentation off World:step -- nothing left to print here.
|
||||||
@@ -791,9 +786,9 @@ end
|
|||||||
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
||||||
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
||||||
-- was a silent extra pop.
|
-- was a silent extra pop.
|
||||||
function Game2:say(text, onDone)
|
function Game2:say(text, onDone, opts)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
self.stack:push(TextBox.new(self, text, onDone))
|
self.stack:push(TextBox.new(self, text, onDone, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The landmark the player is standing in, for the Pokegear map's marker.
|
-- The landmark the player is standing in, for the Pokegear map's marker.
|
||||||
@@ -948,6 +943,12 @@ function Game2:load()
|
|||||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||||
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
||||||
|
-- The engine's own strings, keyed by the disassembly's label. gen2Text
|
||||||
|
-- above is the script text and is keyed by bank:address for the overworld
|
||||||
|
-- VM, so the two are different tables and both are loaded. This one is
|
||||||
|
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
|
||||||
|
-- helper is shared with Gen 1 and looks up data.text[label].
|
||||||
|
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
|
||||||
-- data/generated/events.lua: the side tables a script command NAMES rather
|
-- data/generated/events.lua: the side tables a script command NAMES rather
|
||||||
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
||||||
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
||||||
@@ -1968,7 +1969,11 @@ function Game2:applyOptions()
|
|||||||
local options = self.options or {}
|
local options = self.options or {}
|
||||||
Music.applyOptions(options)
|
Music.applyOptions(options)
|
||||||
require("src.core.Sound").applyOptions(options)
|
require("src.core.Sound").applyOptions(options)
|
||||||
require("src.render.Zoom").applyOptions(options)
|
local Zoom = require("src.render.Zoom")
|
||||||
|
Zoom.applyOptions(options)
|
||||||
|
local caps = require("src.core.Performance").applyOptions(options)
|
||||||
|
Zoom.allowSurvey = caps.survey
|
||||||
|
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||||
require("src.render.Tilt").applyOptions(options)
|
require("src.render.Tilt").applyOptions(options)
|
||||||
require("src.render.GbcPalette").applyOptions(options)
|
require("src.render.GbcPalette").applyOptions(options)
|
||||||
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
||||||
@@ -1993,6 +1998,13 @@ function Game2:applyOptions()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game2:_cycleSpeed(dir)
|
||||||
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
|
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
||||||
|
if self.save then self.save.options = self.options end
|
||||||
|
self:persistOptions()
|
||||||
|
end
|
||||||
|
|
||||||
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
|
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
|
||||||
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
|
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
|
||||||
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
|
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
|
||||||
@@ -2003,27 +2015,49 @@ end
|
|||||||
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
|
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
|
||||||
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
|
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
|
||||||
-- to find out killed the process. It reaches Input like every other button now.
|
-- to find out killed the process. It reaches Input like every other button now.
|
||||||
function Game2:gamepadpressed(_joystick, button)
|
function Game2:gamepadpressed(joystick, button)
|
||||||
-- a controller is being used: the touch overlay steps aside until the next
|
-- a controller is being used: the touch overlay steps aside until the next
|
||||||
-- screen touch (mobile only; a no-op elsewhere)
|
-- screen touch (mobile only; a no-op elsewhere)
|
||||||
TouchControls:noteGamepad()
|
TouchControls:noteGamepad()
|
||||||
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path.
|
local selectHeld = Input:isDown("select")
|
||||||
if button == "rightshoulder" or button == "leftshoulder" then
|
if not selectHeld and joystick and joystick.isGamepadDown then
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local ok, down = pcall(function()
|
||||||
local dir = button == "rightshoulder" and 1 or -1
|
return joystick:isGamepadDown("back")
|
||||||
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
end)
|
||||||
if self.save then self.save.options = self.options end
|
selectHeld = ok and down == true
|
||||||
self:persistOptions()
|
end
|
||||||
|
-- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881
|
||||||
|
if not selectHeld then
|
||||||
|
if button == "rightshoulder" or button == "righttrigger" then
|
||||||
|
self:_cycleSpeed(1)
|
||||||
return
|
return
|
||||||
|
elseif button == "leftshoulder" or button == "lefttrigger" then
|
||||||
|
self:_cycleSpeed(-1)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local top = self.stack and self.stack:top()
|
||||||
|
if top and top.onGamepadPressed then
|
||||||
|
top:onGamepadPressed(button)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if selectHeld then
|
||||||
|
local digit = GamepadMap.displayChordDigit(button)
|
||||||
|
if digit then
|
||||||
|
self:keypressed(digit)
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- START opens the start menu in the overworld; it used to quit, from before
|
-- START opens the start menu in the overworld; it used to quit, from before
|
||||||
-- there was a menu to open.
|
-- there was a menu to open.
|
||||||
|
|
||||||
Input:gamepadpressed(_joystick, button)
|
Input:gamepadpressed(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game2:gamepadreleased(joystick, button)
|
function Game2:gamepadreleased(joystick, button)
|
||||||
Input:gamepadreleased(joystick, button)
|
Input:gamepadreleased(joystick, button)
|
||||||
|
local top = self.stack and self.stack:top()
|
||||||
|
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game2:gamepadaxis(joystick, axis, value)
|
function Game2:gamepadaxis(joystick, axis, value)
|
||||||
@@ -2032,4 +2066,61 @@ function Game2:gamepadaxis(joystick, axis, value)
|
|||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
|
||||||
|
local function isRawStick(joystick)
|
||||||
|
return not (joystick and joystick.isGamepad and joystick:isGamepad())
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game2:joystickpressed(joystick, button)
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
|
TouchControls:noteGamepad()
|
||||||
|
local top = self.stack and self.stack:top()
|
||||||
|
if isRawStick(joystick) and top and top.onJoystickPressed then
|
||||||
|
top:onJoystickPressed(button)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Input:joystickpressed(joystick, button)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game2:joystickreleased(joystick, button)
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
|
Input:joystickreleased(joystick, button)
|
||||||
|
local top = self.stack and self.stack:top()
|
||||||
|
if isRawStick(joystick) and top and top.onJoystickReleased then
|
||||||
|
top:onJoystickReleased(button)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game2:joystickaxis(joystick, axis, value)
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
|
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||||
|
Input:joystickaxis(joystick, axis, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game2:joystickhat(joystick, hat, direction)
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
|
if direction ~= "c" then TouchControls:noteGamepad() end
|
||||||
|
Input:joystickhat(joystick, hat, direction)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- src/core/Game.lua:1015 (#799)
|
||||||
|
function Game2:recoverInput()
|
||||||
|
Input:reset()
|
||||||
|
Input:reconcile()
|
||||||
|
TouchControls:reset()
|
||||||
|
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
|
||||||
|
self:cancelPointers()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game2:joystickadded()
|
||||||
|
self:recoverInput()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The overlay comes back on its own when the last pad is unplugged
|
||||||
|
-- (src/core/Game.lua:1044).
|
||||||
|
function Game2:joystickremoved()
|
||||||
|
self:recoverInput()
|
||||||
|
TouchControls:joystickremoved()
|
||||||
|
end
|
||||||
|
|
||||||
return Game2
|
return Game2
|
||||||
|
|||||||
@@ -106,6 +106,17 @@ function GamepadMap.ignoreRawForJoystick(joystick)
|
|||||||
return ok and isPad == true
|
return ok and isPad == true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||||
|
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||||
|
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||||
|
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||||
|
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||||
|
-- never as "* Accelerometer".
|
||||||
|
function GamepadMap.isAccelerometer(joystick)
|
||||||
|
local name = joystick and joystick.getName and joystick:getName()
|
||||||
|
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
function GamepadMap.mapRawButton(index)
|
function GamepadMap.mapRawButton(index)
|
||||||
if nxActive() then
|
if nxActive() then
|
||||||
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
|
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
|
||||||
|
|||||||
+6
-1
@@ -281,6 +281,7 @@ end
|
|||||||
|
|
||||||
function Input:joystickpressed(joystick, button)
|
function Input:joystickpressed(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
noteCapture(self, "joy", "pressed", button)
|
noteCapture(self, "joy", "pressed", button)
|
||||||
local btn = self.joyBindings[button]
|
local btn = self.joyBindings[button]
|
||||||
if btn then press(self, btn, "joy:" .. button) end
|
if btn then press(self, btn, "joy:" .. button) end
|
||||||
@@ -288,6 +289,7 @@ end
|
|||||||
|
|
||||||
function Input:joystickreleased(joystick, button)
|
function Input:joystickreleased(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
noteCapture(self, "joy", "released", button)
|
noteCapture(self, "joy", "released", button)
|
||||||
local btn = self.joyBindings[button]
|
local btn = self.joyBindings[button]
|
||||||
if btn then release(self, btn, "joy:" .. button) end
|
if btn then release(self, btn, "joy:" .. button) end
|
||||||
@@ -330,6 +332,7 @@ end
|
|||||||
|
|
||||||
function Input:joystickaxis(joystick, axis, value)
|
function Input:joystickaxis(joystick, axis, value)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
if axis == 1 then
|
if axis == 1 then
|
||||||
self:gamepadaxis(joystick, "leftx", value)
|
self:gamepadaxis(joystick, "leftx", value)
|
||||||
elseif axis == 2 then
|
elseif axis == 2 then
|
||||||
@@ -343,6 +346,7 @@ end
|
|||||||
-- directions on top of a direction rebind.
|
-- directions on top of a direction rebind.
|
||||||
function Input:joystickhat(joystick, hat, direction)
|
function Input:joystickhat(joystick, hat, direction)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
local source = "hat:" .. hat
|
local source = "hat:" .. hat
|
||||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||||
release(self, btn, source)
|
release(self, btn, source)
|
||||||
@@ -380,7 +384,8 @@ function Input:reconcile()
|
|||||||
local ok, joysticks = pcall(js.getJoysticks)
|
local ok, joysticks = pcall(js.getJoysticks)
|
||||||
if not ok or type(joysticks) ~= "table" then return end
|
if not ok or type(joysticks) ~= "table" then return end
|
||||||
for _, j in ipairs(joysticks) do
|
for _, j in ipairs(joysticks) do
|
||||||
if GamepadMap.ignoreRawForJoystick(j) then
|
if GamepadMap.isAccelerometer(j) then
|
||||||
|
elseif GamepadMap.ignoreRawForJoystick(j) then
|
||||||
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
|
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
|
||||||
if j.isGamepadDown then
|
if j.isGamepadDown then
|
||||||
for button, btn in pairs(self.padBindings) do
|
for button, btn in pairs(self.padBindings) do
|
||||||
|
|||||||
+79
-23
@@ -1,4 +1,4 @@
|
|||||||
-- Screen orientation lock, Android only (#592, #716).
|
-- Screen orientation lock, Android and iOS (#592, #716, #1638).
|
||||||
--
|
--
|
||||||
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
|
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
|
||||||
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
|
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
|
||||||
@@ -10,16 +10,12 @@
|
|||||||
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
|
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
|
||||||
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
|
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
|
||||||
--
|
--
|
||||||
-- SDL only re-reads the hint when the window is created or its resizable
|
-- Android only re-reads the hint at window creation or on a resizable-flag
|
||||||
-- flag changes (SDL_androidwindow.c: Android_CreateWindow /
|
-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on
|
||||||
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE
|
-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window
|
||||||
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through
|
-- always is -- so the hint never reached a running activity (#1638).
|
||||||
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable
|
-- apply() sets the hint for a later window, then goes over JNI for the live
|
||||||
-- flag off and back on -- each edge makes the Android backend recompute the
|
-- one. iOS needs only the hint. Desktop and headless stubs no-op.
|
||||||
-- requested orientation, so a change from the launcher or the OPTION menu
|
|
||||||
-- takes hold immediately, and the flag ends where it started (conf.lua sets
|
|
||||||
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
|
|
||||||
-- Info.plist governs there) and headless stubs make this a no-op.
|
|
||||||
|
|
||||||
local Orientation = {}
|
local Orientation = {}
|
||||||
|
|
||||||
@@ -58,6 +54,11 @@ function Orientation.isAndroid()
|
|||||||
return love.system.getOS() == "Android"
|
return love.system.getOS() == "Android"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Orientation.isIOS()
|
||||||
|
if not love or not love.system or not love.system.getOS then return false end
|
||||||
|
return love.system.getOS() == "iOS"
|
||||||
|
end
|
||||||
|
|
||||||
function Orientation.cycle(mode, dir)
|
function Orientation.cycle(mode, dir)
|
||||||
local cur, idx = Orientation.normalize(mode), 1
|
local cur, idx = Orientation.normalize(mode), 1
|
||||||
for i, m in ipairs(Orientation.MODES) do
|
for i, m in ipairs(Orientation.MODES) do
|
||||||
@@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir)
|
|||||||
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
|
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ActivityInfo constants, what setOrientationBis lands on per hint after
|
||||||
|
-- GameActivity's *_SENSOR -> *_USER remap (#716).
|
||||||
|
local REQUESTED = {
|
||||||
|
auto = 13,
|
||||||
|
portrait = 1,
|
||||||
|
landscape = 11,
|
||||||
|
reverseLandscape = 8,
|
||||||
|
}
|
||||||
|
|
||||||
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
|
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
|
||||||
-- once and remember whether it took; ffi itself may be absent (plain Lua
|
-- once and remember whether it took; ffi itself may be absent (plain Lua
|
||||||
-- test interpreters), hence the pcall'd require.
|
-- test interpreters), hence the pcall'd require.
|
||||||
@@ -77,33 +87,79 @@ local function sdlFfi()
|
|||||||
if cdefOk == nil then
|
if cdefOk == nil then
|
||||||
cdefOk = pcall(ffi.cdef, [[
|
cdefOk = pcall(ffi.cdef, [[
|
||||||
typedef struct SDL_Window SDL_Window;
|
typedef struct SDL_Window SDL_Window;
|
||||||
|
typedef union { int32_t i; int64_t pad; } love_jvalue;
|
||||||
int SDL_SetHint(const char *name, const char *value);
|
int SDL_SetHint(const char *name, const char *value);
|
||||||
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
||||||
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
|
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
|
||||||
|
void *SDL_AndroidGetJNIEnv(void);
|
||||||
|
void *SDL_AndroidGetActivity(void);
|
||||||
]])
|
]])
|
||||||
end
|
end
|
||||||
if not cdefOk then return nil end
|
if not cdefOk then return nil end
|
||||||
return ffi
|
return ffi
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Push the mode into the live activity. Returns true when the hint reached
|
-- Slot numbers in JNINativeInterface (jni.h).
|
||||||
-- SDL (the symbols resolved), false on any non-Android / stubbed platform.
|
local JNI_EXCEPTION_CLEAR = 17
|
||||||
|
local JNI_DELETE_LOCAL_REF = 23
|
||||||
|
local JNI_GET_OBJECT_CLASS = 31
|
||||||
|
local JNI_GET_METHOD_ID = 33
|
||||||
|
local JNI_CALL_VOID_METHOD_A = 63
|
||||||
|
|
||||||
|
-- What Android_JNI_SetOrientation reaches, called directly: the hint path
|
||||||
|
-- cannot re-run on a live fullscreen window (SDL_video.c:2237).
|
||||||
|
local function setRequestedOrientation(ffi, requested)
|
||||||
|
local env = ffi.C.SDL_AndroidGetJNIEnv()
|
||||||
|
if env == nil then return false end
|
||||||
|
local activity = ffi.C.SDL_AndroidGetActivity()
|
||||||
|
if activity == nil then return false end
|
||||||
|
local fns = ffi.cast("void***", env)[0]
|
||||||
|
local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS])
|
||||||
|
local getMethodID = ffi.cast(
|
||||||
|
"void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID])
|
||||||
|
local callVoidMethodA = ffi.cast(
|
||||||
|
"void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A])
|
||||||
|
local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF])
|
||||||
|
local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR])
|
||||||
|
|
||||||
|
local ok = false
|
||||||
|
local cls = getObjectClass(env, activity)
|
||||||
|
if cls ~= nil then
|
||||||
|
local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V")
|
||||||
|
if mid ~= nil then
|
||||||
|
local args = ffi.new("love_jvalue[1]")
|
||||||
|
args[0].pad = 0
|
||||||
|
args[0].i = requested
|
||||||
|
callVoidMethodA(env, activity, mid, args)
|
||||||
|
ok = true
|
||||||
|
end
|
||||||
|
exceptionClear(env)
|
||||||
|
deleteLocalRef(env, cls)
|
||||||
|
end
|
||||||
|
deleteLocalRef(env, activity)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns true only when the request actually landed, never unconditionally
|
||||||
|
-- as it once did (#1638).
|
||||||
function Orientation.apply(mode)
|
function Orientation.apply(mode)
|
||||||
if not Orientation.isAndroid() then return false end
|
local android = Orientation.isAndroid()
|
||||||
|
if not (android or Orientation.isIOS()) then return false end
|
||||||
local ffi = sdlFfi()
|
local ffi = sdlFfi()
|
||||||
if not ffi then return false end
|
if not ffi then return false end
|
||||||
mode = Orientation.normalize(mode)
|
mode = Orientation.normalize(mode)
|
||||||
local ok = pcall(function()
|
local ok, reached = pcall(function()
|
||||||
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h);
|
-- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android
|
||||||
-- despite the IOS in the string, the Android backend reads it too.
|
-- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each
|
||||||
|
-- engine ignores the other's key.
|
||||||
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
|
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
|
||||||
local win = ffi.C.SDL_GL_GetCurrentWindow()
|
ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode])
|
||||||
if win ~= nil then
|
-- On iOS the hint is the lock: UIKit re-asks on every rotation
|
||||||
ffi.C.SDL_SetWindowResizable(win, 0)
|
-- (SDL_uikitviewcontroller.m supportedInterfaceOrientations).
|
||||||
ffi.C.SDL_SetWindowResizable(win, 1)
|
if not android then return true end
|
||||||
end
|
return setRequestedOrientation(ffi, REQUESTED[mode])
|
||||||
end)
|
end)
|
||||||
return ok
|
return ok and reached == true
|
||||||
end
|
end
|
||||||
|
|
||||||
function Orientation.applyOptions(opts)
|
function Orientation.applyOptions(opts)
|
||||||
|
|||||||
@@ -86,8 +86,11 @@ function Performance.detect()
|
|||||||
local cores = processorCount()
|
local cores = processorCount()
|
||||||
|
|
||||||
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||||
-- already ships a build for): the weakest target here.
|
-- already ships a build for): the weakest target here. Desktop ARM
|
||||||
if isArm and os ~= "Android" and os ~= "iOS" then
|
-- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those
|
||||||
|
-- used to resolve AUTO → LOW, which stripped survey zoom-out from
|
||||||
|
-- OPTIONS so the ZOOM row only offered IN.
|
||||||
|
if isArm and os == "Linux" then
|
||||||
return "low"
|
return "low"
|
||||||
end
|
end
|
||||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||||
|
|||||||
+21
-1
@@ -1700,6 +1700,14 @@ local function clamp(n, lo, hi, fallback)
|
|||||||
return n
|
return n
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PP and the PP Up count are unsigned bit fields of one byte
|
||||||
|
-- (constants/pokemon_data_constants.asm:101-102)
|
||||||
|
local function ppInt(v, fallback)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n or n == math.huge or n == -math.huge then return fallback end
|
||||||
|
return math.max(0, math.floor(n))
|
||||||
|
end
|
||||||
|
|
||||||
local function ensureOrphaned(save)
|
local function ensureOrphaned(save)
|
||||||
if not save.orphaned then
|
if not save.orphaned then
|
||||||
save.orphaned = { mons = {}, items = {} }
|
save.orphaned = { mons = {}, items = {} }
|
||||||
@@ -1763,7 +1771,7 @@ local function scrubKnownMon(mon, data)
|
|||||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||||
-- so the derived stats use a sane level. A save that already has stats is
|
-- so the derived stats use a sane level. A complete stat block is
|
||||||
-- untouched.
|
-- untouched.
|
||||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||||
local moves = mon.moves
|
local moves = mon.moves
|
||||||
@@ -1783,6 +1791,18 @@ local function scrubKnownMon(mon, data)
|
|||||||
moves[1] = { id = fallback, pp = def.pp }
|
moves[1] = { id = fallback, pp = def.pp }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- the replacement PP mirrors AddBonusPP (engine/items/item_effects.asm:2418);
|
||||||
|
-- Mimic rewrites the move id and not the PP (engine/battle/effects.asm:1266)
|
||||||
|
for j = 1, #moves do
|
||||||
|
local slot = moves[j]
|
||||||
|
if type(slot) == "table" then
|
||||||
|
local mdef = data.moves and data.moves[slot.id]
|
||||||
|
local base = ppInt(mdef and mdef.pp, 0)
|
||||||
|
local ppUps = math.min(3, ppInt(slot.ppUps, 0))
|
||||||
|
if slot.ppUps ~= nil then slot.ppUps = ppUps end
|
||||||
|
slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scrubMonList(list, where, save, data, report)
|
local function scrubMonList(list, where, save, data, report)
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ function ScreenPosition.normalize(v)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ScreenPosition.label(v)
|
function ScreenPosition.label(v)
|
||||||
|
if ScreenPosition.skinActive() then return "SKIN" end
|
||||||
return LABELS[ScreenPosition.normalize(v)]
|
return LABELS[ScreenPosition.normalize(v)]
|
||||||
end
|
end
|
||||||
|
|
||||||
function ScreenPosition.cycle(v, dir)
|
function ScreenPosition.cycle(v, dir)
|
||||||
|
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
|
||||||
v = ScreenPosition.normalize(v)
|
v = ScreenPosition.normalize(v)
|
||||||
local modes = ScreenPosition.MODES
|
local modes = ScreenPosition.MODES
|
||||||
local cur = 1
|
local cur = 1
|
||||||
@@ -44,6 +46,9 @@ end
|
|||||||
function ScreenPosition.skinActive(w, h)
|
function ScreenPosition.skinActive(w, h)
|
||||||
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
|
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
|
||||||
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
|
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
|
||||||
|
if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
|
||||||
|
w, h = love.graphics.getDimensions()
|
||||||
|
end
|
||||||
local okv, x = pcall(TouchSkin.viewport, w, h)
|
local okv, x = pcall(TouchSkin.viewport, w, h)
|
||||||
return okv and x ~= nil
|
return okv and x ~= nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain)
|
|||||||
return newFileSource(def)
|
return newFileSource(def)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function deviceSuspended()
|
||||||
|
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||||
|
return ChipAudio ~= nil and ChipAudio.isSuspended()
|
||||||
|
end
|
||||||
|
|
||||||
local function playPath(data, key, def, pitch, tempo, plain)
|
local function playPath(data, key, def, pitch, tempo, plain)
|
||||||
if not love.audio or not def then return nil end
|
if not love.audio or not def then return nil end
|
||||||
|
if deviceSuspended() then return nil end
|
||||||
local src = cache[key]
|
local src = cache[key]
|
||||||
if src == false then return nil end -- known bad, already logged
|
if src == false then return nil end -- known bad, already logged
|
||||||
if not src then
|
if not src then
|
||||||
@@ -602,6 +608,7 @@ end
|
|||||||
-- cache carries no clips (Red/Blue) or headless.
|
-- cache carries no clips (Red/Blue) or headless.
|
||||||
function Sound.playPikaCry(data, n)
|
function Sound.playPikaCry(data, n)
|
||||||
if not love.audio then return nil end
|
if not love.audio then return nil end
|
||||||
|
if deviceSuspended() then return nil end
|
||||||
local count = data.audio and data.audio.pikaCries
|
local count = data.audio and data.audio.pikaCries
|
||||||
if not count then return nil end
|
if not count then return nil end
|
||||||
n = math.max(1, math.min(count, n or 1))
|
n = math.max(1, math.min(count, n or 1))
|
||||||
@@ -633,6 +640,7 @@ end
|
|||||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||||
function Sound.playCry(data, species, pikaClip)
|
function Sound.playCry(data, species, pikaClip)
|
||||||
if not love.audio then return nil end
|
if not love.audio then return nil end
|
||||||
|
if deviceSuspended() then return nil end
|
||||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||||
-- never used for the species there). Which clip is a property of the
|
-- never used for the species there). Which clip is a property of the
|
||||||
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
||||||
|
|||||||
@@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts)
|
|||||||
-- launcher editor round-trips through config() (#806)
|
-- launcher editor round-trips through config() (#806)
|
||||||
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
||||||
TouchSkin.setOverlayLive(self.active)
|
TouchSkin.setOverlayLive(self.active)
|
||||||
self:selectSkin(cfg.skin)
|
-- Off means off everywhere: do not leave a hidden selected skin behind to
|
||||||
|
-- influence renderer placement on desktop or with a controller attached.
|
||||||
|
self:selectSkin(cfg.enabled and cfg.skin or nil)
|
||||||
self.layouts = cfg.layouts
|
self.layouts = cfg.layouts
|
||||||
self.layoutW, self.layoutH = nil, nil
|
self.layoutW, self.layoutH = nil, nil
|
||||||
self.layoutOx, self.layoutOy = nil, nil
|
self.layoutOx, self.layoutOy = nil, nil
|
||||||
@@ -335,7 +337,10 @@ function TouchControls:visible()
|
|||||||
local art = TouchSkin.active ~= nil or self.img ~= nil
|
local art = TouchSkin.active ~= nil or self.img ~= nil
|
||||||
if self.preview then return art end
|
if self.preview then return art end
|
||||||
if self.enabled == false or not art then return false end
|
if self.enabled == false or not art then return false end
|
||||||
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
|
-- A selected skin is also a desktop/TV bezel. Input remains gated in
|
||||||
|
-- touchpressed, but the artwork must not disappear when a controller is
|
||||||
|
-- connected or the platform is not touch-first.
|
||||||
|
if TouchSkin.active then return true end
|
||||||
return self.active and not self.controllerHidden
|
return self.active and not self.controllerHidden
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul)
|
|||||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawStretched(img, x, y, w, h, alpha)
|
local function drawCovered(img, x, y, w, h, alpha)
|
||||||
if not img or alpha <= 0 then return end
|
if not img or alpha <= 0 then return end
|
||||||
local iw, ih = img:getWidth(), img:getHeight()
|
local iw, ih = img:getWidth(), img:getHeight()
|
||||||
if iw <= 0 or ih <= 0 then return end
|
if iw <= 0 or ih <= 0 then return end
|
||||||
|
-- Cover the assigned box with one uniform scale and crop the excess. The
|
||||||
|
-- old independent X/Y scale made portrait art visibly squash on wide
|
||||||
|
-- displays (and vice versa).
|
||||||
|
local s = math.max(w / iw, h / ih)
|
||||||
|
local dw, dh = iw * s, ih * s
|
||||||
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
|
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
|
||||||
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
|
love.graphics.setScissor(x, y, w, h)
|
||||||
|
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
|
||||||
|
love.graphics.setScissor()
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:drawSkin(alphaMul)
|
function TouchControls:drawSkin(alphaMul)
|
||||||
@@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul)
|
|||||||
|
|
||||||
love.graphics.push("all")
|
love.graphics.push("all")
|
||||||
love.graphics.origin()
|
love.graphics.origin()
|
||||||
drawStretched(page.image, bx, by, bw, bh, opacity)
|
drawCovered(page.image, bx, by, bw, bh, opacity)
|
||||||
|
|
||||||
local pressed = {}
|
local pressed = {}
|
||||||
for _, touch in pairs(self.touches or {}) do
|
for _, touch in pairs(self.touches or {}) do
|
||||||
@@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul)
|
|||||||
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
|
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
|
||||||
local alpha = opacity
|
local alpha = opacity
|
||||||
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
|
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
|
||||||
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+69
-9
@@ -294,7 +294,6 @@ function TouchSkin.parse(text)
|
|||||||
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
||||||
w = num(vp[3], 1), h = num(vp[4], 1) }
|
w = num(vp[3], 1), h = num(vp[4], 1) }
|
||||||
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
||||||
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
page.pixelCoords = not page.normalized
|
page.pixelCoords = not page.normalized
|
||||||
@@ -447,7 +446,6 @@ function TouchSkin.parseNative(text)
|
|||||||
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
||||||
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
||||||
page.viewportFill = raw.viewport.fill == true
|
page.viewportFill = raw.viewport.fill == true
|
||||||
page.viewportExpand = raw.viewport.expand == true
|
|
||||||
end
|
end
|
||||||
for _, c in ipairs(raw.controls or {}) do
|
for _, c in ipairs(raw.controls or {}) do
|
||||||
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||||
@@ -526,7 +524,6 @@ function TouchSkin.toNative(skin)
|
|||||||
x = page.viewport.x, y = page.viewport.y,
|
x = page.viewport.x, y = page.viewport.y,
|
||||||
w = page.viewport.w, h = page.viewport.h,
|
w = page.viewport.w, h = page.viewport.h,
|
||||||
fill = page.viewportFill or nil,
|
fill = page.viewportFill or nil,
|
||||||
expand = page.viewportExpand or nil,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -636,6 +633,19 @@ local function applyPixelScale(page)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- An overlay image is its own design canvas. Older RetroArch cfg files often
|
||||||
|
-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
|
||||||
|
-- all of its normalized controls on the same uniform scale.
|
||||||
|
function TouchSkin.applyImageAspect(page)
|
||||||
|
if not page or page.aspectFromCfg or not page.image
|
||||||
|
or not page.image.getDimensions then return false end
|
||||||
|
local iw, ih = page.image:getDimensions()
|
||||||
|
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
|
||||||
|
page.aspect = iw / ih
|
||||||
|
page.aspectFromImage = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.load(root, id)
|
function TouchSkin.load(root, id)
|
||||||
local cfgPath, format, prefix = findConfig(root)
|
local cfgPath, format, prefix = findConfig(root)
|
||||||
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
||||||
@@ -665,6 +675,7 @@ function TouchSkin.load(root, id)
|
|||||||
elseif page.pdfPath then
|
elseif page.pdfPath then
|
||||||
rasterizePdfPage(page, root)
|
rasterizePdfPage(page, root)
|
||||||
end
|
end
|
||||||
|
TouchSkin.applyImageAspect(page)
|
||||||
if not applyPixelScale(page) then
|
if not applyPixelScale(page) then
|
||||||
return nil, "could not read " .. tostring(page.imagePath)
|
return nil, "could not read " .. tostring(page.imagePath)
|
||||||
.. ", which " .. page.name .. " measures its coordinates against"
|
.. ", which " .. page.name .. " measures its coordinates against"
|
||||||
@@ -771,6 +782,28 @@ function TouchSkin.find(id)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Remove only a user-installed skin. Bundled skins are shipped with the
|
||||||
|
-- game and intentionally have no delete affordance.
|
||||||
|
function TouchSkin.remove(id)
|
||||||
|
local entry = TouchSkin.find(id)
|
||||||
|
if not entry then return nil, "no skin " .. tostring(id) end
|
||||||
|
if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
|
||||||
|
if not (love and love.filesystem and love.filesystem.remove) then
|
||||||
|
return nil, "no writable filesystem"
|
||||||
|
end
|
||||||
|
local function removeTree(path)
|
||||||
|
if isDir(path) and love.filesystem.getDirectoryItems then
|
||||||
|
for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
|
||||||
|
local ok, err = removeTree(path .. "/" .. name)
|
||||||
|
if not ok then return nil, err end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ok, err = love.filesystem.remove(path)
|
||||||
|
return ok and true or nil, err
|
||||||
|
end
|
||||||
|
return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.assetPaths(skin)
|
function TouchSkin.assetPaths(skin)
|
||||||
local out, seen = {}, {}
|
local out, seen = {}, {}
|
||||||
local function add(rel)
|
local function add(rel)
|
||||||
@@ -886,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin)
|
|||||||
if page.viewport then
|
if page.viewport then
|
||||||
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||||
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||||
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
|
||||||
end
|
end
|
||||||
local controls = {}
|
local controls = {}
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -1284,7 +1316,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
-- full_screen means "relative to the window, not the game viewport".
|
-- full_screen means "relative to the window, not the game viewport".
|
||||||
-- When the cfg also names an aspect_ratio, that window is then fitted
|
-- When the cfg also names an aspect_ratio, that window is then fitted
|
||||||
-- to the overlay's design aspect so buttons do not stretch. #1503
|
-- to the overlay's design aspect so buttons do not stretch. #1503
|
||||||
local fit = ((not page.fullScreen) or page.aspectFromCfg)
|
local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
|
||||||
and page.aspect and page.aspect > 0 and h > 0
|
and page.aspect and page.aspect > 0 and h > 0
|
||||||
if fit then
|
if fit then
|
||||||
local displayAspect = w / h
|
local displayAspect = w / h
|
||||||
@@ -1306,6 +1338,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
by = oy + extra
|
by = oy + extra
|
||||||
elseif anchor == "top" then
|
elseif anchor == "top" then
|
||||||
by = oy
|
by = oy
|
||||||
|
elseif page.aspect < 1 then
|
||||||
|
-- A portrait bezel with controls is a controller deck. On an
|
||||||
|
-- unusually tall display, pin the deck to the lower edge and leave
|
||||||
|
-- the additional room for the game above it.
|
||||||
|
by = oy + extra
|
||||||
else
|
else
|
||||||
by = oy + extra * 0.5
|
by = oy + extra * 0.5
|
||||||
end
|
end
|
||||||
@@ -1359,8 +1396,10 @@ function TouchSkin.decorativeOnly()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.drawable()
|
function TouchSkin.drawable()
|
||||||
if not TouchSkin.active then return false end
|
-- A selected skin is a presentation choice, not a mobile-only input mode.
|
||||||
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
|
-- Its artwork and screen placement therefore belong on every platform;
|
||||||
|
-- `overlayLive` still controls whether touch input is available.
|
||||||
|
return TouchSkin.active ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.hasViewport()
|
function TouchSkin.hasViewport()
|
||||||
@@ -1391,6 +1430,16 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
|||||||
return best[1], best[2], best[3], best[4]
|
return best[1], best[2], best[3], best[4]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The deck box pinned to the lower edge (see pageBox) leaves room above it
|
||||||
|
-- that belongs to the game, so a screen rect flush with the top of the deck
|
||||||
|
-- grows into it instead of showing a black band.
|
||||||
|
local function deckHeadroom(y, vh, by, bh, oy, h)
|
||||||
|
if by <= oy + 0.5 then return y, vh end
|
||||||
|
if by + bh < oy + h - 0.5 then return y, vh end
|
||||||
|
if y - by > math.max(2, bh * 0.01) then return y, vh end
|
||||||
|
return oy, vh + (y - oy)
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
if not page then return nil end
|
if not page then return nil end
|
||||||
ox, oy = ox or 0, oy or 0
|
ox, oy = ox or 0, oy or 0
|
||||||
@@ -1400,16 +1449,27 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
|
|||||||
local x, y = bx + v.x * bw, by + v.y * bh
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
local vw, vh = v.w * bw, v.h * bh
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
if vw <= 0 or vh <= 0 then return nil end
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
y, vh = deckHeadroom(y, vh, by, bh, oy, h)
|
||||||
|
return x, y, vw, vh, page.viewportFill == true
|
||||||
end
|
end
|
||||||
if page.screenFit == "remainder" then
|
if page.screenFit == "remainder" then
|
||||||
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||||
if not x then return nil end
|
if not x then return nil end
|
||||||
return x, y, vw, vh, false, false
|
return x, y, vw, vh, false
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Centre of the page's screen cutout. The renderer fits the 160x144
|
||||||
|
-- picture into that rect; this helper is for the studio preview.
|
||||||
|
function TouchSkin.screenCenter(w, h, ox, oy, page)
|
||||||
|
page = page or TouchSkin.page()
|
||||||
|
if not page then return nil end
|
||||||
|
local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
|
if not x then return nil end
|
||||||
|
return x + vw * 0.5, y + vh * 0.5
|
||||||
|
end
|
||||||
|
|
||||||
function TouchSkin.viewport(w, h, ox, oy)
|
function TouchSkin.viewport(w, h, ox, oy)
|
||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
if not page or not TouchSkin.drawable() then return nil end
|
if not page or not TouchSkin.drawable() then return nil end
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
|
|||||||
used = true,
|
used = true,
|
||||||
level = newLevel,
|
level = newLevel,
|
||||||
learned = learned,
|
learned = learned,
|
||||||
|
-- data/text/common_1.asm:86
|
||||||
|
sfx = "Sfx_DexFanfare5079",
|
||||||
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
|
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ function Save.newGame(opts)
|
|||||||
phoneContacts = {},
|
phoneContacts = {},
|
||||||
tradeFlags = {},
|
tradeFlags = {},
|
||||||
pokedex = { seen = {}, caught = {} },
|
pokedex = { seen = {}, caught = {} },
|
||||||
|
-- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the
|
||||||
|
-- #DEX reopens in. NEW_MODE is the cart's zero byte.
|
||||||
|
lastDexMode = "NEW",
|
||||||
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
||||||
-- record beside the #DEX because the #DEX knows only the species
|
-- record beside the #DEX because the #DEX knows only the species
|
||||||
-- (src/core/gen2/Unown.lua).
|
-- (src/core/gen2/Unown.lua).
|
||||||
@@ -685,6 +688,12 @@ function Save.validate(save)
|
|||||||
scrubEvents(save, report)
|
scrubEvents(save, report)
|
||||||
scrubMapScenes(save, report)
|
scrubMapScenes(save, report)
|
||||||
scrubPlayerState(save, report)
|
scrubPlayerState(save, report)
|
||||||
|
-- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES);
|
||||||
|
-- a hand-edited value falls back to NEW_MODE, the cart's zero byte
|
||||||
|
if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD"
|
||||||
|
and save.lastDexMode ~= "A-Z" then
|
||||||
|
save.lastDexMode = "NEW"
|
||||||
|
end
|
||||||
-- The `mailmsg` structs get the same treatment for the same reason: their
|
-- The `mailmsg` structs get the same treatment for the same reason: their
|
||||||
-- `type` byte is an item id nothing else in the save vouches for, and a
|
-- `type` byte is an item id nothing else in the save vouches for, and a
|
||||||
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
||||||
|
|||||||
@@ -287,6 +287,43 @@ function CacheFs.write(rel, data)
|
|||||||
return love.filesystem.write(rel, data)
|
return love.filesystem.write(rel, data)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Open a cache-relative file for streaming replacement. The returned handle
|
||||||
|
-- has write(bytes) and close() methods and follows the same portable/save-dir
|
||||||
|
-- routing as CacheFs.write without forcing the caller to hold the whole file
|
||||||
|
-- in one Lua string.
|
||||||
|
function CacheFs.openWrite(rel)
|
||||||
|
rel = withPrefix(rel)
|
||||||
|
local root = CacheFs.root()
|
||||||
|
if root then
|
||||||
|
ensureParents(root, rel)
|
||||||
|
local f, err = io.open(realPath(root, rel), "wb")
|
||||||
|
if not f then return nil, err end
|
||||||
|
return {
|
||||||
|
write = function(_, data)
|
||||||
|
local ok, writeErr = f:write(data)
|
||||||
|
if not ok then return nil, writeErr end
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
close = function() f:close() end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if not (love and love.filesystem and love.filesystem.newFile) then
|
||||||
|
return nil, "streaming cache writes are unavailable"
|
||||||
|
end
|
||||||
|
local parent = rel:match("^(.*)/[^/]+$")
|
||||||
|
if parent and not love.filesystem.createDirectory(parent) then
|
||||||
|
local info = love.filesystem.getInfo(parent)
|
||||||
|
local reason = info and ("a " .. info.type .. " already exists there")
|
||||||
|
or "unknown reason"
|
||||||
|
return nil, "could not create " .. parent .. ": " .. reason
|
||||||
|
end
|
||||||
|
local file, makeErr = love.filesystem.newFile(rel)
|
||||||
|
if not file then return nil, makeErr or "could not create cache file" end
|
||||||
|
local ok, openErr = file:open("w")
|
||||||
|
if not ok then return nil, openErr or "could not open cache file" end
|
||||||
|
return file
|
||||||
|
end
|
||||||
|
|
||||||
-- read cache-relative `rel`; returns the bytes or nil
|
-- read cache-relative `rel`; returns the bytes or nil
|
||||||
function CacheFs.read(rel)
|
function CacheFs.read(rel)
|
||||||
rel = withPrefix(rel)
|
rel = withPrefix(rel)
|
||||||
|
|||||||
@@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Inverse of pret tools/gfx --interleave (pokecrystal tools/gfx.c).
|
||||||
|
-- Build-time interleave stores each vertical 8x16 pair as consecutive 8x8
|
||||||
|
-- tiles for OBJ mode; this restores row-major sheet order for PNGs.
|
||||||
|
function ImageWriter.deinterleave(raw, width, bytesPerTile)
|
||||||
|
bytesPerTile = bytesPerTile or 16
|
||||||
|
local widthTiles = width / 8
|
||||||
|
local numTiles = #raw / bytesPerTile
|
||||||
|
local out = {}
|
||||||
|
for i = 0, numTiles - 1 do
|
||||||
|
local row = math.floor(i / widthTiles)
|
||||||
|
local src = i * 2 - (row % 2 == 1
|
||||||
|
and widthTiles * (row + 1) - 1
|
||||||
|
or widthTiles * row)
|
||||||
|
for offset = 1, bytesPerTile do
|
||||||
|
out[i * bytesPerTile + offset] = raw[src * bytesPerTile + offset]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
function ImageWriter.save(image, path)
|
function ImageWriter.save(image, path)
|
||||||
local ok, fileData = pcall(image.encode, image, "png")
|
local ok, fileData = pcall(image.encode, image, "png")
|
||||||
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
|
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
|
||||||
-- next boot's applyOptions pick the values up. Every ladder mirrors
|
-- next boot's applyOptions pick the values up. Every ladder mirrors
|
||||||
-- OptionsMenu's semantics and stored values; when editing one, keep the two
|
-- OptionsMenu's semantics and stored values; when editing one, keep the two
|
||||||
-- in sync. ZOOM is deliberately absent: its range depends on the live
|
-- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as
|
||||||
-- renderer's fit scale (Renderer:fitScale), which does not exist here.
|
-- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game.
|
||||||
--
|
--
|
||||||
-- Rows are the same descriptor idiom OptionRows draws in game:
|
-- Rows are the same descriptor idiom OptionRows draws in game:
|
||||||
-- { label, value = fn() -> string, step = fn(dir) -> changed,
|
-- { label, value = fn() -> string, step = fn(dir) -> changed,
|
||||||
@@ -278,6 +278,16 @@ local function coreRows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||||
|
if okZ then
|
||||||
|
add(Strings("ZOOM"),
|
||||||
|
function() return Zoom.offsetLabel(opts.zoom or 0) end,
|
||||||
|
function(dir)
|
||||||
|
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
|
local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
|
||||||
if okTile and TileRenderer.VOID_FILLS then
|
if okTile and TileRenderer.VOID_FILLS then
|
||||||
add(Strings("VOID FILL"),
|
add(Strings("VOID FILL"),
|
||||||
@@ -303,15 +313,12 @@ local function coreRows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation
|
-- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows
|
||||||
-- hint, which iOS reads only at startup (the Info.plist governs there) and
|
-- this one live-applies: the window exists here too, and rotating under
|
||||||
-- desktop ignores. Unlike the other launcher rows this one live-applies:
|
-- the player's finger is the only feedback that reads.
|
||||||
-- the window exists here too, and rotating under the player's finger is
|
|
||||||
-- the only feedback that reads.
|
|
||||||
do
|
do
|
||||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
|
||||||
local okOr, Orientation = pcall(require, "src.core.Orientation")
|
local okOr, Orientation = pcall(require, "src.core.Orientation")
|
||||||
if okOr and osName == "Android" then
|
if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then
|
||||||
add(Strings("ORIENTATION"),
|
add(Strings("ORIENTATION"),
|
||||||
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
|
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
|
||||||
function(dir)
|
function(dir)
|
||||||
@@ -646,6 +653,16 @@ local function gen2Rows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||||
|
if okZ then
|
||||||
|
add(Strings("ZOOM"),
|
||||||
|
function() return Zoom.offsetLabel(opts.zoom or 0) end,
|
||||||
|
function(dir)
|
||||||
|
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
|
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
|
||||||
if okFill and BorderFill.VOID_FILLS then
|
if okFill and BorderFill.VOID_FILLS then
|
||||||
add(Strings("VOID FILL"),
|
add(Strings("VOID FILL"),
|
||||||
|
|||||||
+477
-135
@@ -124,12 +124,48 @@ function LauncherView.detach(imp)
|
|||||||
Kit.clearCaches()
|
Kit.clearCaches()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function markNoDrag(imp, x, y, w, h)
|
||||||
|
if Kit.blockClicks then return end
|
||||||
|
local t = imp._noDragRects
|
||||||
|
if not t then t = {}; imp._noDragRects = t end
|
||||||
|
local n = (imp._noDragN or 0) + 1
|
||||||
|
imp._noDragN = n
|
||||||
|
local r = t[n]
|
||||||
|
if not r then r = {}; t[n] = r end
|
||||||
|
r.x, r.y, r.w, r.h = x, y, w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noDragAt(imp, x, y)
|
||||||
|
local rects = imp._noDragRects
|
||||||
|
for i = 1, imp._noDragN or 0 do
|
||||||
|
if inRect(rects[i], x, y) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function armMouse(imp, x, y)
|
||||||
|
if noDragAt(imp, x, y) then
|
||||||
|
imp._clickPt = { x = x, y = y }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local shielded = imp._modalUpNow
|
||||||
|
imp._mouseAt = {
|
||||||
|
x = x, y = y,
|
||||||
|
region = not shielded and tabScrollMax(imp) > 0
|
||||||
|
and inRect(imp._tabRegionRect, x, y) or false,
|
||||||
|
page = not shielded and (imp._pageScrollMax or 0) > 0 or false,
|
||||||
|
}
|
||||||
|
Kit.dragBegin(x, y)
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- input
|
-- ---------------------------------------------------------------- input
|
||||||
-- The kit is polled, not evented: update() samples the mouse and turns a
|
-- The kit is polled, not evented: update() samples the mouse. A press arms
|
||||||
-- rising edge into a click point that the next draw consumes. Host-forwarded
|
-- a drag that scrolls like a finger, and the click dispatches on RELEASE so
|
||||||
-- mousepressed stays unused, exactly as before, so Android's synthesized
|
-- the drag can disqualify it, exactly like the touch path below; only the
|
||||||
-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the
|
-- cartridge (which owns its own spin-drag) keeps the press-down click.
|
||||||
-- other half of that guarantee.
|
-- Host-forwarded mousepressed stays unused, exactly as before, so Android's
|
||||||
|
-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window
|
||||||
|
-- below is the other half of that guarantee.
|
||||||
function LauncherView.update(imp, dt)
|
function LauncherView.update(imp, dt)
|
||||||
if not imp._flex then return end
|
if not imp._flex then return end
|
||||||
if imp._launchFade then return end
|
if imp._launchFade then return end
|
||||||
@@ -154,7 +190,39 @@ function LauncherView.update(imp, dt)
|
|||||||
if not touching and now >= (imp._suppressMouseUntil or 0)
|
if not touching and now >= (imp._suppressMouseUntil or 0)
|
||||||
and now >= (imp._suppressClickUntil or 0) then
|
and now >= (imp._suppressClickUntil or 0) then
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = love.mouse.getPosition()
|
||||||
imp._clickPt = { x = mx, y = my }
|
armMouse(imp, mx, my)
|
||||||
|
end
|
||||||
|
elseif down and imp._mouseAt then
|
||||||
|
local start = imp._mouseAt
|
||||||
|
local mx, my = love.mouse.getPosition()
|
||||||
|
local ddx, ddy = mx - start.x, my - start.y
|
||||||
|
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||||
|
start.dragged = true
|
||||||
|
end
|
||||||
|
if start.dragged then
|
||||||
|
local last = start.lastY or start.y
|
||||||
|
local move = -(my - last)
|
||||||
|
if move ~= 0 and start.region then
|
||||||
|
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
||||||
|
tabScrollMax(imp), move)
|
||||||
|
setTabScroll(imp, at)
|
||||||
|
move = leftover
|
||||||
|
end
|
||||||
|
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
|
||||||
|
local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0,
|
||||||
|
imp._pageScrollMax, move)
|
||||||
|
imp._pageScroll = at
|
||||||
|
move = leftover
|
||||||
|
end
|
||||||
|
if move ~= 0 then Kit.dragAdd(move) end
|
||||||
|
end
|
||||||
|
start.lastY = my
|
||||||
|
elseif not down and imp._mouseAt then
|
||||||
|
local start = imp._mouseAt
|
||||||
|
imp._mouseAt = nil
|
||||||
|
Kit.dragEnd()
|
||||||
|
if not start.dragged then
|
||||||
|
imp._clickPt = { x = start.x, y = start.y }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
imp._prevMouseDown = down
|
imp._prevMouseDown = down
|
||||||
@@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y)
|
|||||||
imp._clickPt = { x = x, y = y }
|
imp._clickPt = { x = x, y = y }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Event-driven click: a macOS trackpad tap delivers press+release inside one
|
-- Event-driven press: a macOS trackpad tap delivers press+release inside one
|
||||||
-- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click
|
-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag
|
||||||
-- from the press event under the poll's own suppression rules, and mark the
|
-- from the press event under the poll's own suppression rules -- the poll's
|
||||||
-- press seen so the poll cannot mint a second one when isDown does catch it.
|
-- release branch then mints the tap, still within the same frame for a
|
||||||
|
-- one-frame tap -- and mark the press seen so the poll cannot arm a second
|
||||||
|
-- one when isDown does catch it.
|
||||||
function LauncherView.mousepressed(imp, x, y)
|
function LauncherView.mousepressed(imp, x, y)
|
||||||
if not imp._flex then return end
|
if not imp._flex then return end
|
||||||
local now = love.timer.getTime()
|
local now = love.timer.getTime()
|
||||||
@@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y)
|
|||||||
or now < (imp._suppressClickUntil or 0) then
|
or now < (imp._suppressClickUntil or 0) then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
imp._clickPt = { x = x, y = y }
|
if not imp._mouseAt then armMouse(imp, x, y) end
|
||||||
imp._prevMouseDown = true
|
imp._prevMouseDown = true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -495,6 +565,7 @@ end
|
|||||||
|
|
||||||
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||||
local state = cartridgeState(imp, version)
|
local state = cartridgeState(imp, version)
|
||||||
|
markNoDrag(imp, x, y, w, h)
|
||||||
local focused = Kit.focusable(key, x, y, w, h)
|
local focused = Kit.focusable(key, x, y, w, h)
|
||||||
local hot = Kit.hover(x, y, w, h)
|
local hot = Kit.hover(x, y, w, h)
|
||||||
local active = state.active
|
local active = state.active
|
||||||
@@ -682,12 +753,15 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
|||||||
-- and one below the top-right corner notch, like the DMG cart.
|
-- and one below the top-right corner notch, like the DMG cart.
|
||||||
local grooveW = w * 0.115
|
local grooveW = w * 0.115
|
||||||
local grooveH = math.max(1, h * 0.009)
|
local grooveH = math.max(1, h * 0.009)
|
||||||
|
local grooveScale = { 1.22, 1.10, 1.00, 1.00, 1.10, 1.22 }
|
||||||
|
local grooveInset = w * 0.02
|
||||||
for i = 0, 5 do
|
for i = 0, 5 do
|
||||||
local ry = mainTop + h * 0.014 + i * h * 0.021
|
local ry = mainTop + h * 0.014 + i * h * 0.021
|
||||||
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry,
|
local gw = grooveW * grooveScale[i + 1]
|
||||||
grooveW, grooveH, faceZ), side, 0.7)
|
cartPolygon(cartQuad(project, -halfW + grooveInset, ry,
|
||||||
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry,
|
gw, grooveH, faceZ), side, 0.7)
|
||||||
grooveW, grooveH, faceZ), side, 0.7)
|
cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry,
|
||||||
|
gw, grooveH, faceZ), side, 0.7)
|
||||||
end
|
end
|
||||||
-- The thin diagonal mold ridge cut into each long side a little below
|
-- The thin diagonal mold ridge cut into each long side a little below
|
||||||
-- the grip grooves, mirrored left/right.
|
-- the grip grooves, mirrored left/right.
|
||||||
@@ -703,12 +777,14 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
|||||||
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
|
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
|
||||||
}, side, 0.7)
|
}, side, 0.7)
|
||||||
end
|
end
|
||||||
local dgY = mainTop + h * 0.20
|
local dgY = mainTop + h * 0.25
|
||||||
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
|
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
|
||||||
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
|
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
|
||||||
-- The Nintendo GAME BOY recess: one stadium pill sunk into the shell.
|
-- The pill recess: one stadium pill sunk into the shell.
|
||||||
local pillX, pillW = -halfW + w * 0.17, w * 0.62
|
local pillX, pillW = -halfW + w * 0.19, w * 0.62
|
||||||
local pillY, pillH = mainTop + h * 0.024, h * 0.115
|
local pillY, pillH = mainTop + h * 0.015, h * 0.115
|
||||||
|
-- log out pillH
|
||||||
|
|
||||||
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
|
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
|
||||||
local inX, inY = w * 0.008, h * 0.008
|
local inX, inY = w * 0.008, h * 0.008
|
||||||
cartPill(project, pillX + inX, pillY + inY,
|
cartPill(project, pillX + inX, pillY + inY,
|
||||||
@@ -991,9 +1067,22 @@ local GAME_TABS = {
|
|||||||
local HEADER_TABS = {
|
local HEADER_TABS = {
|
||||||
{ id = "mods", key = "tab-mods" },
|
{ id = "mods", key = "tab-mods" },
|
||||||
{ id = "find", key = "tab-find" },
|
{ id = "find", key = "tab-find" },
|
||||||
{ id = "skins", key = "tab-skins", glyph = true },
|
{ id = "skins", key = "tab-skins", glyph = true, beta = true },
|
||||||
{ id = "bug", key = "tab-bug" },
|
{ id = "bug", key = "tab-bug" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse }
|
||||||
|
|
||||||
|
local function drawBetaTag(x, y, w, h)
|
||||||
|
Kit.tag(x, y, w, h, "BETA", PAL.yellow, BETA_TAG_OPTS)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function overlayBeta(tx, ty, w, tabH, m)
|
||||||
|
local bh = math.floor(11 * m.s)
|
||||||
|
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
|
||||||
|
drawBetaTag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh)
|
||||||
|
end
|
||||||
|
|
||||||
for _, t in ipairs(HEADER_TABS) do
|
for _, t in ipairs(HEADER_TABS) do
|
||||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||||
if t.glyph then
|
if t.glyph then
|
||||||
@@ -1197,6 +1286,7 @@ local function buildHeader(imp, m)
|
|||||||
o.image = t.icon
|
o.image = t.icon
|
||||||
o.action = chrome.tab[t.id]
|
o.action = chrome.tab[t.id]
|
||||||
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
||||||
|
if t.beta then overlayBeta(tx, ty, w, tabH, m) end
|
||||||
tx = tx + w + tabGap
|
tx = tx + w + tabGap
|
||||||
end
|
end
|
||||||
-- The bug-report chip sits LAST, past the sync chip.
|
-- The bug-report chip sits LAST, past the sync chip.
|
||||||
@@ -1214,10 +1304,7 @@ local function buildHeader(imp, m)
|
|||||||
local o = chrome.sync
|
local o = chrome.sync
|
||||||
o.active = imp._syncModal ~= nil
|
o.active = imp._syncModal ~= nil
|
||||||
btn(imp, tx, ty, w, tabH, "tab-sync", "", o)
|
btn(imp, tx, ty, w, tabH, "tab-sync", "", o)
|
||||||
local bh = math.floor(11 * m.s)
|
overlayBeta(tx, ty, w, tabH, m)
|
||||||
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
|
|
||||||
Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh,
|
|
||||||
"BETA", o.active and PAL.inverse or PAL.yellow)
|
|
||||||
local eng = imp._sync
|
local eng = imp._sync
|
||||||
if eng and eng.busy and eng:busy() then
|
if eng and eng.busy and eng:busy() then
|
||||||
Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s),
|
Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s),
|
||||||
@@ -1233,7 +1320,7 @@ local function buildHeader(imp, m)
|
|||||||
return y + math.floor(10 * m.s)
|
return y + math.floor(10 * m.s)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The state of the self-updater, as a top-right control.
|
-- The state of the self-updater, shown in the launcher footer.
|
||||||
-- Returns status, label, action, glow.
|
-- Returns status, label, action, glow.
|
||||||
function LauncherView._updateControl(imp)
|
function LauncherView._updateControl(imp)
|
||||||
if not imp.Check then return nil end
|
if not imp.Check then return nil end
|
||||||
@@ -1245,15 +1332,27 @@ function LauncherView._updateControl(imp)
|
|||||||
elseif status == "downloading" then
|
elseif status == "downloading" then
|
||||||
local pct = st.progress and math.floor(st.progress * 100) or 0
|
local pct = st.progress and math.floor(st.progress * 100) or 0
|
||||||
return status, Strings("Updating %d%%", pct), nil, false
|
return status, Strings("Updating %d%%", pct), nil, false
|
||||||
|
elseif status == "full_downloading" then
|
||||||
|
local pct = st.progress and math.floor(st.progress * 100) or 0
|
||||||
|
return status, Strings("Downloading app %d%%", pct), nil, false
|
||||||
elseif status == "available" then
|
elseif status == "available" then
|
||||||
return status, st.latest and (Strings("Update v") .. st.latest)
|
return status, st.latest and (Strings("Update v") .. st.latest)
|
||||||
or Strings("Update"), function() pcall(imp.Check.download) end, true
|
or Strings("Update"), function() pcall(imp.Check.download) end, true
|
||||||
elseif status == "ready" then
|
elseif status == "ready" then
|
||||||
return status, Strings("Restart to update"),
|
return status, Strings("Restart to update"),
|
||||||
function() require("src.core.HostShell").restart() end, true
|
function() require("src.core.HostShell").restart() end, true
|
||||||
elseif status == "needs_full" then
|
elseif status == "needs_full" or status == "full_ready" then
|
||||||
return status, Strings("Open releases"),
|
local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
|
||||||
function() love.system.openURL(imp.Check.releaseUrl()) end, true
|
local label = action and action.label or "Open releases"
|
||||||
|
local url = action and action.url or imp.Check.releaseUrl()
|
||||||
|
return status, Strings(label),
|
||||||
|
function()
|
||||||
|
if action and action.kind and imp.Check.performFullUpdate then
|
||||||
|
pcall(imp.Check.performFullUpdate)
|
||||||
|
else
|
||||||
|
love.system.openURL(url)
|
||||||
|
end
|
||||||
|
end, true
|
||||||
end
|
end
|
||||||
-- idle / uptodate / error: offer a manual check, with no glow.
|
-- idle / uptodate / error: offer a manual check, with no glow.
|
||||||
return status, Strings("Check for updates"),
|
return status, Strings("Check for updates"),
|
||||||
@@ -2059,16 +2158,20 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
+ math.floor(8 * m.s)
|
+ math.floor(8 * m.s)
|
||||||
local listTop = cy
|
local listTop = cy
|
||||||
|
|
||||||
-- One continuous list: every row is laid out, the region scroll moves
|
-- One continuous list: derive the rows that can touch the viewport before
|
||||||
-- through all of it, and only rows inside the region's viewport draw --
|
-- entering the loop. Drawing was already culled, but scanning every
|
||||||
-- so the per-frame cost stays bounded by the window, not the list.
|
-- installed row to discover that defeats the point on a large mod library.
|
||||||
local view = imp._tabRegionRect
|
local view = imp._tabRegionRect
|
||||||
local viewTop = view and view.y or listTop
|
local viewTop = view and view.y or listTop
|
||||||
local viewBot = view and (view.y + view.h) or (listTop + availH)
|
local viewBot = view and (view.y + view.h) or (listTop + availH)
|
||||||
for i = 1, #mods do
|
local stride = rowH + gap
|
||||||
|
local first = math.max(1,
|
||||||
|
math.ceil((viewTop - rowH - listTop) / stride) + 1)
|
||||||
|
local last = math.min(#mods,
|
||||||
|
math.floor((viewBot - listTop) / stride) + 1)
|
||||||
|
for i = first, last do
|
||||||
local mod = mods[i]
|
local mod = mods[i]
|
||||||
local ry = listTop + (i - 1) * (rowH + gap)
|
local ry = listTop + (i - 1) * (rowH + gap)
|
||||||
if ry + rowH >= viewTop and ry <= viewBot then
|
|
||||||
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
||||||
local isFullyDisabled = true
|
local isFullyDisabled = true
|
||||||
if mod.enabledByVersion then
|
if mod.enabledByVersion then
|
||||||
@@ -2178,7 +2281,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
px, ly, PAL.detail)
|
px, ly, PAL.detail)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
local contentH = #mods * rowH + (#mods - 1) * gap
|
local contentH = #mods * rowH + (#mods - 1) * gap
|
||||||
return (listTop + contentH + gap) - y
|
return (listTop + contentH + gap) - y
|
||||||
@@ -2186,8 +2288,8 @@ end
|
|||||||
|
|
||||||
-- ---------------------------------------------------------- find mods panel
|
-- ---------------------------------------------------------- find mods panel
|
||||||
|
|
||||||
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio.
|
-- SKINS tab: pick the on-screen skin, import one, or open Skin Studio.
|
||||||
local function buildSkinsPanel(imp, x, y, w, availH, m)
|
local function buildSkinsPanelLegacy(imp, x, y, w, availH, m)
|
||||||
local skins = imp:_ensureSkins()
|
local skins = imp:_ensureSkins()
|
||||||
local active = imp:_activeSkin()
|
local active = imp:_activeSkin()
|
||||||
local gap = m.gap
|
local gap = m.gap
|
||||||
@@ -2241,7 +2343,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
end
|
end
|
||||||
cy = cy + urlH + math.floor(8 * m.s)
|
cy = cy + urlH + math.floor(8 * m.s)
|
||||||
|
|
||||||
-- Studio button. Desktop only: the host supplies the hook nowhere else.
|
-- The Studio reflows to a touch-first canvas plus inspector on phones.
|
||||||
if imp.onOpenSkinStudio then
|
if imp.onOpenSkinStudio then
|
||||||
local label = Strings("Open Skin Studio")
|
local label = Strings("Open Skin Studio")
|
||||||
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
|
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
|
||||||
@@ -2261,6 +2363,64 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
Kit.caption(x, cy, Strings("INSTALLED"))
|
Kit.caption(x, cy, Strings("INSTALLED"))
|
||||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||||
|
|
||||||
|
-- Keep the actionable list below for detailed metadata and exports, but
|
||||||
|
-- lead with a visual picker: skins are much easier to recognize by their
|
||||||
|
-- bezel than by a folder name. Cards use the same loaded art that the
|
||||||
|
-- runtime draws, so they cannot drift from the selected skin.
|
||||||
|
local previewGap = math.floor(8 * m.s)
|
||||||
|
local previewCols = w >= math.floor(420 * m.s) and 2 or 1
|
||||||
|
local previewW = (w - previewGap * (previewCols - 1)) / previewCols
|
||||||
|
local previewH = math.max(108 * m.s, Kit.tapMin() * 2)
|
||||||
|
local previewCount = #skins
|
||||||
|
for i, entry in ipairs(skins) do
|
||||||
|
local n = i - 1
|
||||||
|
local px = x + (n % previewCols) * (previewW + previewGap)
|
||||||
|
local py = cy + math.floor(n / previewCols) * (previewH + previewGap)
|
||||||
|
local key = "skin-preview-" .. entry.id
|
||||||
|
local selected = active == entry.id
|
||||||
|
local focused = Kit.focusable(key, px, py, previewW, previewH)
|
||||||
|
Kit.card(px, py, previewW, previewH, selected and "selected"
|
||||||
|
or (focused or Kit.hover(px, py, previewW, previewH)))
|
||||||
|
local pad = math.floor(8 * m.s)
|
||||||
|
local artH = math.floor(previewH * 0.60)
|
||||||
|
Theme.fillRounded(px + pad, py + pad, previewW - pad * 2, artH,
|
||||||
|
PAL.bg, 1, Theme.cardRadius() * 0.6)
|
||||||
|
local art = entry.preview
|
||||||
|
if art and art.getDimensions then
|
||||||
|
local iw, ih = art:getDimensions()
|
||||||
|
if iw > 0 and ih > 0 then
|
||||||
|
local scale = math.min((previewW - pad * 4) / iw, (artH - pad * 2) / ih)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(art, px + (previewW - iw * scale) * 0.5,
|
||||||
|
py + pad + (artH - ih * scale) * 0.5, 0, scale, scale)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Theme.strokeRounded(px + previewW * 0.23, py + pad + artH * 0.15,
|
||||||
|
previewW * 0.54, artH * 0.42, PAL.line, Theme.A.hairline, 1, 2)
|
||||||
|
Theme.fillRounded(px + previewW * 0.20, py + pad + artH * 0.64,
|
||||||
|
previewW * 0.22, artH * 0.17, PAL.steel, 0.75, 3)
|
||||||
|
Theme.fillRounded(px + previewW * 0.60, py + pad + artH * 0.61,
|
||||||
|
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
|
||||||
|
Theme.fillRounded(px + previewW * 0.75, py + pad + artH * 0.56,
|
||||||
|
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
|
||||||
|
end
|
||||||
|
Kit.text("mono", Kit.ellipsize("mono", entry.id, previewW - pad * 2),
|
||||||
|
px + pad, py + pad + artH + math.floor(5 * m.s),
|
||||||
|
selected and PAL.green or PAL.heading)
|
||||||
|
if selected then
|
||||||
|
Kit.text("micro", Strings("IN USE"), px + pad,
|
||||||
|
py + previewH - pad - Kit.textHeight("micro"), PAL.green)
|
||||||
|
end
|
||||||
|
if Kit.press(px, py, previewW, previewH) or Kit._activateId == key then
|
||||||
|
queueAction(imp, key, function() imp:_useSkin(entry.id) end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if previewCount > 0 then
|
||||||
|
cy = cy + math.ceil(previewCount / previewCols) * previewH
|
||||||
|
+ math.max(0, math.ceil(previewCount / previewCols) - 1) * previewGap
|
||||||
|
+ gap
|
||||||
|
end
|
||||||
|
|
||||||
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
|
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
|
||||||
imp._skinGear = imp._skinGear
|
imp._skinGear = imp._skinGear
|
||||||
or love.graphics.newImage("assets/launcher/gear.png")
|
or love.graphics.newImage("assets/launcher/gear.png")
|
||||||
@@ -2374,6 +2534,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
return cy + hintH - y
|
return cy + hintH - y
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The launcher is the short path: bring a skin in, see what is enabled, or
|
||||||
|
-- turn skin use off. Browsing, pagination and per-skin editing/export live
|
||||||
|
-- together in My Skins, where they are useful instead of competing here.
|
||||||
|
local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||||
|
local cy, gap, bh = y, m.gap, m.btnH
|
||||||
|
local active = imp:_activeSkin()
|
||||||
|
|
||||||
|
Kit.text("button", Strings("Skins"), x, cy, PAL.heading)
|
||||||
|
local importW = math.min(w * 0.46,
|
||||||
|
Kit.textWidth("small", imp:_skinsImportButtonLabel()) + math.floor(24 * m.s))
|
||||||
|
btn(imp, x + w - importW, cy, importW, bh, "skins-import",
|
||||||
|
imp:_skinsImportButtonLabel(), { kind = "accent", font = "small",
|
||||||
|
action = function() imp:chooseSkin() end })
|
||||||
|
cy = cy + bh + gap
|
||||||
|
|
||||||
|
if imp._skinNotice then
|
||||||
|
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
|
||||||
|
imp._skinNotice.ok and PAL.green or PAL.red, 2) + gap
|
||||||
|
end
|
||||||
|
|
||||||
|
local addW = Kit.textWidth("small", Strings("Add")) + math.floor(24 * m.s)
|
||||||
|
if imp._skinFetch then
|
||||||
|
Loader.inline(x, cy, w, bh, Strings("Downloading %s...",
|
||||||
|
tostring(imp._skinFetch.name or "")))
|
||||||
|
else
|
||||||
|
btn(imp, x + w - addW, cy, addW, bh, "skins-url-add", Strings("Add"), {
|
||||||
|
kind = "accent", font = "small", action = function() imp:_addSkinFromUrl() end })
|
||||||
|
textField(imp, x, cy, w - addW - gap, bh, "skins-url", imp.skinUrl or "",
|
||||||
|
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
|
||||||
|
imp._skinUrlFocus == true, function() imp:_toggleSkinUrlFocus() end)
|
||||||
|
end
|
||||||
|
cy = cy + bh + gap
|
||||||
|
|
||||||
|
local currentH = active and (bh * 2 + gap * 2) or (bh + gap * 2)
|
||||||
|
Kit.card(x, cy, w, currentH)
|
||||||
|
Kit.caption(x + gap, cy + gap, "CURRENT SKIN")
|
||||||
|
local current = active and tostring(active) or Strings("No skin enabled")
|
||||||
|
Kit.text("mono", Kit.ellipsize("mono", current, w - gap * 2), x + gap,
|
||||||
|
cy + gap + Kit.textHeight("small") + math.floor(4 * m.s),
|
||||||
|
active and PAL.green or PAL.muted)
|
||||||
|
local buttonY = cy + bh + gap
|
||||||
|
local half = (w - gap * 3) * 0.5
|
||||||
|
if active then
|
||||||
|
btn(imp, x + gap, buttonY, half, bh, "skins-export-current",
|
||||||
|
Strings("Export current"), { font = "small",
|
||||||
|
action = function() imp:_exportSkin(active, "native") end })
|
||||||
|
btn(imp, x + gap * 2 + half, buttonY, half, bh, "skins-off",
|
||||||
|
Strings("Turn skins off"), { kind = "danger", font = "small",
|
||||||
|
action = function() imp:_disableSkins() end })
|
||||||
|
end
|
||||||
|
cy = cy + currentH + gap
|
||||||
|
|
||||||
|
if imp.onOpenSkinStudio then
|
||||||
|
btn(imp, x, cy, w, bh, "skins-my-skins", Strings("My Skins"), {
|
||||||
|
kind = "accent", font = "small",
|
||||||
|
action = function() imp.onOpenSkinStudio(imp.modScope or "red", active) end })
|
||||||
|
cy = cy + bh + gap
|
||||||
|
end
|
||||||
|
Kit.textWrapped("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."),
|
||||||
|
x, cy, w, PAL.muted, 3)
|
||||||
|
return cy + Kit.wrapHeight("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), w, 3) - y
|
||||||
|
end
|
||||||
|
|
||||||
local function buildBugPanel(imp, x, y, w, availH, m)
|
local function buildBugPanel(imp, x, y, w, availH, m)
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
local gap = m.gap
|
local gap = m.gap
|
||||||
@@ -2934,9 +3157,13 @@ local function buildTextModal(imp, m, key, title, body, closeFn)
|
|||||||
local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s))
|
local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s))
|
||||||
local px, py, pw, ph = modalPanel(m, w, h)
|
local px, py, pw, ph = modalPanel(m, w, h)
|
||||||
local cy = py + pad
|
local cy = py + pad
|
||||||
Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad),
|
local xW = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||||
|
Kit.text("button", Kit.ellipsize("button", title,
|
||||||
|
pw - 2 * pad - xW - math.floor(8 * m.s)),
|
||||||
px + pad, cy, PAL.heading)
|
px + pad, cy, PAL.heading)
|
||||||
cy = cy + Kit.textHeight("button") + math.floor(10 * m.s)
|
btn(imp, px + pw - pad - xW, cy, xW, xW, key .. "-x", "X",
|
||||||
|
{ font = "small", action = closeFn })
|
||||||
|
cy = cy + math.max(Kit.textHeight("button"), xW) + math.floor(10 * m.s)
|
||||||
|
|
||||||
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||||
local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
|
local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
|
||||||
@@ -4274,26 +4501,75 @@ local function syncTitle(imp, m, px, py, pw, pad)
|
|||||||
Kit.text("button", label, px + pad, py, PAL.heading)
|
Kit.text("button", label, px + pad, py, PAL.heading)
|
||||||
local bh = math.floor(15 * m.s)
|
local bh = math.floor(15 * m.s)
|
||||||
local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s)
|
local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s)
|
||||||
Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
|
drawBetaTag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
|
||||||
py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow)
|
py + (Kit.textHeight("button") - bh) / 2, bw, bh)
|
||||||
return py + Kit.textHeight("button") + math.floor(12 * m.s)
|
return py + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function syncStatus(imp, m, x, y, w, eng)
|
local function syncWidth(m, want)
|
||||||
|
return math.floor(math.min(want, m.W - 2 * m.pad))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function syncFit(m, fixed, rows, gaps, texts)
|
||||||
|
local fit = { btnH = m.btnH, gap = math.floor(8 * m.s), lines = {} }
|
||||||
|
local avail = m.H - 2 * m.pad
|
||||||
|
texts = texts or {}
|
||||||
|
for i, blk in ipairs(texts) do fit.lines[i] = blk.max end
|
||||||
|
local function total()
|
||||||
|
local t = fixed + rows * fit.btnH + gaps * fit.gap
|
||||||
|
for i, blk in ipairs(texts) do
|
||||||
|
t = t + Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
while total() > avail do
|
||||||
|
local worst, worstH = nil, 0
|
||||||
|
for i, blk in ipairs(texts) do
|
||||||
|
if fit.lines[i] > 1 then
|
||||||
|
local hgt = Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
|
||||||
|
if hgt > worstH then worst, worstH = i, hgt end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not worst then break end
|
||||||
|
fit.lines[worst] = fit.lines[worst] - 1
|
||||||
|
end
|
||||||
|
if total() > avail and gaps > 0 then
|
||||||
|
fit.gap = math.max(math.max(2, math.floor(3 * m.s)),
|
||||||
|
fit.gap - math.ceil((total() - avail) / gaps))
|
||||||
|
end
|
||||||
|
if total() > avail and rows > 0 then
|
||||||
|
fit.btnH = math.max(Kit.tapMin(),
|
||||||
|
fit.btnH - math.ceil((total() - avail) / rows))
|
||||||
|
end
|
||||||
|
fit.over = total() - avail
|
||||||
|
fit.h = math.min(total(), avail)
|
||||||
|
return fit
|
||||||
|
end
|
||||||
|
|
||||||
|
local function syncStatus(imp, m, x, y, w, eng, fit)
|
||||||
|
local bh = (fit and fit.btnH) or m.btnH
|
||||||
|
local gap = (fit and fit.gap) or math.floor(8 * m.s)
|
||||||
if eng:busy() then
|
if eng:busy() then
|
||||||
Loader.inline(x, y, w, m.btnH, eng.status)
|
Loader.inline(x, y, w, bh, eng.status)
|
||||||
return m.btnH + math.floor(8 * m.s)
|
return bh + gap
|
||||||
end
|
end
|
||||||
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
|
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
|
||||||
eng.phase == "error" and PAL.red or PAL.muted)
|
eng.phase == "error" and PAL.red or PAL.muted)
|
||||||
|
return Kit.textHeight("small") + gap + math.floor(2 * m.s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function syncReserve(m, eng)
|
||||||
|
if eng:busy() then return m.btnH + math.floor(8 * m.s) end
|
||||||
return Kit.textHeight("small") + math.floor(10 * m.s)
|
return Kit.textHeight("small") + math.floor(10 * m.s)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function syncRow(imp, m, x, y, w, key, label, opts)
|
local function syncRow(imp, m, x, y, w, key, label, opts, fit)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
opts.font = "small"
|
opts.font = "small"
|
||||||
btn(imp, x, y, w, m.btnH, key, label, opts)
|
local bh = (fit and fit.btnH) or m.btnH
|
||||||
return y + m.btnH + math.floor(8 * m.s)
|
local gap = (fit and fit.gap) or math.floor(8 * m.s)
|
||||||
|
btn(imp, x, y, w, bh, key, label, opts)
|
||||||
|
return y + bh + gap
|
||||||
end
|
end
|
||||||
|
|
||||||
function LauncherView.syncSideText(meta)
|
function LauncherView.syncSideText(meta)
|
||||||
@@ -4325,91 +4601,99 @@ end
|
|||||||
local function buildSyncConflict(imp, m, eng)
|
local function buildSyncConflict(imp, m, eng)
|
||||||
local row = eng.conflicts[1]
|
local row = eng.conflicts[1]
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.floor(520 * m.s)
|
local w = syncWidth(m, math.floor(520 * m.s))
|
||||||
local innerW = w - 2 * pad
|
local innerW = w - 2 * pad
|
||||||
local lead = row.overlap
|
local lead = row.overlap
|
||||||
and Strings("These saves were played at the same time.")
|
and Strings("These saves were played at the same time.")
|
||||||
or Strings("This save also changed on another device.")
|
or Strings("This save also changed on another device.")
|
||||||
local leadH = Kit.wrapHeight("small", lead, innerW, 2)
|
local mine = LauncherView.syncSideText(row.localMeta)
|
||||||
local sideH = Kit.textHeight("small") + math.floor(2 * m.s)
|
local theirs = LauncherView.syncSideText(row.remoteMeta)
|
||||||
+ Kit.wrapHeight("micro", "x", innerW, 2)
|
local fit = syncFit(m,
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH
|
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
|
||||||
+ math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s))
|
+ 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
|
||||||
+ 4 * (m.btnH + math.floor(8 * m.s)) + pad
|
4, 4, {
|
||||||
local px, py, pw = modalPanel(m, w, h)
|
{ font = "small", str = lead, w = innerW, max = 2 },
|
||||||
|
{ font = "micro", str = mine, w = innerW, max = 2 },
|
||||||
|
{ font = "micro", str = theirs, w = innerW, max = 2 },
|
||||||
|
})
|
||||||
|
local px, py, pw = modalPanel(m, w, fit.h)
|
||||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||||
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad,
|
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW,
|
||||||
PAL.detail, 2) + math.floor(10 * m.s)
|
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||||
|
|
||||||
local function side(title, meta)
|
local function side(title, text, lines)
|
||||||
Kit.text("small", title, px + pad, cy, PAL.heading)
|
Kit.text("small", title, px + pad, cy, PAL.heading)
|
||||||
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
|
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
|
||||||
cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta),
|
cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW,
|
||||||
px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s)
|
PAL.muted, lines) + math.floor(10 * m.s)
|
||||||
end
|
end
|
||||||
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
|
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
|
||||||
row.localMeta)
|
mine, fit.lines[2])
|
||||||
side(Strings("Other device"), row.remoteMeta)
|
side(Strings("Other device"), theirs, fit.lines[3])
|
||||||
|
|
||||||
local key = row.key
|
local key = row.key
|
||||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-this",
|
||||||
Strings("Keep this device"), { kind = "primary",
|
Strings("Keep this device"), { kind = "primary",
|
||||||
action = function() imp:_syncResolve(key, "local") end })
|
action = function() imp:_syncResolve(key, "local") end }, fit)
|
||||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-other",
|
||||||
Strings("Keep the other device"), { kind = "accent",
|
Strings("Keep the other device"), { kind = "accent",
|
||||||
action = function() imp:_syncResolve(key, "remote") end })
|
action = function() imp:_syncResolve(key, "remote") end }, fit)
|
||||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-both",
|
||||||
Strings("Keep both"), {
|
Strings("Keep both"), {
|
||||||
action = function() imp:_syncResolve(key, "both") end })
|
action = function() imp:_syncResolve(key, "both") end }, fit)
|
||||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close",
|
syncRow(imp, m, px + pad, cy, innerW, "sync-conflict-close",
|
||||||
Strings("Close"), { action = function() imp:_closeSync() end })
|
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function buildSyncLink(imp, m, eng)
|
local function buildSyncLink(imp, m, eng)
|
||||||
local mo = imp._syncModal
|
local mo = imp._syncModal
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.floor(460 * m.s)
|
local w = syncWidth(m, math.floor(460 * m.s))
|
||||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
local innerW = w - 2 * pad
|
||||||
local hint = Strings("Enter the two codes the other device is showing.")
|
local hint = Strings("Enter the two codes the other device is showing.")
|
||||||
local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2)
|
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
|
local fit = syncFit(m,
|
||||||
+ math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s))
|
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
|
||||||
+ Kit.textHeight("small") + math.floor(10 * m.s)
|
+ 2 * (fieldH + math.floor(8 * m.s)) + syncReserve(m, eng),
|
||||||
+ 2 * (m.btnH + math.floor(8 * m.s)) + pad
|
2, 2, { { font = "small", str = hint, w = innerW, max = 2 } })
|
||||||
local px, py, pw = modalPanel(m, w, h)
|
local px, py, pw = modalPanel(m, w, fit.h)
|
||||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||||
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad,
|
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW,
|
||||||
PAL.detail, 2) + math.floor(10 * m.s)
|
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||||
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1",
|
textField(imp, px + pad, cy, innerW, fieldH, "sync-code1",
|
||||||
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
|
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
|
||||||
function() imp:_syncFocusField("code1") end)
|
function() imp:_syncFocusField("code1") end)
|
||||||
cy = cy + fieldH + math.floor(8 * m.s)
|
cy = cy + fieldH + fit.gap
|
||||||
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2",
|
textField(imp, px + pad, cy, innerW, fieldH, "sync-code2",
|
||||||
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
|
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
|
||||||
function() imp:_syncFocusField("code2") end)
|
function() imp:_syncFocusField("code2") end)
|
||||||
cy = cy + fieldH + math.floor(8 * m.s)
|
cy = cy + fieldH + fit.gap
|
||||||
cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng)
|
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link-go",
|
||||||
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
|
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
|
||||||
action = function() imp:_syncLink() end })
|
action = function() imp:_syncLink() end }, fit)
|
||||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back",
|
syncRow(imp, m, px + pad, cy, innerW, "sync-link-back",
|
||||||
Strings("Back"), { action = function() imp:_syncView("home") end })
|
Strings("Back"), { action = function() imp:_syncView("home") end }, fit)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function buildSyncMods(imp, m, eng)
|
local function buildSyncMods(imp, m, eng)
|
||||||
local mo = imp._syncModal
|
local mo = imp._syncModal
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.floor(500 * m.s)
|
local w = syncWidth(m, math.floor(500 * m.s))
|
||||||
|
local innerW = w - 2 * pad
|
||||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||||
local plan = eng.modPlan
|
local plan = eng.modPlan
|
||||||
local rows = 4 + (plan and 1 or 0)
|
local rows = 4 + (plan and 1 or 0)
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
local codeH = eng.shareCode and (Kit.textHeight("small")
|
||||||
+ 3 * (Kit.textHeight("small") + math.floor(8 * m.s))
|
+ Kit.textHeight("stat") + Kit.textHeight("micro")
|
||||||
+ fieldH + math.floor(8 * m.s)
|
+ math.floor(18 * m.s)) or 0
|
||||||
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
|
local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0
|
||||||
local px, py, pw = modalPanel(m, w, h)
|
local fit = syncFit(m,
|
||||||
|
2 * pad + Kit.textHeight("button") + math.floor(12 * m.s) + codeH + planH
|
||||||
|
+ fieldH + math.floor(8 * m.s) + syncReserve(m, eng),
|
||||||
|
rows, rows, {})
|
||||||
|
local px, py, pw = modalPanel(m, w, fit.h)
|
||||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||||
local innerW = pw - 2 * pad
|
|
||||||
|
|
||||||
if eng.shareCode then
|
if eng.shareCode then
|
||||||
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
|
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
|
||||||
@@ -4421,17 +4705,23 @@ local function buildSyncMods(imp, m, eng)
|
|||||||
px + pad, cy, PAL.muted)
|
px + pad, cy, PAL.muted)
|
||||||
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
|
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||||
end
|
end
|
||||||
|
local withOptions = mo.withOptions ~= false
|
||||||
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-options",
|
||||||
|
Strings("Include my mod options") .. " \194\183 "
|
||||||
|
.. (withOptions and Strings("ON") or Strings("OFF")),
|
||||||
|
{ kind = withOptions and "accent" or nil, enabled = not eng:busy(),
|
||||||
|
action = function() imp:_syncToggleShareOptions() end }, fit)
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
|
||||||
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
|
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
|
||||||
action = function() imp:_syncShareMods() end })
|
action = function() imp:_syncShareMods() end }, fit)
|
||||||
|
|
||||||
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
|
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
|
||||||
mo.share or "", Strings("Paste a 6-character mod code"),
|
mo.share or "", Strings("Paste a 6-character mod code"),
|
||||||
imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
|
imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
|
||||||
cy = cy + fieldH + math.floor(8 * m.s)
|
cy = cy + fieldH + fit.gap
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
|
||||||
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
|
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
|
||||||
action = function() imp:_syncGetShare() end })
|
action = function() imp:_syncGetShare() end }, fit)
|
||||||
|
|
||||||
if plan then
|
if plan then
|
||||||
local line = Strings("%d mods, %d indexes to add",
|
local line = Strings("%d mods, %d indexes to add",
|
||||||
@@ -4440,24 +4730,29 @@ local function buildSyncMods(imp, m, eng)
|
|||||||
line = line .. " \194\183 " .. Strings("%d not in your indexes",
|
line = line .. " \194\183 " .. Strings("%d not in your indexes",
|
||||||
#plan.missing)
|
#plan.missing)
|
||||||
end
|
end
|
||||||
|
if #(plan.options or {}) > 0 then
|
||||||
|
line = line .. " \194\183 " .. (plan.applyOptions
|
||||||
|
and Strings("options for %d mods", #plan.options)
|
||||||
|
or Strings("their options skipped"))
|
||||||
|
end
|
||||||
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
|
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
|
||||||
PAL.detail)
|
PAL.detail)
|
||||||
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
|
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
|
||||||
local prog = mo.progress
|
local prog = mo.progress
|
||||||
if prog then
|
if prog then
|
||||||
Loader.inline(px + pad, cy, innerW, m.btnH,
|
Loader.inline(px + pad, cy, innerW, fit.btnH,
|
||||||
Strings("%d of %d", prog.done or 0, prog.total or 0))
|
Strings("%d of %d", prog.done or 0, prog.total or 0))
|
||||||
cy = cy + m.btnH + math.floor(8 * m.s)
|
cy = cy + fit.btnH + fit.gap
|
||||||
else
|
else
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
|
||||||
Strings("Apply these mods"), { kind = "primary",
|
Strings("Apply these mods"), { kind = "primary",
|
||||||
enabled = not eng:busy(),
|
enabled = not eng:busy(),
|
||||||
action = function() imp:_syncApplyMods() end })
|
action = function() imp:_syncApplyMods() end }, fit)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
|
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||||
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
|
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
|
||||||
{ action = function() imp:_syncView("home") end })
|
{ action = function() imp:_syncView("home") end }, fit)
|
||||||
end
|
end
|
||||||
|
|
||||||
function LauncherView.syncDeviceRows(eng, limit)
|
function LauncherView.syncDeviceRows(eng, limit)
|
||||||
@@ -4479,29 +4774,35 @@ end
|
|||||||
|
|
||||||
local function buildSyncHome(imp, m, eng)
|
local function buildSyncHome(imp, m, eng)
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.floor(460 * m.s)
|
local w = syncWidth(m, math.floor(460 * m.s))
|
||||||
local linked = eng:linked()
|
local linked = eng:linked()
|
||||||
local codes = eng.codes
|
local codes = eng.codes
|
||||||
local body = linked
|
local body = linked
|
||||||
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.")
|
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
|
||||||
or Strings(SYNC_HINT)
|
or Strings(SYNC_HINT)
|
||||||
local innerW = w - 2 * pad
|
local innerW = w - 2 * pad
|
||||||
local hintH = Kit.wrapHeight("small", body, innerW, 5)
|
|
||||||
local codesH = codes
|
local codesH = codes
|
||||||
and (Kit.textHeight("small") + math.floor(6 * m.s)
|
and (Kit.textHeight("small") + math.floor(6 * m.s)
|
||||||
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
|
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
|
||||||
+ math.floor(8 * m.s)) or 0
|
+ math.floor(8 * m.s)) or 0
|
||||||
local devices = linked and LauncherView.syncDeviceRows(eng) or {}
|
local devices = linked and LauncherView.syncDeviceRows(eng) or {}
|
||||||
|
local hidden, fit = 0, nil
|
||||||
|
repeat
|
||||||
local devicesH = #devices > 0
|
local devicesH = #devices > 0
|
||||||
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
|
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
|
||||||
local rows = (linked and 5 or 3) + #devices
|
fit = syncFit(m,
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
|
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH
|
||||||
+ math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s)
|
+ devicesH + syncReserve(m, eng),
|
||||||
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
|
(linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices,
|
||||||
local px, py, pw = modalPanel(m, w, h)
|
{ { font = "small", str = body, w = innerW, max = 5 } })
|
||||||
|
if fit.over <= 0 or #devices == 0 then break end
|
||||||
|
table.remove(devices)
|
||||||
|
hidden = hidden + 1
|
||||||
|
until false
|
||||||
|
local px, py, pw = modalPanel(m, w, fit.h)
|
||||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||||
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5)
|
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail,
|
||||||
+ math.floor(10 * m.s)
|
fit.lines[1]) + math.floor(10 * m.s)
|
||||||
|
|
||||||
if codes then
|
if codes then
|
||||||
Kit.text("small", Strings("Enter these on your other device:"), px + pad,
|
Kit.text("small", Strings("Enter these on your other device:"), px + pad,
|
||||||
@@ -4512,23 +4813,24 @@ local function buildSyncHome(imp, m, eng)
|
|||||||
Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
|
Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
|
||||||
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
|
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
|
||||||
end
|
end
|
||||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
|
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||||
|
|
||||||
if #devices > 0 then
|
if #devices > 0 then
|
||||||
Kit.text("small", Strings("Devices on this account:"), px + pad, cy,
|
Kit.text("small", hidden > 0
|
||||||
PAL.muted)
|
and Strings("Devices on this account (%d more)", hidden)
|
||||||
|
or Strings("Devices on this account:"), px + pad, cy, PAL.muted)
|
||||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||||
for i, device in ipairs(devices) do
|
for i, device in ipairs(devices) do
|
||||||
local id = device.id
|
local id = device.id
|
||||||
if device.current then
|
if device.current then
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
||||||
device.label .. " \194\183 " .. Strings("this device"),
|
device.label .. " \194\183 " .. Strings("this device"),
|
||||||
{ enabled = false })
|
{ enabled = false }, fit)
|
||||||
else
|
else
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
||||||
Strings("Unlink %s", device.label), { kind = "danger",
|
Strings("Unlink %s", device.label), { kind = "danger",
|
||||||
enabled = not eng:busy(),
|
enabled = not eng:busy(),
|
||||||
action = function() imp:_syncUnlinkDevice(id) end })
|
action = function() imp:_syncUnlinkDevice(id) end }, fit)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4536,38 +4838,69 @@ local function buildSyncHome(imp, m, eng)
|
|||||||
if linked then
|
if linked then
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
|
||||||
{ kind = "primary", enabled = not eng:busy(),
|
{ kind = "primary", enabled = not eng:busy(),
|
||||||
action = function() imp:_syncNow() end })
|
action = function() imp:_syncNow() end }, fit)
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
|
||||||
Strings("Share or get a mod list"), { kind = "accent",
|
Strings("Share or get a mod list"), { kind = "accent",
|
||||||
action = function() imp:_syncView("mods") end })
|
action = function() imp:_syncView("mods") end }, fit)
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
|
||||||
Strings("Unlink this device"), { kind = "danger",
|
Strings("Unlink this device"), { kind = "danger",
|
||||||
action = function() imp:_syncUnlink() end })
|
action = function() imp:_syncUnlink() end }, fit)
|
||||||
else
|
else
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
|
||||||
Strings("Create sync account"), { kind = "primary",
|
Strings("Create sync account"), { kind = "primary",
|
||||||
enabled = not eng:busy(),
|
enabled = not eng:busy(),
|
||||||
action = function() imp:_syncCreate() end })
|
action = function() imp:_syncCreate() end }, fit)
|
||||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
|
||||||
Strings("Link this device"), { kind = "accent",
|
Strings("Link this device"), { kind = "accent",
|
||||||
action = function() imp:_syncView("link") end })
|
action = function() imp:_syncView("link") end }, fit)
|
||||||
end
|
end
|
||||||
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
|
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
|
||||||
{ action = function() imp:_closeSync() end })
|
{ action = function() imp:_closeSync() end }, fit)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildSyncModOptions(imp, m, eng)
|
||||||
|
local plan = eng.modPlan
|
||||||
|
local ids = {}
|
||||||
|
for _, row in ipairs(plan.options or {}) do ids[#ids + 1] = row.id end
|
||||||
|
local pad = math.floor(18 * m.s)
|
||||||
|
local w = syncWidth(m, math.floor(480 * m.s))
|
||||||
|
local innerW = w - 2 * pad
|
||||||
|
local lead = Strings(
|
||||||
|
"This mod list also carries the options its owner set for %d mods. Import their options, or keep the ones you have?",
|
||||||
|
#ids)
|
||||||
|
local names = table.concat(ids, ", ")
|
||||||
|
local fit = syncFit(m,
|
||||||
|
2 * pad + Kit.textHeight("button") + math.floor(32 * m.s), 2, 2, {
|
||||||
|
{ font = "small", str = lead, w = innerW, max = 4 },
|
||||||
|
{ font = "micro", str = names, w = innerW, max = 3 },
|
||||||
|
})
|
||||||
|
local px, py, pw = modalPanel(m, w, fit.h)
|
||||||
|
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||||
|
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, PAL.detail,
|
||||||
|
fit.lines[1]) + math.floor(8 * m.s)
|
||||||
|
cy = cy + Kit.textWrapped("micro", names, px + pad, cy, innerW, PAL.muted,
|
||||||
|
fit.lines[2]) + math.floor(12 * m.s)
|
||||||
|
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-options-import",
|
||||||
|
Strings("Import their options"), { kind = "primary",
|
||||||
|
action = function() imp:_syncAnswerModOptions(true) end }, fit)
|
||||||
|
syncRow(imp, m, px + pad, cy, innerW, "sync-options-skip",
|
||||||
|
Strings("Keep my options"), {
|
||||||
|
action = function() imp:_syncAnswerModOptions(false) end }, fit)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function buildSyncUnavailable(imp, m, msg)
|
local function buildSyncUnavailable(imp, m, msg)
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.floor(420 * m.s)
|
local w = syncWidth(m, math.floor(420 * m.s))
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
local innerW = w - 2 * pad
|
||||||
+ Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s)
|
local fit = syncFit(m,
|
||||||
+ m.btnH + pad
|
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0,
|
||||||
local px, py, pw = modalPanel(m, w, h)
|
{ { font = "small", str = msg, w = innerW, max = 4 } })
|
||||||
|
local px, py, pw = modalPanel(m, w, fit.h)
|
||||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||||
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad,
|
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW,
|
||||||
PAL.detail, 4) + math.floor(10 * m.s)
|
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close",
|
syncRow(imp, m, px + pad, cy, innerW, "sync-close",
|
||||||
Strings("Close"), { action = function() imp:_closeSync() end })
|
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function buildSyncModal(imp, m)
|
local function buildSyncModal(imp, m)
|
||||||
@@ -4586,6 +4919,12 @@ local function buildSyncModal(imp, m)
|
|||||||
buildSyncConflict(imp, m, eng)
|
buildSyncConflict(imp, m, eng)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local plan = eng.modPlan
|
||||||
|
if type(plan) == "table" and #(plan.options or {}) > 0
|
||||||
|
and plan.applyOptions == nil then
|
||||||
|
buildSyncModOptions(imp, m, eng)
|
||||||
|
return
|
||||||
|
end
|
||||||
local view = imp._syncModal and imp._syncModal.view or "home"
|
local view = imp._syncModal and imp._syncModal.view or "home"
|
||||||
if view == "link" then
|
if view == "link" then
|
||||||
buildSyncLink(imp, m, eng)
|
buildSyncLink(imp, m, eng)
|
||||||
@@ -4868,13 +5207,16 @@ function LauncherView.draw(imp)
|
|||||||
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
||||||
imp._clickPt = nil
|
imp._clickPt = nil
|
||||||
imp._wheelY = 0
|
imp._wheelY = 0
|
||||||
|
imp._noDragN = 0
|
||||||
|
|
||||||
Theme.field()
|
Theme.field()
|
||||||
|
|
||||||
-- Everything from here to buildModals sits UNDER any open modal, so the
|
-- Everything from here to buildModals sits UNDER any open modal, so the
|
||||||
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
||||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||||
Kit.blockClicks = modalUp(imp)
|
imp._modalUpNow = modalUp(imp)
|
||||||
|
if imp._modalUpNow then imp:_blurPanelFields() end
|
||||||
|
Kit.blockClicks = imp._modalUpNow
|
||||||
|
|
||||||
local step = Kit.scrollStep(m.s)
|
local step = Kit.scrollStep(m.s)
|
||||||
do
|
do
|
||||||
|
|||||||
+263
-38
@@ -27,7 +27,7 @@ RomExtractorGen2.__index = RomExtractorGen2
|
|||||||
-- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex,
|
-- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex,
|
||||||
-- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation,
|
-- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation,
|
||||||
-- audio, stubs
|
-- audio, stubs
|
||||||
local STAGE_COUNT = 26
|
local STAGE_COUNT = 27
|
||||||
local Opcodes = require("src.script.gen2.Opcodes")
|
local Opcodes = require("src.script.gen2.Opcodes")
|
||||||
|
|
||||||
-- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm
|
-- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm
|
||||||
@@ -136,6 +136,18 @@ local TEXT_NO_GLYPH = {
|
|||||||
[0x13] = true, [0x15] = true,
|
[0x13] = true, [0x15] = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- The three runtime name slots. PlaceMoveUsersName, PlaceMoveTargetsName and
|
||||||
|
-- PlaceEnemysName (home/text.asm:302, :307, :327) swap these for a battler's
|
||||||
|
-- own name as the line prints, so they are markers rather than glyphs. They
|
||||||
|
-- decode to the same shape Gen 1 uses, which src/core/RomText.lua already
|
||||||
|
-- fills in argument order. Dropped, SubTookDamageText read "took damage
|
||||||
|
-- for" with nothing after it.
|
||||||
|
local NAME_SLOT = {
|
||||||
|
["<USER>"] = "{USER}",
|
||||||
|
["<TARGET>"] = "{TARGET}",
|
||||||
|
["<ENEMY>"] = "{ENEMY}",
|
||||||
|
}
|
||||||
|
|
||||||
local ROOF_TILES = 9
|
local ROOF_TILES = 9
|
||||||
local SPRITEDATA_LENGTH = 6
|
local SPRITEDATA_LENGTH = 6
|
||||||
|
|
||||||
@@ -2596,6 +2608,8 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
|
|||||||
out[#out + 1] = ch
|
out[#out + 1] = ch
|
||||||
elseif ch == "<……>" or b == 0x56 then
|
elseif ch == "<……>" or b == 0x56 then
|
||||||
out[#out + 1] = "……"
|
out[#out + 1] = "……"
|
||||||
|
elseif NAME_SLOT[ch] then
|
||||||
|
out[#out + 1] = NAME_SLOT[ch]
|
||||||
elseif not ch then
|
elseif not ch then
|
||||||
out[#out + 1] = ("{BYTE:%02X}"):format(b)
|
out[#out + 1] = ("{BYTE:%02X}"):format(b)
|
||||||
end
|
end
|
||||||
@@ -3439,6 +3453,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
|
|||||||
elseif info.name == "givepoke" then
|
elseif info.name == "givepoke" then
|
||||||
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
||||||
args[1], args[2], args[3], args[4]
|
args[1], args[2], args[3], args[4]
|
||||||
|
-- Script_givepoke (engine/overworld/scripting.asm:1806)
|
||||||
|
if size == 8 then
|
||||||
|
local function readAt(lo, hi)
|
||||||
|
local addr = (args[lo] or 0) + (args[hi] or 0) * 0x100
|
||||||
|
if not romAddrOk(bank, addr) then return nil end
|
||||||
|
local okStr, str = pcall(self.rom.readString, self.rom,
|
||||||
|
bank, addr, charmap, 0x50, 16)
|
||||||
|
return okStr and str or nil
|
||||||
|
end
|
||||||
|
cmd.name = readAt(5, 6)
|
||||||
|
cmd.otName = readAt(7, 8)
|
||||||
|
end
|
||||||
elseif info.name == "pokepic" or info.name == "disappear" then
|
elseif info.name == "pokepic" or info.name == "disappear" then
|
||||||
cmd.species = args[1] -- pokepic
|
cmd.species = args[1] -- pokepic
|
||||||
cmd.object = args[1] -- disappear (same byte)
|
cmd.object = args[1] -- disappear (same byte)
|
||||||
@@ -3686,6 +3712,37 @@ function RomExtractorGen2:splashGfx()
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The engine's own strings, keyed by the label the disassembly gives them.
|
||||||
|
--
|
||||||
|
-- This is what extractOakSpeech has always done for _OakText1-7: resolve the
|
||||||
|
-- label, decode from the cart, key by name. What is new is that the list of
|
||||||
|
-- labels comes from the manifest instead of being written out here, so all of
|
||||||
|
-- data/text/ arrives rather than seven strings. Gen 1 has had the same table
|
||||||
|
-- since RomExtractor:extractText; this is the Gen 2 side of it, and it is
|
||||||
|
-- what lets src/core/RomText.lua work on Gold and Silver at all.
|
||||||
|
--
|
||||||
|
-- Written as `rom_text` rather than `text`: data/generated/text.lua is
|
||||||
|
-- already the script text, keyed by bank:address for the overworld VM, and
|
||||||
|
-- these are a different table with different keys.
|
||||||
|
function RomExtractorGen2:extractText()
|
||||||
|
self:beginStage("Dialogue")
|
||||||
|
local charmap = self.manifest.charmap or {}
|
||||||
|
local labels = (self.manifest.text or {}).labels or {}
|
||||||
|
local texts = {}
|
||||||
|
for index, label in ipairs(labels) do
|
||||||
|
local location = self.symbols[label]
|
||||||
|
-- A label the manifest names but the symbol table does not carry would
|
||||||
|
-- be a generator bug, not a cart difference: make_gold_manifest.py
|
||||||
|
-- resolves every one of these before it writes the list.
|
||||||
|
if location then
|
||||||
|
texts[label] = self:decodeGen2Text(location[1], location[2], charmap)
|
||||||
|
end
|
||||||
|
self:tick("Dialogue", index, #labels)
|
||||||
|
end
|
||||||
|
self:write("rom_text", texts)
|
||||||
|
return texts
|
||||||
|
end
|
||||||
|
|
||||||
-- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the
|
-- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the
|
||||||
-- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls
|
-- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls
|
||||||
-- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema.
|
-- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema.
|
||||||
@@ -5139,6 +5196,7 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
}
|
}
|
||||||
out.pack = pack
|
out.pack = pack
|
||||||
out.pokedex = self:pokedexGfx()
|
out.pokedex = self:pokedexGfx()
|
||||||
|
out.billsPc = self:billsPcGfx()
|
||||||
out.pokegear = self:pokegearGfx()
|
out.pokegear = self:pokegearGfx()
|
||||||
out.trainerCard = self:trainerCardGfx()
|
out.trainerCard = self:trainerCardGfx()
|
||||||
out.unownPuzzle = self:unownPuzzleGfx()
|
out.unownPuzzle = self:unownPuzzleGfx()
|
||||||
@@ -5231,81 +5289,228 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
end
|
end
|
||||||
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
||||||
|
|
||||||
|
-- StatsScreenPageTilesGFX (gfx/font.asm:23), the 17 tiles
|
||||||
|
-- LoadStatsScreenPageTilesGFX lands at vTiles2 $31 (engine/gfx/load_font.asm:90).
|
||||||
|
local hpBarBorder = self.symbols["EnemyHPBarBorderGFX"]
|
||||||
|
if hpBarBorder then
|
||||||
|
local address = hpBarBorder[2] - 17 * 16
|
||||||
|
self:write2bpp(self.rom:bytes(hpBarBorder[1], address, 17 * 16),
|
||||||
|
17 * 8, 8, "menu/stats_tiles.png")
|
||||||
|
out.stats = {
|
||||||
|
sheet = "assets/generated/menu/stats_tiles.png",
|
||||||
|
tiles = 17,
|
||||||
|
firstTile = 0x31,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
-- Goldenrod Game Corner: Slot Machine graphics assets
|
-- Goldenrod Game Corner: Slot Machine graphics assets
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local function packBytes(bytes)
|
||||||
|
local chars = {}
|
||||||
|
for i = 1, #bytes do chars[i] = string.char(bytes[i]) end
|
||||||
|
return table.concat(chars)
|
||||||
|
end
|
||||||
|
local function writeRaw(relative, bytes)
|
||||||
|
local ok, writeError = CacheFs.write(
|
||||||
|
"assets/generated/" .. relative, packBytes(bytes))
|
||||||
|
if not ok then
|
||||||
|
error("could not write " .. relative .. ": " .. tostring(writeError))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Canonical sheet sizes match the cart art the UI indexes (and pret's
|
||||||
|
-- gfx/slots + gfx/card_flip PNGs). ROM LZ streams are those sheets after
|
||||||
|
-- Makefile gfx transforms; reverse what the decompressed bytes still carry.
|
||||||
|
local SLOTS1_W, SLOTS1_H = 16, 152
|
||||||
|
local SLOTS2_W, SLOTS2_H = 16, 256
|
||||||
|
local SLOTS3_W, SLOTS3_H = 24, 240
|
||||||
|
local CARD1_W, CARD1_H = 128, 32
|
||||||
|
local CARD2_W, CARD2_H = 24, 160
|
||||||
|
local CARD3_W, CARD3_H = 8, 56
|
||||||
|
|
||||||
|
local function pad2bpp(raw, width, height)
|
||||||
|
local need = width * height / 4
|
||||||
|
while #raw < need do raw[#raw + 1] = 0 end
|
||||||
|
while #raw > need do table.remove(raw) end
|
||||||
|
return raw
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeSheet(raw, width, height, relative, transparent)
|
||||||
|
self:write2bpp(pad2bpp(raw, width, height), width, height, relative,
|
||||||
|
transparent)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Slots3LZ is unique 8x16 OBJ columns (interleave + remove-duplicates +
|
||||||
|
-- remove-xflip). Rebuild the 24x240 actor sheet the UI quads expect from
|
||||||
|
-- OAMData_SlotsGolem / Chansey* / Egg (data/sprite_anims/oam.asm), same
|
||||||
|
-- pattern as title-screen Ho-Oh frame composition above.
|
||||||
|
local function composeSlotsActors(raw)
|
||||||
|
local tileCount = math.floor(#raw / 16)
|
||||||
|
local tiles = {}
|
||||||
|
for index = 0, tileCount - 1 do
|
||||||
|
local one = {}
|
||||||
|
for b = 1, 16 do one[b] = raw[index * 16 + b] or 0 end
|
||||||
|
tiles[index] = ImageWriter.decode2bpp(one, 8, 8, true)
|
||||||
|
end
|
||||||
|
local sheet = ImageWriter.blank(SLOTS3_W, SLOTS3_H, 1, 1, 1, 0)
|
||||||
|
local function blit8x16(tileId, dx, dy, flipX)
|
||||||
|
local top, bot = tiles[tileId], tiles[tileId + 1]
|
||||||
|
if not (top and bot) then return end
|
||||||
|
ImageWriter.blit(sheet, top, dx, dy, 0, 0, 8, 8, flipX)
|
||||||
|
ImageWriter.blit(sheet, bot, dx, dy + 8, 0, 0, 8, 8, flipX)
|
||||||
|
end
|
||||||
|
local function blitPose(poseY, base, entries)
|
||||||
|
for _, e in ipairs(entries) do
|
||||||
|
blit8x16(base + e.t, (e.x + 2) * 8, poseY + (e.y + 2) * 8, e.xf)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local golem = {
|
||||||
|
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||||
|
{ x = 0, y = -2, t = 0x00, xf = true },
|
||||||
|
{ x = -2, y = 0, t = 0x04 }, { x = -1, y = 0, t = 0x06 },
|
||||||
|
{ x = 0, y = 0, t = 0x04, xf = true },
|
||||||
|
}
|
||||||
|
local chansey = {
|
||||||
|
{
|
||||||
|
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||||
|
{ x = 0, y = -2, t = 0x04 },
|
||||||
|
{ x = -2, y = 0, t = 0x06 }, { x = -1, y = 0, t = 0x08 },
|
||||||
|
{ x = 0, y = 0, t = 0x0a },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||||
|
{ x = 0, y = -2, t = 0x04 },
|
||||||
|
{ x = -2, y = 0, t = 0x0c }, { x = -1, y = 0, t = 0x0e },
|
||||||
|
{ x = 0, y = 0, t = 0x10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||||
|
{ x = 0, y = -2, t = 0x04 },
|
||||||
|
{ x = -2, y = 0, t = 0x12 }, { x = -1, y = 0, t = 0x14 },
|
||||||
|
{ x = 0, y = 0, t = 0x16 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||||
|
{ x = 0, y = -2, t = 0x04 },
|
||||||
|
{ x = -2, y = 0, t = 0x18 }, { x = -1, y = 0, t = 0x1a },
|
||||||
|
{ x = 0, y = 0, t = 0x1c },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{ x = -2, y = -2, t = 0x1e }, { x = -1, y = -2, t = 0x20 },
|
||||||
|
{ x = 0, y = -2, t = 0x22 },
|
||||||
|
{ x = -2, y = 0, t = 0x24 }, { x = -1, y = 0, t = 0x26 },
|
||||||
|
{ x = 0, y = 0, t = 0x28 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
blitPose(0, 0x00, golem)
|
||||||
|
blitPose(32, 0x08, golem)
|
||||||
|
for index, frame in ipairs(chansey) do
|
||||||
|
blitPose(32 + index * 32, 0x10, frame)
|
||||||
|
end
|
||||||
|
blit8x16(0x3a, 0, 224, false)
|
||||||
|
return sheet
|
||||||
|
end
|
||||||
|
|
||||||
|
-- card_flip_2.2bpp uses --remove-whitespace: blank tiles in column 2 of the
|
||||||
|
-- 3-wide header strip (indices 2,5,...,23) are dropped from the ROM stream.
|
||||||
|
-- Re-insert them so HEADER_TILE_MAP / MON_ANCHORS (pret sheet indices) work.
|
||||||
|
local function expandCardFlip2(compact)
|
||||||
|
local need = CARD2_W * CARD2_H / 4
|
||||||
|
local out = {}
|
||||||
|
for i = 1, need do out[i] = 0 end
|
||||||
|
local whitespace = {
|
||||||
|
[2] = true, [5] = true, [8] = true, [11] = true,
|
||||||
|
[14] = true, [17] = true, [20] = true, [23] = true,
|
||||||
|
}
|
||||||
|
local src = 0
|
||||||
|
for tile = 0, 59 do
|
||||||
|
if not whitespace[tile] then
|
||||||
|
for b = 1, 16 do
|
||||||
|
out[tile * 16 + b] = compact[src * 16 + b] or 0
|
||||||
|
end
|
||||||
|
src = src + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local slots = nil
|
||||||
if self.symbols["Slots1LZ"] then
|
if self.symbols["Slots1LZ"] then
|
||||||
|
-- --trim-whitespace drops the final empty tile (37 of 38).
|
||||||
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||||||
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
|
writeSheet(raw1, SLOTS1_W, SLOTS1_H, "slots/gold_slots_1.png")
|
||||||
|
slots = slots or {}
|
||||||
|
slots.sheet1 = "assets/generated/slots/gold_slots_1.png"
|
||||||
end
|
end
|
||||||
if self.symbols["Slots2LZ"] then
|
if self.symbols["Slots2LZ"] then
|
||||||
local raw2 = self:decompressLz3Symbol("Slots2LZ")
|
local raw2 = ImageWriter.deinterleave(
|
||||||
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
|
self:decompressLz3Symbol("Slots2LZ"), SLOTS2_W)
|
||||||
|
-- Commercial Gold stores the Seven symbol with inverted bit polarity.
|
||||||
for i = 1, math.min(64, #raw2) do
|
for i = 1, math.min(64, #raw2) do
|
||||||
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
|
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
|
||||||
end
|
end
|
||||||
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
|
writeSheet(raw2, SLOTS2_W, SLOTS2_H, "slots/gold_slots_2.png")
|
||||||
|
slots = slots or {}
|
||||||
|
slots.sheet2 = "assets/generated/slots/gold_slots_2.png"
|
||||||
end
|
end
|
||||||
if self.symbols["Slots3LZ"] then
|
if self.symbols["Slots3LZ"] then
|
||||||
local raw3 = self:decompressLz3Symbol("Slots3LZ")
|
local raw3 = self:decompressLz3Symbol("Slots3LZ")
|
||||||
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
|
local actors = composeSlotsActors(raw3)
|
||||||
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
|
self:save(actors, "slots/gold_slots_3.png")
|
||||||
-- Y=0: Golem 1 (Standing, 24x32)
|
self:save(actors, "slots/gold_slots_actors.png")
|
||||||
-- Y=32: Golem 2 (Ball, 24x32)
|
slots = slots or {}
|
||||||
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
|
slots.sheet3 = "assets/generated/slots/gold_slots_3.png"
|
||||||
-- Y=96: Chansey 2 (Step 2, 24x32)
|
|
||||||
-- Y=128: Chansey 3 (Step 3, 24x32)
|
|
||||||
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
|
|
||||||
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
|
|
||||||
-- Y=224: Egg (8x16 at X=0)
|
|
||||||
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
|
|
||||||
end
|
end
|
||||||
if self.symbols["SlotsTilemap"] then
|
if self.symbols["SlotsTilemap"] then
|
||||||
local symbol = self:symbol("SlotsTilemap")
|
local symbol = self:symbol("SlotsTilemap")
|
||||||
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
|
||||||
self:save(tm, "slots/gold_slots.tilemap")
|
writeRaw("slots/gold_slots.tilemap", tm)
|
||||||
|
slots = slots or {}
|
||||||
|
slots.tilemap = "assets/generated/slots/gold_slots.tilemap"
|
||||||
end
|
end
|
||||||
|
if slots then out.slots = slots end
|
||||||
|
|
||||||
-- Goldenrod Game Corner: Card Flip graphics assets
|
-- Goldenrod Game Corner: Card Flip graphics assets
|
||||||
|
local cardFlip = nil
|
||||||
if self.symbols["CardFlipLZ01"] then
|
if self.symbols["CardFlipLZ01"] then
|
||||||
|
-- --trim-whitespace: 62 of 64 tiles in the ROM stream.
|
||||||
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
|
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
|
||||||
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
|
writeSheet(raw1, CARD1_W, CARD1_H, "card_flip/card_flip_1.png")
|
||||||
|
cardFlip = cardFlip or {}
|
||||||
|
cardFlip.sheet1 = "assets/generated/card_flip/card_flip_1.png"
|
||||||
end
|
end
|
||||||
if self.symbols["CardFlipLZ02"] then
|
if self.symbols["CardFlipLZ02"] then
|
||||||
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
|
local raw2 = expandCardFlip2(self:decompressLz3Symbol("CardFlipLZ02"))
|
||||||
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
|
writeSheet(raw2, CARD2_W, CARD2_H, "card_flip/card_flip_2.png")
|
||||||
|
cardFlip = cardFlip or {}
|
||||||
|
cardFlip.sheet2 = "assets/generated/card_flip/card_flip_2.png"
|
||||||
end
|
end
|
||||||
if self.symbols["CardFlipLZ03"] then
|
if self.symbols["CardFlipLZ03"] then
|
||||||
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
|
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
|
||||||
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
|
writeSheet(raw3, CARD3_W, CARD3_H, "card_flip/card_flip_3.png")
|
||||||
|
cardFlip = cardFlip or {}
|
||||||
|
cardFlip.sheet3 = "assets/generated/card_flip/card_flip_3.png"
|
||||||
end
|
end
|
||||||
if self.symbols["CardFlipOnButtonGFX"] then
|
if self.symbols["CardFlipOnButtonGFX"] then
|
||||||
local symbol = self:symbol("CardFlipOnButtonGFX")
|
local symbol = self:symbol("CardFlipOnButtonGFX")
|
||||||
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
|
||||||
|
cardFlip = cardFlip or {}
|
||||||
|
cardFlip.on = "assets/generated/card_flip/on.png"
|
||||||
end
|
end
|
||||||
if self.symbols["CardFlipOffButtonGFX"] then
|
if self.symbols["CardFlipOffButtonGFX"] then
|
||||||
local symbol = self:symbol("CardFlipOffButtonGFX")
|
local symbol = self:symbol("CardFlipOffButtonGFX")
|
||||||
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
|
||||||
|
cardFlip = cardFlip or {}
|
||||||
|
cardFlip.off = "assets/generated/card_flip/off.png"
|
||||||
end
|
end
|
||||||
if self.symbols["CardFlipTilemap"] then
|
if self.symbols["CardFlipTilemap"] then
|
||||||
local symbol = self:symbol("CardFlipTilemap")
|
local symbol = self:symbol("CardFlipTilemap")
|
||||||
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
|
||||||
self:save(tm, "card_flip/card_flip.tilemap")
|
writeRaw("card_flip/card_flip.tilemap", tm)
|
||||||
|
cardFlip = cardFlip or {}
|
||||||
|
cardFlip.tilemap = "assets/generated/card_flip/card_flip.tilemap"
|
||||||
end
|
end
|
||||||
|
if cardFlip then out.cardFlip = cardFlip end
|
||||||
out.slots = {
|
|
||||||
sheet1 = "assets/generated/slots/gold_slots_1.png",
|
|
||||||
sheet2 = "assets/generated/slots/gold_slots_2.png",
|
|
||||||
sheet3 = "assets/generated/slots/gold_slots_3.png",
|
|
||||||
tilemap = "assets/generated/slots/gold_slots.tilemap",
|
|
||||||
}
|
|
||||||
|
|
||||||
out.cardFlip = {
|
|
||||||
sheet1 = "assets/generated/card_flip/card_flip_1.png",
|
|
||||||
sheet2 = "assets/generated/card_flip/card_flip_2.png",
|
|
||||||
sheet3 = "assets/generated/card_flip/card_flip_3.png",
|
|
||||||
on = "assets/generated/card_flip/on.png",
|
|
||||||
off = "assets/generated/card_flip/off.png",
|
|
||||||
tilemap = "assets/generated/card_flip/card_flip.tilemap",
|
|
||||||
}
|
|
||||||
|
|
||||||
self:write("menu_gfx", out)
|
self:write("menu_gfx", out)
|
||||||
self:tick("Menu graphics", 1, 1)
|
self:tick("Menu graphics", 1, 1)
|
||||||
@@ -5538,6 +5743,25 @@ function RomExtractorGen2:pokedexGfx()
|
|||||||
return dex
|
return dex
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- BillsPC_InitGFX's four tiles at vTiles2 $5c
|
||||||
|
-- (engine/pokemon/bills_pc.asm:2170-2173)
|
||||||
|
function RomExtractorGen2:billsPcGfx()
|
||||||
|
if not self.symbols["PCMailGFX"] then return nil end
|
||||||
|
local pc = {}
|
||||||
|
local gfx = self:symbol("PCMailGFX")
|
||||||
|
self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16),
|
||||||
|
32, 8, "pc/mail_item.png")
|
||||||
|
pc.icons = "assets/generated/pc/mail_item.png"
|
||||||
|
pc.firstTile = 0x5c
|
||||||
|
pc.palette = self:predefPal(PREDEFPAL_POKEDEX)
|
||||||
|
-- gfx/pc/orange.pal
|
||||||
|
if self.symbols["BillsPCOrangePalette"] then
|
||||||
|
local orange = self:symbol("BillsPCOrangePalette")
|
||||||
|
pc.orangePalette = self:colors(orange.bank, orange.address, 4)
|
||||||
|
end
|
||||||
|
return pc
|
||||||
|
end
|
||||||
|
|
||||||
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
|
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
|
||||||
-- own comment says "repeat count, tile ID" and has it backwards: it loads b
|
-- own comment says "repeat count, tile ID" and has it backwards: it loads b
|
||||||
-- from the first byte, c from the second, and writes `b` c times. $ff ends
|
-- from the first byte, c from the second, and writes `b` c times. $ff ends
|
||||||
@@ -6104,6 +6328,7 @@ function RomExtractorGen2:run()
|
|||||||
results.sprites = self:extractSprites()
|
results.sprites = self:extractSprites()
|
||||||
results.stdScripts = self:extractStdScripts()
|
results.stdScripts = self:extractStdScripts()
|
||||||
results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts)
|
results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts)
|
||||||
|
results.text = self:extractText()
|
||||||
results.pokemon = self:extractPokemon()
|
results.pokemon = self:extractPokemon()
|
||||||
results.moves = self:extractMoves()
|
results.moves = self:extractMoves()
|
||||||
results.items = self:extractItems()
|
results.items = self:extractItems()
|
||||||
|
|||||||
+252
-25
@@ -104,6 +104,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
|||||||
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
|
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
|
||||||
"data/generated/scripts.lua", -- disassembled map scripts
|
"data/generated/scripts.lua", -- disassembled map scripts
|
||||||
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
|
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
|
||||||
|
-- The engine's own strings, keyed by label rather than by address. A
|
||||||
|
-- cache built before RomExtractorGen2:extractText has none, and every
|
||||||
|
-- line that reads through src/core/RomText.lua would silently keep
|
||||||
|
-- printing its Lua fallback, so this re-imports those caches rather than
|
||||||
|
-- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too.
|
||||||
|
"data/generated/rom_text.lua",
|
||||||
"data/generated/pokemon.lua",
|
"data/generated/pokemon.lua",
|
||||||
"data/generated/tilesets.lua",
|
"data/generated/tilesets.lua",
|
||||||
"data/generated/audio.lua",
|
"data/generated/audio.lua",
|
||||||
@@ -140,6 +146,14 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
|||||||
-- before the four ball tiles were extracted (#1502).
|
-- before the four ball tiles were extracted (#1502).
|
||||||
"assets/generated/battle/hud/balls.png",
|
"assets/generated/battle/hud/balls.png",
|
||||||
"assets/generated/audio/programs.bin",
|
"assets/generated/audio/programs.bin",
|
||||||
|
-- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to
|
||||||
|
-- advertise these paths even when Slots*LZ / CardFlip* were absent from
|
||||||
|
-- the manifest, so a cache that never wrote the PNGs still looked
|
||||||
|
-- complete and SlotMachine crashed on its labelled-cell fallback.
|
||||||
|
"assets/generated/slots/gold_slots_1.png",
|
||||||
|
"assets/generated/card_flip/card_flip_1.png",
|
||||||
|
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
|
||||||
|
"assets/generated/pc/mail_item.png",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
||||||
@@ -382,6 +396,142 @@ local function externalFileSize(path)
|
|||||||
return size
|
return size
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function openImportSource(path)
|
||||||
|
-- Desktop picker paths live outside LÖVE's virtual filesystem. Prefer the
|
||||||
|
-- native file handle so a 1.46 GiB disc is never copied to a temp file or
|
||||||
|
-- read into one Lua string before validation.
|
||||||
|
local native = io.open(path, "rb")
|
||||||
|
if native then
|
||||||
|
local size, sizeErr = native:seek("end")
|
||||||
|
if size == nil or size == false then
|
||||||
|
native:close()
|
||||||
|
return nil, sizeErr or "could not determine source file size"
|
||||||
|
end
|
||||||
|
local reset, resetErr = native:seek("set", 0)
|
||||||
|
if reset == nil or reset == false then
|
||||||
|
native:close()
|
||||||
|
return nil, resetErr or "could not rewind source file"
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
size = size,
|
||||||
|
read = function(_, n) return native:read(n) end,
|
||||||
|
close = function() native:close() end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if love and love.filesystem and love.filesystem.newFile then
|
||||||
|
local file, makeErr = love.filesystem.newFile(path)
|
||||||
|
if not file then return nil, makeErr or "could not open source file" end
|
||||||
|
local ok, openErr = file:open("r")
|
||||||
|
if not ok then return nil, openErr or "could not open source file" end
|
||||||
|
local size = file.getSize and file:getSize() or nil
|
||||||
|
return {
|
||||||
|
size = size,
|
||||||
|
read = function(_, n) return file:read(n) end,
|
||||||
|
close = function() file:close() end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return nil, "streaming source access is unavailable"
|
||||||
|
end
|
||||||
|
local function streamRequiredImport(manifest, importId, source)
|
||||||
|
local RequiredImports = require("src.mods.RequiredImports")
|
||||||
|
local spec = RequiredImports.spec(manifest, importId)
|
||||||
|
if not spec then return nil, "Import declaration was not found." end
|
||||||
|
if spec.format == "n64" then
|
||||||
|
return nil, "streaming canonicalization is unavailable for N64 imports"
|
||||||
|
end
|
||||||
|
local input, openErr = openImportSource(source)
|
||||||
|
if not input then return nil, openErr end
|
||||||
|
local sizeErr = RequiredImports.sizeError(spec, input.size, false)
|
||||||
|
if sizeErr then input:close(); return nil, sizeErr end
|
||||||
|
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local destination = RequiredImports.path(manifest, spec)
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
local output
|
||||||
|
local inputClosed, outputClosed = false, false
|
||||||
|
|
||||||
|
local function closeInput()
|
||||||
|
if inputClosed then return end
|
||||||
|
inputClosed = true
|
||||||
|
pcall(function() input:close() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function closeOutput()
|
||||||
|
if outputClosed or not output then return end
|
||||||
|
outputClosed = true
|
||||||
|
pcall(function() output:close() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local resultOk, resultDetail
|
||||||
|
CacheFs.prefix = ""
|
||||||
|
local ran, thrown = xpcall(function()
|
||||||
|
CacheFs.remove(RequiredImports.receiptPath(manifest, spec))
|
||||||
|
CacheFs.remove(destination)
|
||||||
|
local makeErr
|
||||||
|
output, makeErr = CacheFs.openWrite(destination)
|
||||||
|
if not output then
|
||||||
|
resultDetail = makeErr or "could not create imported file"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local MD5 = require("src.mods.StreamMD5")
|
||||||
|
local md5 = MD5.new()
|
||||||
|
local total, chunkBytes = 0, 4 * 1024 * 1024
|
||||||
|
while true do
|
||||||
|
local chunk = input:read(chunkBytes)
|
||||||
|
if not chunk or #chunk == 0 then break end
|
||||||
|
md5:update(chunk)
|
||||||
|
local wrote, writeErr = output:write(chunk)
|
||||||
|
if wrote == false or wrote == nil then
|
||||||
|
resultDetail = "could not copy import: "
|
||||||
|
.. tostring(writeErr or "write failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
total = total + #chunk
|
||||||
|
if #chunk < chunkBytes then break end
|
||||||
|
end
|
||||||
|
|
||||||
|
closeInput()
|
||||||
|
closeOutput()
|
||||||
|
if input.size and total ~= input.size then
|
||||||
|
resultDetail = ("source read ended early (expected %d bytes, copied %d)")
|
||||||
|
:format(input.size, total)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local storedSizeErr = RequiredImports.sizeError(spec, total, true)
|
||||||
|
if storedSizeErr then resultDetail = storedSizeErr; return end
|
||||||
|
local digest = md5:final()
|
||||||
|
|
||||||
|
-- acceptStoredDigest uses the normal engine path/receipt rules, so
|
||||||
|
-- restore the caller's prefix before handing control to it.
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
|
local accepted, detail = RequiredImports.acceptStoredDigest(
|
||||||
|
manifest, importId, digest, love.filesystem)
|
||||||
|
if not accepted then resultDetail = detail; return end
|
||||||
|
resultOk, resultDetail = true, detail
|
||||||
|
end, function(err)
|
||||||
|
if debug and debug.traceback then
|
||||||
|
return debug.traceback(tostring(err), 2)
|
||||||
|
end
|
||||||
|
return tostring(err)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- finally: resource handles and the process-global CacheFs prefix must
|
||||||
|
-- be restored even when a read/hash/write helper raises a Lua error.
|
||||||
|
closeInput()
|
||||||
|
closeOutput()
|
||||||
|
CacheFs.prefix = ""
|
||||||
|
if not ran or not resultOk then
|
||||||
|
pcall(function() CacheFs.remove(destination) end)
|
||||||
|
end
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
|
|
||||||
|
if not ran then
|
||||||
|
return nil, "could not copy import: " .. tostring(thrown)
|
||||||
|
end
|
||||||
|
if not resultOk then return nil, resultDetail end
|
||||||
|
return true, resultDetail
|
||||||
|
end
|
||||||
local function readDroppedFile(file)
|
local function readDroppedFile(file)
|
||||||
local ok, openError = file:open("r")
|
local ok, openError = file:open("r")
|
||||||
if not ok then return nil, openError end
|
if not ok then return nil, openError end
|
||||||
@@ -942,13 +1092,19 @@ end
|
|||||||
-- naive "first ROM wins" scan would re-import Red when the player tries to
|
-- naive "first ROM wins" scan would re-import Red when the player tries to
|
||||||
-- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is
|
-- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is
|
||||||
-- 2 MiB).
|
-- 2 MiB).
|
||||||
local function findPendingRom(ready)
|
-- `wanted` narrows the scan to one version. A Choose names the cart it is
|
||||||
|
-- for, so without it several dumps sitting in the save directory answered in
|
||||||
|
-- listing order and the selection was dropped: picking Red imported Blue
|
||||||
|
-- (#1274). The callers that are not a Choose, the Android USB-drop scans,
|
||||||
|
-- pass nothing and still take the first pending cart of any version.
|
||||||
|
local function findPendingRom(ready, wanted)
|
||||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||||
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
|
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
|
||||||
local data = love.filesystem.read(name)
|
local data = love.filesystem.read(name)
|
||||||
if type(data) == "string" and isAcceptedRomSize(#data) then
|
if type(data) == "string" and isAcceptedRomSize(#data) then
|
||||||
local version = GameVersion.forSha1(sha1(data))
|
local version = GameVersion.forSha1(sha1(data))
|
||||||
if version and not ready[version] then
|
if version and not ready[version]
|
||||||
|
and (wanted == nil or version == wanted) then
|
||||||
return name, data
|
return name, data
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1228,11 +1384,13 @@ local function chooseRequiredFile()
|
|||||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
"$d.Title='" .. prompt .. "';",
|
"$d.Title='" .. prompt .. "';",
|
||||||
"$d.Filter='All files (*.*)|*.*';",
|
"$d.Filter='All files (*.*)|*.*';",
|
||||||
|
-- Required imports can be multi-gigabyte optical-disc images. Do NOT
|
||||||
|
-- stage them through %TEMP%: that doubles free-space requirements and a
|
||||||
|
-- failed Copy-Item can leave a plausible-looking truncated temp file.
|
||||||
|
-- Stream the selected source directly into the mod-owned destination.
|
||||||
"if($d.ShowDialog() -eq 'OK'){",
|
"if($d.ShowDialog() -eq 'OK'){",
|
||||||
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
|
|
||||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
|
||||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||||
"[Console]::Write($t)}",
|
"[Console]::Write($d.FileName)}",
|
||||||
})
|
})
|
||||||
return commandOutput(
|
return commandOutput(
|
||||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
@@ -1537,6 +1695,9 @@ function RomImporter:focus(f)
|
|||||||
self._modPress = nil
|
self._modPress = nil
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if type(self._sync) == "table" then
|
||||||
|
pcall(self._sync.noteResumed, self._sync)
|
||||||
|
end
|
||||||
if not (f and self.android and self.workState ~= "working") then return end
|
if not (f and self.android and self.workState ~= "working") then return end
|
||||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||||
if love.filesystem.getInfo("export_done.flag", "file") then
|
if love.filesystem.getInfo("export_done.flag", "file") then
|
||||||
@@ -2069,8 +2230,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local RequiredImports = require("src.mods.RequiredImports")
|
local RequiredImports = require("src.mods.RequiredImports")
|
||||||
|
-- A desktop picker returns a host path. Ask the host file handle first;
|
||||||
|
-- love.filesystem.getInfo is only authoritative for virtual/save paths.
|
||||||
|
local size = externalFileSize(source)
|
||||||
|
if not size then
|
||||||
local info = love.filesystem.getInfo(source, "file")
|
local info = love.filesystem.getInfo(source, "file")
|
||||||
local size = info and info.size or externalFileSize(source)
|
size = info and info.size or nil
|
||||||
|
end
|
||||||
local sizeErr = RequiredImports.sizeError(spec, size, false)
|
local sizeErr = RequiredImports.sizeError(spec, size, false)
|
||||||
if sizeErr then
|
if sizeErr then
|
||||||
requiredImportNotice(self, modId, importId, sizeErr)
|
requiredImportNotice(self, modId, importId, sizeErr)
|
||||||
@@ -2093,6 +2259,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
|
||||||
|
and spec.format ~= "n64" then
|
||||||
|
local ok, result = streamRequiredImport(manifest, importId, source)
|
||||||
|
if ok then
|
||||||
|
self.requiredImportNotice = nil
|
||||||
|
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
|
||||||
|
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||||
|
self:_refreshMods()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
requiredImportNotice(self, modId, importId, result)
|
||||||
|
self.modNotice = nil
|
||||||
|
return nil
|
||||||
|
end
|
||||||
local data = love.filesystem.read(source)
|
local data = love.filesystem.read(source)
|
||||||
if not data then data = readExternalPath(source) end
|
if not data then data = readExternalPath(source) end
|
||||||
if not data then
|
if not data then
|
||||||
@@ -2407,8 +2587,9 @@ function RomImporter:choose(version)
|
|||||||
if self.android then
|
if self.android then
|
||||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||||
-- was the #167 failure mode (second Choose just re-extracted Red).
|
-- was the #167 failure mode (second Choose just re-extracted Red). This
|
||||||
local name, data = findPendingRom(self.ready)
|
-- is a Choose, so it takes the cart that was asked for and nothing else.
|
||||||
|
local name, data = findPendingRom(self.ready, self.chooseVersion)
|
||||||
if name then
|
if name then
|
||||||
self:startData(data, name)
|
self:startData(data, name)
|
||||||
elseif consumePickedRomError(self) then
|
elseif consumePickedRomError(self) then
|
||||||
@@ -2436,8 +2617,10 @@ function RomImporter:choose(version)
|
|||||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||||
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
||||||
-- used on Android, which works when the game is launched as an unpacked
|
-- used on Android, which works when the game is launched as an unpacked
|
||||||
-- directory (see build-rg34xxsp.sh).
|
-- directory (see build-rg34xxsp.sh). Narrowed to the chosen version: with
|
||||||
local name, data = findPendingRom(self.ready)
|
-- four dumps in the folder the unnarrowed scan answered in listing order,
|
||||||
|
-- so Choose Red imported and decoded Blue (#1274).
|
||||||
|
local name, data = findPendingRom(self.ready, self.chooseVersion)
|
||||||
if name then
|
if name then
|
||||||
self:startData(data, name)
|
self:startData(data, name)
|
||||||
return
|
return
|
||||||
@@ -2845,6 +3028,7 @@ function RomImporter:_updatePadCursor(dt)
|
|||||||
local overY = 0
|
local overY = 0
|
||||||
if ny > oy + h then overY = ny - (oy + h)
|
if ny > oy + h then overY = ny - (oy + h)
|
||||||
elseif ny < oy then overY = ny - oy end
|
elseif ny < oy then overY = ny - oy end
|
||||||
|
if math.abs(ay) > PAD_DEAD and not self._padStickCentered then overY = 0 end
|
||||||
if overY ~= 0 and self._flex then
|
if overY ~= 0 and self._flex then
|
||||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||||
end
|
end
|
||||||
@@ -2904,7 +3088,11 @@ end
|
|||||||
function RomImporter:gamepadaxis(_, axis, value)
|
function RomImporter:gamepadaxis(_, axis, value)
|
||||||
if axis == "leftx" or axis == "lefty" or axis == "righty" then
|
if axis == "leftx" or axis == "lefty" or axis == "righty" then
|
||||||
self._padAxis[axis] = value
|
self._padAxis[axis] = value
|
||||||
if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end
|
if math.abs(value) > PAD_DEAD then
|
||||||
|
self:_activatePadCursor()
|
||||||
|
elseif axis == "lefty" then
|
||||||
|
self._padStickCentered = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2913,18 +3101,21 @@ end
|
|||||||
-- fire the virtual cursor's click twice off one A press (#620).
|
-- fire the virtual cursor's click twice off one A press (#620).
|
||||||
function RomImporter:joystickpressed(joystick, button)
|
function RomImporter:joystickpressed(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||||
if padButton then self:gamepadpressed(joystick, padButton) end
|
if padButton then self:gamepadpressed(joystick, padButton) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:joystickreleased(joystick, button)
|
function RomImporter:joystickreleased(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||||
if padButton then self:gamepadreleased(joystick, padButton) end
|
if padButton then self:gamepadreleased(joystick, padButton) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:joystickaxis(joystick, axis, value)
|
function RomImporter:joystickaxis(joystick, axis, value)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
if axis == 1 then
|
if axis == 1 then
|
||||||
self:gamepadaxis(joystick, "leftx", value)
|
self:gamepadaxis(joystick, "leftx", value)
|
||||||
elseif axis == 2 then
|
elseif axis == 2 then
|
||||||
@@ -2934,6 +3125,7 @@ end
|
|||||||
|
|
||||||
function RomImporter:joystickhat(joystick, hat, direction)
|
function RomImporter:joystickhat(joystick, hat, direction)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
|
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
|
||||||
self._padDir[dir] = nil
|
self._padDir[dir] = nil
|
||||||
end
|
end
|
||||||
@@ -3179,6 +3371,10 @@ function RomImporter:_ensureSkins(force)
|
|||||||
format = skin and skin.format or nil,
|
format = skin and skin.format or nil,
|
||||||
pages = skin and #skin.pages or 0,
|
pages = skin and #skin.pages or 0,
|
||||||
controls = controls,
|
controls = controls,
|
||||||
|
-- The launcher owns the visual skin picker, so retain the already
|
||||||
|
-- loaded first-page bezel for its preview card instead of decoding it
|
||||||
|
-- again every frame.
|
||||||
|
preview = page and page.image or nil,
|
||||||
screen = page ~= nil
|
screen = page ~= nil
|
||||||
and (page.viewport ~= nil or page.screenFit == "remainder"),
|
and (page.viewport ~= nil or page.screenFit == "remainder"),
|
||||||
ok = skin ~= nil,
|
ok = skin ~= nil,
|
||||||
@@ -3191,7 +3387,7 @@ end
|
|||||||
function RomImporter:_activeSkin()
|
function RomImporter:_activeSkin()
|
||||||
local opts = require("src.core.SaveData").loadOptions()
|
local opts = require("src.core.SaveData").loadOptions()
|
||||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||||
return tc.skin
|
return tc.enabled == false and nil or tc.skin
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_useSkin(id)
|
function RomImporter:_useSkin(id)
|
||||||
@@ -3208,6 +3404,16 @@ function RomImporter:_useSkin(id)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function RomImporter:_disableSkins()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = SaveData.loadOptions()
|
||||||
|
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||||
|
tc.enabled, tc.skin = false, nil
|
||||||
|
opts.touchControls = tc
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." }
|
||||||
|
end
|
||||||
|
|
||||||
function RomImporter:_installSkinZip(source)
|
function RomImporter:_installSkinZip(source)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
self.tab = "skins"
|
self.tab = "skins"
|
||||||
@@ -3475,7 +3681,7 @@ end
|
|||||||
function RomImporter:_openSync()
|
function RomImporter:_openSync()
|
||||||
self:_syncEngine()
|
self:_syncEngine()
|
||||||
self._syncModal = self._syncModal
|
self._syncModal = self._syncModal
|
||||||
or { view = "home", code1 = "", code2 = "", share = "" }
|
or { view = "home", code1 = "", code2 = "", share = "", withOptions = true }
|
||||||
self._syncFocus = nil
|
self._syncFocus = nil
|
||||||
self:_disarmTextInput()
|
self:_disarmTextInput()
|
||||||
end
|
end
|
||||||
@@ -3560,9 +3766,22 @@ function RomImporter:_syncUnlinkDevice(deviceId)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_syncShareMods()
|
function RomImporter:_syncShareMods()
|
||||||
local eng = self:_syncEngine()
|
local eng, mo = self:_syncEngine(), self._syncModal
|
||||||
if not eng then return false end
|
if not eng then return false end
|
||||||
return eng:shareMods()
|
return eng:shareMods(mo and mo.withOptions ~= false)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncToggleShareOptions()
|
||||||
|
local mo = self._syncModal
|
||||||
|
if not mo then return false end
|
||||||
|
mo.withOptions = not (mo.withOptions ~= false)
|
||||||
|
return mo.withOptions
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_syncAnswerModOptions(importThem)
|
||||||
|
local eng = self:_syncEngine()
|
||||||
|
if not eng or type(eng.answerModOptions) ~= "function" then return false end
|
||||||
|
return eng:answerModOptions(importThem)
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_syncGetShare()
|
function RomImporter:_syncGetShare()
|
||||||
@@ -3981,6 +4200,18 @@ function RomImporter:_disarmTextInput()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function RomImporter:_blurPanelFields()
|
||||||
|
if not (self._findSearchFocus or self._skinUrlFocus) then return end
|
||||||
|
if self._indexPrompt or self._rename or self._settingsText
|
||||||
|
or self._profileSavePrompt or self._profileRenamePrompt
|
||||||
|
or (self._syncModal and self._syncFocus) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._findSearchFocus = false
|
||||||
|
self._skinUrlFocus = false
|
||||||
|
self:_disarmTextInput()
|
||||||
|
end
|
||||||
|
|
||||||
function RomImporter:_beginRename(version, id)
|
function RomImporter:_beginRename(version, id)
|
||||||
local label
|
local label
|
||||||
for _, slot in ipairs(self.slots[version] or {}) do
|
for _, slot in ipairs(self.slots[version] or {}) do
|
||||||
@@ -4111,7 +4342,6 @@ function RomImporter:_refreshMods()
|
|||||||
end
|
end
|
||||||
self.mods = kept
|
self.mods = kept
|
||||||
end
|
end
|
||||||
self:_syncModUpdateInfo(false)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Point the MODS panel at one game (or nil for all of them) and relist, so
|
-- Point the MODS panel at one game (or nil for all of them) and relist, so
|
||||||
@@ -4125,15 +4355,12 @@ function RomImporter:_ensureMods()
|
|||||||
if not self.mods then self:_refreshMods() end
|
if not self.mods then self:_refreshMods() end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Resolve cached (or freshly fetched) GitHub status for every mod that
|
-- Resolve GitHub status for every installed mod that declares a github field.
|
||||||
-- declares a github field. force=true bypasses the 6h cache on every repo.
|
-- This is deliberately opt-in: only the explicit "Check for updates" action
|
||||||
-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }.
|
-- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not
|
||||||
-- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any
|
-- create a burst of release work behind the list. force=true bypasses the 6h
|
||||||
-- toggle or install -- and used to make one blocking curl call per mod with a
|
-- cache on every repo. Results live on self.modUpdateInfo[id] = {
|
||||||
-- github field, in a loop, on the render thread. A handful of mods was a
|
-- status, latest, best, releases } and resolve asynchronously across frames.
|
||||||
-- multi-second freeze of the whole launcher. Now each mod gets a handle and
|
|
||||||
-- they resolve together across later frames; a mod whose cache is still fresh
|
|
||||||
-- resolves on the first pump with no network at all.
|
|
||||||
function RomImporter:_syncModUpdateInfo(force)
|
function RomImporter:_syncModUpdateInfo(force)
|
||||||
local ModUpdate = require("src.mods.ModUpdate")
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
self.modUpdateInfo = self.modUpdateInfo or {}
|
self.modUpdateInfo = self.modUpdateInfo or {}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
local Bag = {}
|
local Bag = {}
|
||||||
|
|
||||||
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of
|
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of
|
||||||
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS). A `mods` bagSize override
|
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS -- ram/wram.asm:2421 is
|
||||||
|
-- `wTMsHMs:: ds NUM_TMS + NUM_HMS`, 57 bytes). A `mods` bagSize override
|
||||||
-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
|
-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
|
||||||
local POCKET_CAPACITY = {
|
local POCKET_CAPACITY = {
|
||||||
ITEM = 20,
|
ITEM = 20,
|
||||||
BALL = 12,
|
BALL = 12,
|
||||||
KEY_ITEM = 25,
|
KEY_ITEM = 25,
|
||||||
TM_HM = 64,
|
TM_HM = 57,
|
||||||
}
|
}
|
||||||
local DEFAULT_CAPACITY = 20
|
local DEFAULT_CAPACITY = 20
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ function ItemEffects.healsHP(id)
|
|||||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- .useRareCandy prints over the still-drawn party menu
|
||||||
|
-- (engine/items/item_effects.asm:1392-1418)
|
||||||
|
function ItemEffects.keepsPartyMenuOpen(id)
|
||||||
|
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
|
||||||
|
end
|
||||||
|
|
||||||
function ItemEffects.isBattleMedicine(id)
|
function ItemEffects.isBattleMedicine(id)
|
||||||
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
||||||
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||||
|
|||||||
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.opponentName = theirName
|
self.opponentName = theirName
|
||||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
self.introText = self:romText("_TrainerWantsToFightText",
|
||||||
|
"%s wants\nto fight!", theirName)
|
||||||
self.remoteHashes = {}
|
self.remoteHashes = {}
|
||||||
self.localHashes = {}
|
self.localHashes = {}
|
||||||
self.remoteParts = {}
|
self.remoteParts = {}
|
||||||
|
|||||||
+46
-5
@@ -808,6 +808,33 @@ local function buildOverworld()
|
|||||||
local Movement = rawRequire("src.script.gen2.Movement")
|
local Movement = rawRequire("src.script.gen2.Movement")
|
||||||
local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
|
local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
|
||||||
local Bike = rawRequire("src.world.gen2.Bike")
|
local Bike = rawRequire("src.world.gen2.Bike")
|
||||||
|
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
|
||||||
|
|
||||||
|
-- home/map.asm:1869
|
||||||
|
local function stepAllowed(world, entity, dir, cx, cy)
|
||||||
|
local d = Map2.DELTA[dir]
|
||||||
|
if not (world.map and d and entity) then return true end
|
||||||
|
cx, cy = cx or entity.cellX, cy or entity.cellY
|
||||||
|
if not (cx and cy) then return true end
|
||||||
|
if not Permissions.stepPermitted(
|
||||||
|
function(px, py) return world:cellCollisionAcross(world.map, px, py) end,
|
||||||
|
cx, cy, dir) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local map = world.map
|
||||||
|
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
|
||||||
|
map = world:surfMap(world.map)
|
||||||
|
end
|
||||||
|
local tx, ty = cx + d[1], cy + d[2]
|
||||||
|
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
|
||||||
|
for _, e in ipairs(world.entities or {}) do
|
||||||
|
if e ~= entity and not e.passable then
|
||||||
|
if e.cellX == tx and e.cellY == ty then return false end
|
||||||
|
if e.moving and e.targetX == tx and e.targetY == ty then return false end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local api = nil
|
local api = nil
|
||||||
-- one WorldAPI instance, so queueScript reuses the five-verb allow list
|
-- one WorldAPI instance, so queueScript reuses the five-verb allow list
|
||||||
@@ -1011,20 +1038,33 @@ local function buildOverworld()
|
|||||||
|
|
||||||
-- Gold has ONE movement slot; a second concurrent call is refused with a
|
-- Gold has ONE movement slot; a second concurrent call is refused with a
|
||||||
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
|
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
|
||||||
function ow.scriptMove(entity, dir, tiles, onDone)
|
function ow.scriptMove(entity, dir, tiles, onDone, opts)
|
||||||
local world = w("scriptMove")
|
local world = w("scriptMove")
|
||||||
if not world then return nil, "no overworld" end
|
if not world then return nil, "no overworld" end
|
||||||
if world.moveState then return nil, "a movement is already running" end
|
if world.moveState then return nil, "a movement is already running" end
|
||||||
local step = Movement.stepByte(dir)
|
local step = Movement.stepByte(dir)
|
||||||
if not step then return nil, "unknown direction: " .. tostring(dir) end
|
if not step then return nil, "unknown direction: " .. tostring(dir) end
|
||||||
local bytes = {}
|
|
||||||
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
|
|
||||||
bytes[#bytes + 1] = Movement.STEP_END
|
|
||||||
local objectId = objectIdOf(world, entity)
|
local objectId = objectIdOf(world, entity)
|
||||||
if not objectId then
|
if not objectId then
|
||||||
return nil, "no Gen 2 objectId for that entity: only the player and a "
|
return nil, "no Gen 2 objectId for that entity: only the player and a "
|
||||||
.. "mapped object (def.index) can be moved"
|
.. "mapped object (def.index) can be moved"
|
||||||
end
|
end
|
||||||
|
local n = math.max(0, tiles or 1)
|
||||||
|
if opts and opts.collide and n > 0 then
|
||||||
|
local d = Map2.DELTA[dir]
|
||||||
|
local cx, cy = entity.cellX, entity.cellY
|
||||||
|
local allowed = 0
|
||||||
|
for _ = 1, n do
|
||||||
|
if not stepAllowed(world, entity, dir, cx, cy) then break end
|
||||||
|
allowed = allowed + 1
|
||||||
|
if d and cx and cy then cx, cy = cx + d[1], cy + d[2] end
|
||||||
|
end
|
||||||
|
if allowed < n then entity.facing = dir end
|
||||||
|
n = allowed
|
||||||
|
end
|
||||||
|
local bytes = {}
|
||||||
|
for _ = 1, n do bytes[#bytes + 1] = step end
|
||||||
|
bytes[#bytes + 1] = Movement.STEP_END
|
||||||
world:beginMovement(objectId, bytes, onDone)
|
world:beginMovement(objectId, bytes, onDone)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -1401,7 +1441,8 @@ COVERAGE[OW] = {
|
|||||||
.. "objectId 1 is wLastTalked, not object zero",
|
.. "objectId 1 is wLastTalked, not object zero",
|
||||||
scriptMove = "the player maps to objectId 0 and a mapped object to "
|
scriptMove = "the player maps to objectId 0 and a mapped object to "
|
||||||
.. "def.index + 1; an entity with neither (a mod's own guest) is "
|
.. "def.index + 1; an entity with neither (a mod's own guest) is "
|
||||||
.. "REFUSED with a reason rather than moving the last-talked NPC",
|
.. "REFUSED with a reason rather than moving the last-talked NPC; "
|
||||||
|
.. "opts.collide truncates the walk at the first blocked step",
|
||||||
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
|
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
|
||||||
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
|
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
|
||||||
timeOfDay = "recomputed from the clock every call and never cached, so a "
|
timeOfDay = "recomputed from the clock every call and never cached, so a "
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- Scoped access to a mod's launcher-validated required/optional imports and
|
||||||
|
-- to installation-wide generated cache data.
|
||||||
|
--
|
||||||
|
-- This intentionally does not expose host paths or raw filesystem handles.
|
||||||
|
-- Import reads are bounded and can only address ids declared by the calling
|
||||||
|
-- mod's manifest. Cache paths are confined to mod_cache/<mod-id>/ and are not
|
||||||
|
-- tied to a Pokémon playthrough.
|
||||||
|
|
||||||
|
local RequiredImports = require("src.mods.RequiredImports")
|
||||||
|
local SafePath = require("src.mods.SafePath")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local ImportAccess = {}
|
||||||
|
|
||||||
|
ImportAccess.MAX_READ_BYTES = 8 * 1024 * 1024
|
||||||
|
ImportAccess.MAX_CACHE_WRITE_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
local function specMap(manifest)
|
||||||
|
local out = {}
|
||||||
|
for _, spec in ipairs(RequiredImports.specs(manifest)) do out[spec.id] = spec end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parentOf(path)
|
||||||
|
return path:match("^(.*)/[^/]+$")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function copyInfo(info)
|
||||||
|
if not info then return nil end
|
||||||
|
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fsReadRange(fs, path, offset, length)
|
||||||
|
if fs and type(fs.readRange) == "function" then
|
||||||
|
return fs.readRange(path, offset, length)
|
||||||
|
end
|
||||||
|
local newFile = fs and fs.newFile
|
||||||
|
if newFile then
|
||||||
|
local file, makeErr = newFile(path)
|
||||||
|
if not file then return nil, makeErr or "could not open import" end
|
||||||
|
local ok, openErr = file:open("r")
|
||||||
|
if not ok then return nil, openErr or "could not open import" end
|
||||||
|
local seekOk, seekErr = file:seek(offset)
|
||||||
|
if seekOk == nil or seekOk == false then
|
||||||
|
file:close()
|
||||||
|
return nil, seekErr or "could not seek import"
|
||||||
|
end
|
||||||
|
local data, readErr = file:read(length)
|
||||||
|
file:close()
|
||||||
|
return data, readErr
|
||||||
|
end
|
||||||
|
-- Injectable headless filesystems may expose only read(). Production
|
||||||
|
-- love.filesystem has newFile(), so large imports are never materialized
|
||||||
|
-- into one Lua string by this fallback.
|
||||||
|
if fs and fs.read then
|
||||||
|
local data = fs.read(path)
|
||||||
|
if type(data) ~= "string" then return nil, "could not read import" end
|
||||||
|
return data:sub(offset + 1, offset + length)
|
||||||
|
end
|
||||||
|
return nil, "random-access import reads are unavailable"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validatedInfo(manifest, spec, fs)
|
||||||
|
local ok, detail = RequiredImports.validateStored(manifest, spec, fs)
|
||||||
|
if not ok then return nil, detail or "import is not validated" end
|
||||||
|
local path = RequiredImports.path(manifest, spec)
|
||||||
|
local info = fs.getInfo and fs.getInfo(path, "file") or nil
|
||||||
|
if not info then return nil, "import is missing" end
|
||||||
|
return info, detail
|
||||||
|
end
|
||||||
|
|
||||||
|
local function makeCache(modId, fs)
|
||||||
|
local root = "mod_cache/" .. modId
|
||||||
|
local function pathFor(rel, what)
|
||||||
|
rel = SafePath.require(rel, what or "mod.cache path")
|
||||||
|
return root .. "/" .. rel
|
||||||
|
end
|
||||||
|
local cache = {}
|
||||||
|
|
||||||
|
function cache:write(rel, bytes)
|
||||||
|
if type(bytes) ~= "string" then
|
||||||
|
return nil, "mod.cache:write expects a byte string"
|
||||||
|
end
|
||||||
|
if #bytes > ImportAccess.MAX_CACHE_WRITE_BYTES then
|
||||||
|
return nil, "mod.cache:write payload exceeds 64 MiB; split generated data into smaller files"
|
||||||
|
end
|
||||||
|
local path = pathFor(rel, "mod.cache:write")
|
||||||
|
local parent = parentOf(path)
|
||||||
|
if parent and fs.createDirectory then
|
||||||
|
local ok = fs.createDirectory(parent)
|
||||||
|
if ok == false then return nil, "could not create cache directory" end
|
||||||
|
end
|
||||||
|
if not fs.write then return nil, "cache writes are unavailable" end
|
||||||
|
return fs.write(path, bytes)
|
||||||
|
end
|
||||||
|
|
||||||
|
function cache:read(rel)
|
||||||
|
local path = pathFor(rel, "mod.cache:read")
|
||||||
|
if not fs.read then return nil, "cache reads are unavailable" end
|
||||||
|
return fs.read(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
function cache:info(rel)
|
||||||
|
local path = pathFor(rel, "mod.cache:info")
|
||||||
|
if not fs.getInfo then return nil end
|
||||||
|
return copyInfo(fs.getInfo(path))
|
||||||
|
end
|
||||||
|
|
||||||
|
function cache:exists(rel)
|
||||||
|
local info = self:info(rel)
|
||||||
|
return info ~= nil and info.type == "file"
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
function cache:delete(rel)
|
||||||
|
local path = pathFor(rel, "mod.cache:delete")
|
||||||
|
if not fs.remove then return nil, "cache deletion is unavailable" end
|
||||||
|
return fs.remove(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
return cache
|
||||||
|
end
|
||||||
|
|
||||||
|
function ImportAccess.new(manifest, fs)
|
||||||
|
local specs = specMap(manifest)
|
||||||
|
local cacheFs = SaveData.persistenceFs(fs) or fs
|
||||||
|
local imports = {}
|
||||||
|
|
||||||
|
function imports:info(id)
|
||||||
|
local spec = specs[id]
|
||||||
|
if not spec then return nil, "undeclared import: " .. tostring(id) end
|
||||||
|
local info, digestOrErr = validatedInfo(manifest, spec, fs)
|
||||||
|
if not info then return nil, digestOrErr end
|
||||||
|
return {
|
||||||
|
id = spec.id,
|
||||||
|
name = spec.name,
|
||||||
|
file = spec.file,
|
||||||
|
size = info.size,
|
||||||
|
md5 = digestOrErr,
|
||||||
|
required = spec.required ~= false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function imports:read(id, offset, length)
|
||||||
|
local spec = specs[id]
|
||||||
|
if not spec then return nil, "undeclared import: " .. tostring(id) end
|
||||||
|
offset, length = tonumber(offset), tonumber(length)
|
||||||
|
if not offset or offset < 0 or offset % 1 ~= 0 then
|
||||||
|
return nil, "offset must be a non-negative integer"
|
||||||
|
end
|
||||||
|
if not length or length < 0 or length % 1 ~= 0 then
|
||||||
|
return nil, "length must be a non-negative integer"
|
||||||
|
end
|
||||||
|
if length > ImportAccess.MAX_READ_BYTES then
|
||||||
|
return nil, "single import read exceeds 8 MiB"
|
||||||
|
end
|
||||||
|
|
||||||
|
local info, err = validatedInfo(manifest, spec, fs)
|
||||||
|
if not info then return nil, err end
|
||||||
|
local size = tonumber(info.size) or tonumber(spec.size)
|
||||||
|
if size and offset + length > size then return nil, "import read is out of bounds" end
|
||||||
|
if length == 0 then return "" end
|
||||||
|
|
||||||
|
local path = RequiredImports.path(manifest, spec)
|
||||||
|
local data, readErr = fsReadRange(fs, path, offset, length)
|
||||||
|
if not data then return nil, readErr end
|
||||||
|
if #data ~= length then return nil, "short import read" end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
return imports, makeCache(manifest.id, cacheFs)
|
||||||
|
end
|
||||||
|
|
||||||
|
return ImportAccess
|
||||||
@@ -603,6 +603,33 @@ function LauncherMods.setEnabled(id, enabled, version)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function LauncherMods.modOptions()
|
||||||
|
local ok, options = pcall(SaveData.loadOptions)
|
||||||
|
if not ok or type(options) ~= "table" then return {} end
|
||||||
|
return options.modOptions or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function LauncherMods.setModOptions(id, values)
|
||||||
|
if type(id) ~= "string" or id == "" or type(values) ~= "table" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local options = SaveData.loadOptions()
|
||||||
|
if SaveData.isSafeMode(options) then return false end
|
||||||
|
options.modOptions = options.modOptions or {}
|
||||||
|
local bucket = options.modOptions[id] or {}
|
||||||
|
for key, value in pairs(values) do
|
||||||
|
local t = type(value)
|
||||||
|
if type(key) == "string" and key ~= ""
|
||||||
|
and (t == "string" or t == "number" or t == "boolean") then
|
||||||
|
bucket[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
options.modOptions[id] = bucket
|
||||||
|
SaveData.saveOptions(options)
|
||||||
|
LauncherMods.syncActiveProfile(options)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
|
-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
|
||||||
-- all buttons (#647). Writes what setEnabled writes, but loads and
|
-- all buttons (#647). Writes what setEnabled writes, but loads and
|
||||||
-- saves once for the whole list: saveOptions rewrites the whole options file per
|
-- saves once for the whole list: saveOptions rewrites the whole options file per
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = {
|
|||||||
["src.ui.OptionsMenu"] = true,
|
["src.ui.OptionsMenu"] = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local function crossGenerationDenial(name, generation)
|
||||||
|
if type(name) ~= "string" or generation ~= 1 then return nil end
|
||||||
|
if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs "
|
||||||
|
.. "it reads and writes are not this game's, so anything it stores lands "
|
||||||
|
.. "on the save in the wrong shape. Take the game from mod.game and the "
|
||||||
|
.. "world from mod.world, which resolve per generation"):format(name)
|
||||||
|
end
|
||||||
|
|
||||||
-- the src.* modules the mod surface points authors at: another mod's
|
-- the src.* modules the mod surface points authors at: another mod's
|
||||||
-- exports carry a version string that wants range-checking before use, and
|
-- exports carry a version string that wants range-checking before use, and
|
||||||
-- ChipAsm is the authoring path for chip music and sfx
|
-- ChipAsm is the authoring path for chip music and sfx
|
||||||
@@ -214,6 +225,7 @@ function Loader:_installDevShim()
|
|||||||
if owner or callerIsMod(3) then
|
if owner or callerIsMod(3) then
|
||||||
local id = type(owner) == "string" and owner or nil
|
local id = type(owner) == "string" and owner or nil
|
||||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||||
|
or (id and crossGenerationDenial(name, devShim.generation))
|
||||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||||
end
|
end
|
||||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||||
@@ -962,6 +974,8 @@ function Loader:_api(mod)
|
|||||||
local Storage = engineRequire("src.mods.Storage")
|
local Storage = engineRequire("src.mods.Storage")
|
||||||
local storage = Storage and Storage.new(modId, loader.fs)
|
local storage = Storage and Storage.new(modId, loader.fs)
|
||||||
local Checkpoint = engineRequire("src.core.Checkpoint")
|
local Checkpoint = engineRequire("src.core.Checkpoint")
|
||||||
|
local ImportAccess = engineRequire("src.mods.ImportAccess")
|
||||||
|
local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs)
|
||||||
local api = {
|
local api = {
|
||||||
id = modId,
|
id = modId,
|
||||||
version = mod.manifest.version,
|
version = mod.manifest.version,
|
||||||
@@ -1161,6 +1175,12 @@ function Loader:_api(mod)
|
|||||||
-- checkpoint. The
|
-- checkpoint. The
|
||||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||||
-- callers never receive paths or a raw filesystem handle.
|
-- callers never receive paths or a raw filesystem handle.
|
||||||
|
-- Read-only bounded access to this mod's manifest-declared, launcher-validated
|
||||||
|
-- imports. No host path is exposed; large sources are read in bounded ranges.
|
||||||
|
imports = importApi,
|
||||||
|
-- Installation-scoped generated data, independent from Pokémon save slots.
|
||||||
|
-- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped.
|
||||||
|
cache = installCache,
|
||||||
storage = {
|
storage = {
|
||||||
context = function(_, game) return storage:context(game) end,
|
context = function(_, game) return storage:context(game) end,
|
||||||
selected = function(_, game) return storage:selected(game) end,
|
selected = function(_, game) return storage:selected(game) end,
|
||||||
|
|||||||
@@ -120,6 +120,36 @@ local function accepts(spec, digest)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function specById(manifest, importId)
|
||||||
|
for _, candidate in ipairs(allSpecs(manifest)) do
|
||||||
|
if candidate.id == importId then return candidate end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
RequiredImports.spec = specById
|
||||||
|
|
||||||
|
local function streamDigest(fs, path, chunkBytes)
|
||||||
|
if not (fs and fs.newFile) then return nil, "streaming file access is unavailable" end
|
||||||
|
local file, makeErr = fs.newFile(path)
|
||||||
|
if not file then return nil, makeErr or "could not open stored import" end
|
||||||
|
local ok, openErr = file:open("r")
|
||||||
|
if not ok then return nil, openErr or "could not open stored import" end
|
||||||
|
local MD5 = require("src.mods.StreamMD5")
|
||||||
|
local ctx = MD5.new()
|
||||||
|
chunkBytes = chunkBytes or (4 * 1024 * 1024)
|
||||||
|
while true do
|
||||||
|
local data, readErr = file:read(chunkBytes)
|
||||||
|
if data and #data > 0 then ctx:update(data) end
|
||||||
|
if not data or #data < chunkBytes then
|
||||||
|
if readErr then file:close(); return nil, readErr end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
file:close()
|
||||||
|
return ctx:final()
|
||||||
|
end
|
||||||
|
|
||||||
function RequiredImports.path(manifest, spec)
|
function RequiredImports.path(manifest, spec)
|
||||||
return manifest.path .. "/baseroms/" .. spec.file
|
return manifest.path .. "/baseroms/" .. spec.file
|
||||||
end
|
end
|
||||||
@@ -180,6 +210,47 @@ local function removeReceipt(manifest, spec, fs)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Finalize a caller-streamed import after the destination bytes have already
|
||||||
|
-- been copied into the engine-owned baseroms path. This keeps large imports
|
||||||
|
-- out of a single Lua string while preserving the same size/MD5 receipt rules.
|
||||||
|
function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs)
|
||||||
|
fs = fs or (love and love.filesystem)
|
||||||
|
local spec = specById(manifest, importId)
|
||||||
|
if not spec then return nil, "unknown required import: " .. tostring(importId) end
|
||||||
|
digest = tostring(digest or ""):lower()
|
||||||
|
if not accepts(spec, digest) then
|
||||||
|
return nil, ("MD5 mismatch (got %s)"):format(digest ~= "" and digest or "unavailable")
|
||||||
|
end
|
||||||
|
local path = RequiredImports.path(manifest, spec)
|
||||||
|
local info = fs and fs.getInfo and fs.getInfo(path, "file") or nil
|
||||||
|
if not info then return nil, "copied import is missing" end
|
||||||
|
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||||
|
if sizeErr then return nil, sizeErr end
|
||||||
|
if love and fs == love.filesystem then
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
local ok, prefixErr = xpcall(function()
|
||||||
|
CacheFs.prefix = ""
|
||||||
|
CacheFs.remove(removedMarker(manifest, spec))
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
|
-- writeReceipt has its own temporary CacheFs prefix switch. Keep it
|
||||||
|
-- inside this guard too so a write error cannot leak global state.
|
||||||
|
writeReceipt(manifest, spec, digest, info, fs)
|
||||||
|
end, function(err)
|
||||||
|
return tostring(err)
|
||||||
|
end)
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
|
if not ok then
|
||||||
|
return nil, "could not finalize import receipt: " .. tostring(prefixErr)
|
||||||
|
end
|
||||||
|
return true, digest
|
||||||
|
elseif fs and fs.remove then
|
||||||
|
fs.remove(removedMarker(manifest, spec))
|
||||||
|
end
|
||||||
|
writeReceipt(manifest, spec, digest, info, fs)
|
||||||
|
return true, digest
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
-- Validate bytes against a declaration. The returned data is canonicalized
|
-- Validate bytes against a declaration. The returned data is canonicalized
|
||||||
-- (notably for N64 byte order/header variants) and is what must be stored.
|
-- (notably for N64 byte order/header variants) and is what must be stored.
|
||||||
function RequiredImports.validateData(spec, data, hashFn)
|
function RequiredImports.validateData(spec, data, hashFn)
|
||||||
@@ -217,6 +288,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
|||||||
local cached = cachedDigest(manifest, spec, fs, info)
|
local cached = cachedDigest(manifest, spec, fs, info)
|
||||||
if cached then return true, cached, true end
|
if cached then return true, cached, true end
|
||||||
removeReceipt(manifest, spec, fs)
|
removeReceipt(manifest, spec, fs)
|
||||||
|
-- Large raw imports (GameCube discs, future optical images, etc.) must never
|
||||||
|
-- be materialized into one Lua string merely because their validation
|
||||||
|
-- receipt was lost. Stream the MD5 directly from the installed file. N64
|
||||||
|
-- sources stay on the canonicalization path because byte-order/header
|
||||||
|
-- normalization is part of their validation contract.
|
||||||
|
if info.size and info.size > RequiredImports.LARGE_WARN_BYTES
|
||||||
|
and spec.format ~= "n64" and fs.newFile then
|
||||||
|
local digest, hashErr = streamDigest(fs, path)
|
||||||
|
if not digest then return nil, hashErr end
|
||||||
|
if not accepts(spec, digest) then
|
||||||
|
return nil, ("MD5 mismatch (got %s)"):format(digest)
|
||||||
|
end
|
||||||
|
info = fs.getInfo(path, "file") or info
|
||||||
|
writeReceipt(manifest, spec, digest, info, fs)
|
||||||
|
return true, digest, false
|
||||||
|
end
|
||||||
if not fs.read then return nil, "file could not be read" end
|
if not fs.read then return nil, "file could not be read" end
|
||||||
local data = fs.read(path)
|
local data = fs.read(path)
|
||||||
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
|
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
local bitlib = rawget(_G, "bit") or rawget(_G, "bit32")
|
||||||
|
if not bitlib then error("StreamMD5 requires bit or bit32") end
|
||||||
|
|
||||||
|
local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot
|
||||||
|
local lshift, rshift = bitlib.lshift, bitlib.rshift
|
||||||
|
local rol = bitlib.rol or bitlib.lrotate
|
||||||
|
|
||||||
|
local K = {
|
||||||
|
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,
|
||||||
|
0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821,
|
||||||
|
0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
|
||||||
|
0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
|
||||||
|
0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,
|
||||||
|
0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
|
||||||
|
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,
|
||||||
|
0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391,
|
||||||
|
}
|
||||||
|
local S = {
|
||||||
|
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
|
||||||
|
5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20,
|
||||||
|
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
|
||||||
|
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function add32(a,b,c,d)
|
||||||
|
local n = (a or 0) + (b or 0) + (c or 0) + (d or 0)
|
||||||
|
return band(n, 0xffffffff)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function le32_from(s, i)
|
||||||
|
local b1,b2,b3,b4 = s:byte(i, i+3)
|
||||||
|
return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function le32_bytes(x)
|
||||||
|
return string.char(
|
||||||
|
band(x,0xff), band(rshift(x,8),0xff),
|
||||||
|
band(rshift(x,16),0xff), band(rshift(x,24),0xff))
|
||||||
|
end
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
local StreamMD5 = {}
|
||||||
|
StreamMD5.__index = StreamMD5
|
||||||
|
|
||||||
|
function StreamMD5.new()
|
||||||
|
return setmetatable({
|
||||||
|
a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476,
|
||||||
|
bytes=0, buffer="", done=false,
|
||||||
|
}, StreamMD5)
|
||||||
|
end
|
||||||
|
|
||||||
|
function StreamMD5:_block(block)
|
||||||
|
for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end
|
||||||
|
local a,b,c,d = self.a,self.b,self.c,self.d
|
||||||
|
for i=0,63 do
|
||||||
|
local f,g
|
||||||
|
if i < 16 then
|
||||||
|
f = bor(band(b,c), band(bnot(b),d)); g=i
|
||||||
|
elseif i < 32 then
|
||||||
|
f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16
|
||||||
|
elseif i < 48 then
|
||||||
|
f = bxor(b,c,d); g=(3*i+5)%16
|
||||||
|
else
|
||||||
|
f = bxor(c, bor(b,bnot(d))); g=(7*i)%16
|
||||||
|
end
|
||||||
|
local tmp=d
|
||||||
|
d=c
|
||||||
|
c=b
|
||||||
|
b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1]))
|
||||||
|
a=tmp
|
||||||
|
end
|
||||||
|
self.a=add32(self.a,a); self.b=add32(self.b,b)
|
||||||
|
self.c=add32(self.c,c); self.d=add32(self.d,d)
|
||||||
|
end
|
||||||
|
|
||||||
|
function StreamMD5:update(data)
|
||||||
|
assert(not self.done, "StreamMD5 context already finalized")
|
||||||
|
assert(type(data)=="string", "StreamMD5:update expects a string")
|
||||||
|
self.bytes = self.bytes + #data
|
||||||
|
local s = self.buffer .. data
|
||||||
|
local full = #s - (#s % 64)
|
||||||
|
for i=1,full,64 do self:_block(s:sub(i,i+63)) end
|
||||||
|
self.buffer = s:sub(full+1)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function StreamMD5:final()
|
||||||
|
assert(not self.done, "StreamMD5 context already finalized")
|
||||||
|
local originalBytes = self.bytes
|
||||||
|
local padLen = (56 - ((originalBytes + 1) % 64)) % 64
|
||||||
|
local bits = originalBytes * 8
|
||||||
|
local lo = bits % 4294967296
|
||||||
|
local hi = math.floor(bits / 4294967296) % 4294967296
|
||||||
|
self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi))
|
||||||
|
assert(#self.buffer == 0, "MD5 finalization left a partial block")
|
||||||
|
self.done=true
|
||||||
|
local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d)
|
||||||
|
return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end))
|
||||||
|
end
|
||||||
|
|
||||||
|
return StreamMD5
|
||||||
+37
-11
@@ -19,6 +19,9 @@ local romText = require("src.core.RomText")
|
|||||||
|
|
||||||
local Evolution = {}
|
local Evolution = {}
|
||||||
|
|
||||||
|
-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames)
|
||||||
|
local EVOLVING_TEXT_FRAMES = 50
|
||||||
|
|
||||||
Evolution.METHODS = {
|
Evolution.METHODS = {
|
||||||
LEVEL = {
|
LEVEL = {
|
||||||
check = function(game, mon, evo, trigger)
|
check = function(game, mon, evo, trigger)
|
||||||
@@ -157,27 +160,50 @@ end
|
|||||||
-- Play the evolution movie (flashing forms), then apply + text.
|
-- Play the evolution movie (flashing forms), then apply + text.
|
||||||
-- Headless (no real graphics) falls back to the plain text flow.
|
-- Headless (no real graphics) falls back to the plain text flow.
|
||||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||||
|
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
|
-- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11
|
||||||
|
-- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134)
|
||||||
|
local isEvolving = romText(game.data, "_IsEvolvingText",
|
||||||
|
"What?\n%s is\nevolving!", oldName)
|
||||||
if love.image and love.image.newImageData then
|
if love.image and love.image.newImageData then
|
||||||
-- forward `via` so EvolutionState can keep trade evolutions
|
local intro
|
||||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
intro = TextBox.new(game, isEvolving, nil, { stay = {
|
||||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
onShown = function()
|
||||||
|
-- DelayFrames 50 with the box and the old screen still up
|
||||||
|
-- (evos_moves.asm:122-123)
|
||||||
|
local hold = { t = 0 }
|
||||||
|
hold.update = function()
|
||||||
|
hold.t = hold.t + 1
|
||||||
|
if hold.t < EVOLVING_TEXT_FRAMES then return end
|
||||||
|
game.stack:pop() -- this hold
|
||||||
|
-- forward `via` so trade evolutions stay non-cancelable while
|
||||||
|
-- others accept B (evos_moves.asm:72-75) (#213)
|
||||||
|
Screens.push(game, "EvolutionState", mon, newSpecies, function()
|
||||||
|
-- the result/cancel box owns the intro box's pop (#1596)
|
||||||
|
if game.stack:top() == intro then game.stack:pop() end
|
||||||
|
if onDone then onDone() end
|
||||||
|
end, via)
|
||||||
|
end
|
||||||
|
hold.draw = function() end
|
||||||
|
game.stack:push(hold)
|
||||||
|
end,
|
||||||
|
} })
|
||||||
|
game.stack:push(intro)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
|
||||||
Evolution.apply(game, mon, newSpecies, via)
|
Evolution.apply(game, mon, newSpecies, via)
|
||||||
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
-- EvolvedText then IntoText in the same box (evos_moves.asm:136-150)
|
||||||
-- truncated (it stops at a dynamic marker the decoder does not follow)
|
local msg = isEvolving .. "\f"
|
||||||
local msg = romText(game.data, "_IsEvolvingText",
|
.. romText(game.data, "_EvolvedText", "%s evolved", oldName)
|
||||||
"What?\n%s is\nevolving!", oldName)
|
.. romText(game.data, "_IntoText", "\ninto %s!",
|
||||||
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
game.data.pokemon[newSpecies].name)
|
||||||
oldName, game.data.pokemon[newSpecies].name)
|
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
-- re-run the evolved species' level-up learn check before onDone
|
-- re-run the evolved species' level-up learn check before onDone
|
||||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||||
end))
|
end, TextBox.soundOpts(game, "Get_Item2")))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Entry point for mods whose methods fire outside the vanilla moments
|
-- Entry point for mods whose methods fire outside the vanilla moments
|
||||||
|
|||||||
+13
-4
@@ -51,11 +51,20 @@ end
|
|||||||
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
|
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
|
||||||
-- CalcStats) and when one is moved back into the party
|
-- CalcStats) and when one is moved back into the party
|
||||||
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
||||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
|
-- kept (box_struct does hold it) but clamped to the recalculated maximum.
|
||||||
-- a tampered save cannot overfill the bar. A mon that already has stats is
|
-- A complete stat block is returned untouched; an incomplete one is rebuilt,
|
||||||
-- returned untouched, so a vanilla save round-trips. #233, #304
|
-- because CalcStats writes all NUM_STATS stats or none
|
||||||
|
-- (home/move_mon.asm:33-48). #233, #304, #1517
|
||||||
|
local function statsComplete(stats)
|
||||||
|
for _, key in ipairs(ORDER) do
|
||||||
|
if type(stats[key]) ~= "number" then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function Stats.ensure(speciesDef, mon)
|
function Stats.ensure(speciesDef, mon)
|
||||||
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
|
if type(mon) ~= "table" then return mon end
|
||||||
|
if type(mon.stats) == "table" and statsComplete(mon.stats) then return mon end
|
||||||
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
|
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
|
||||||
return mon
|
return mon
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ function PaletteFX.pal(data, name)
|
|||||||
if fromCgb then return fromCgb end
|
if fromCgb then return fromCgb end
|
||||||
end
|
end
|
||||||
local p = PaletteFX.pack(data)
|
local p = PaletteFX.pack(data)
|
||||||
local c = p and p.palettes[name]
|
local c = p and p.palettes and p.palettes[name]
|
||||||
if c then return c end
|
if c then return c end
|
||||||
if GameVersion.isYellow() then
|
if GameVersion.isYellow() then
|
||||||
local y = PaletteFX.yellowPack()
|
local y = PaletteFX.yellowPack()
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ local TouchSkin = require("src.core.TouchSkin")
|
|||||||
|
|
||||||
local Playfield = {}
|
local Playfield = {}
|
||||||
|
|
||||||
Playfield.WIDTH, Playfield.HEIGHT = 160, 144
|
|
||||||
|
|
||||||
Playfield.entered = false
|
Playfield.entered = false
|
||||||
Playfield.box = nil
|
Playfield.box = nil
|
||||||
|
|
||||||
@@ -35,14 +33,9 @@ function Playfield.cutout(sw, sh)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Playfield.rect(sw, sh)
|
function Playfield.rect(sw, sh)
|
||||||
local x, y, w, h, _, expand = Playfield.cutout(sw, sh)
|
local x, y, w, h = Playfield.cutout(sw, sh)
|
||||||
if not x then return 0, 0, sw or 0, sh or 0, false end
|
if not x then return 0, 0, sw or 0, sh or 0, false end
|
||||||
if expand then return x, y, w, h, true end
|
return x, y, w, h, true
|
||||||
local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH,
|
|
||||||
h / Playfield.HEIGHT)))
|
|
||||||
local pw = math.min(w, Playfield.WIDTH * s)
|
|
||||||
local ph = math.min(h, Playfield.HEIGHT * s)
|
|
||||||
return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Playfield.enter(x, y, w, h)
|
function Playfield.enter(x, y, w, h)
|
||||||
|
|||||||
@@ -87,12 +87,12 @@ local function displayMetrics()
|
|||||||
if dpiX < 1e-6 then dpiX = 1 end
|
if dpiX < 1e-6 then dpiX = 1 end
|
||||||
if dpiY < 1e-6 then dpiY = 1 end
|
if dpiY < 1e-6 then dpiY = 1 end
|
||||||
local vx, vy = 0, 0
|
local vx, vy = 0, 0
|
||||||
local cut, grow = false, false
|
local cut = false
|
||||||
local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph)
|
local sx, sy, sw, sh = Playfield.cutout(pw, ph)
|
||||||
if sx then
|
if sx then
|
||||||
vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand
|
vx, vy, pw, ph, cut = sx, sy, sw, sh, true
|
||||||
end
|
end
|
||||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
|
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut
|
||||||
end
|
end
|
||||||
|
|
||||||
local function positionLift(ph, contentPx, dpiY, cut)
|
local function positionLift(ph, contentPx, dpiY, cut)
|
||||||
@@ -275,7 +275,7 @@ end
|
|||||||
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||||
-- tilt is inactive).
|
-- tilt is inactive).
|
||||||
function Renderer:worldViewSize()
|
function Renderer:worldViewSize()
|
||||||
local _, _, pw, ph, _, dpiY, _, _, cut, grow = displayMetrics()
|
local _, _, pw, ph, _, dpiY, _, _, cut = displayMetrics()
|
||||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||||
@@ -287,7 +287,6 @@ function Renderer:worldViewSize()
|
|||||||
-- this is the same sum with the viewport standing in for the window, so
|
-- this is the same sum with the viewport standing in for the window, so
|
||||||
-- both platforms show the same map area at the same zoom.
|
-- both platforms show the same map area at the same zoom.
|
||||||
local cap = FaithfulRes.scaleCap()
|
local cap = FaithfulRes.scaleCap()
|
||||||
if not cap and cut and not grow then cap = self:fitScale() end
|
|
||||||
if cap then
|
if cap then
|
||||||
local uiw, uih = self:uiSize()
|
local uiw, uih = self:uiSize()
|
||||||
pw = cut and math.min(pw, uiw * cap) or uiw * cap
|
pw = cut and math.min(pw, uiw * cap) or uiw * cap
|
||||||
|
|||||||
+13
-2
@@ -44,7 +44,8 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
|||||||
-- waits for nothing, shows no blinking arrow, and never pops itself --
|
-- waits for nothing, shows no blinking arrow, and never pops itself --
|
||||||
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
|
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
|
||||||
-- the last page finishes typing, which is where the caller pushes whatever
|
-- the last page finishes typing, which is where the caller pushes whatever
|
||||||
-- goes on top of it (#591).
|
-- goes on top of it (#591). stay.prompt waits out one arrowed A/B press
|
||||||
|
-- first (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) (#1511).
|
||||||
function TextBox.new(game, text, onDone, opts)
|
function TextBox.new(game, text, onDone, opts)
|
||||||
local self = setmetatable({}, TextBox)
|
local self = setmetatable({}, TextBox)
|
||||||
self.game = game
|
self.game = game
|
||||||
@@ -296,6 +297,15 @@ function TextBox:update(dt)
|
|||||||
-- exactly once (#591)
|
-- exactly once (#591)
|
||||||
if self.stay then
|
if self.stay then
|
||||||
if not self.stayShown then
|
if not self.stayShown then
|
||||||
|
-- stay.prompt: arrowed A/B wait, then the box stays up
|
||||||
|
-- (TextCommand_PROMPT_BUTTON, home/text.asm:434-444)
|
||||||
|
if self.stay.prompt
|
||||||
|
and not (input:wasPressed("a") or input:wasPressed("b")) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.stay.prompt then
|
||||||
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
|
end
|
||||||
self.stayShown = true
|
self.stayShown = true
|
||||||
if self.stay.onShown then self.stay.onShown() end
|
if self.stay.onShown then self.stay.onShown() end
|
||||||
end
|
end
|
||||||
@@ -494,7 +504,8 @@ function TextBox:draw()
|
|||||||
Font.draw(money, 152 - Font.width(money), 8)
|
Font.draw(money, 152 - Font.width(money), 8)
|
||||||
end
|
end
|
||||||
if (self.waiting or (self.done and not self.choice and not self.auto
|
if (self.waiting or (self.done and not self.choice and not self.auto
|
||||||
and not self.stay))
|
and (not self.stay
|
||||||
|
or (self.stay.prompt and not self.stayShown))))
|
||||||
and self.blink < 30 then
|
and self.blink < 30 then
|
||||||
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
||||||
-- the original prints via `ld a, "▼"` (home/text.asm)
|
-- the original prints via `ld a, "▼"` (home/text.asm)
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ function Transition.new(game, onMidpoint, onDone, warp)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Transition:finish()
|
function Transition:finish()
|
||||||
self.game.stack:pop()
|
if self.done then return end
|
||||||
|
self.done = true
|
||||||
|
if not self.offStack then self.game.stack:pop() end
|
||||||
if self.onDone then self.onDone() end
|
if self.onDone then self.onDone() end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -96,10 +98,16 @@ function Transition:update(dt)
|
|||||||
self.t = 0
|
self.t = 0
|
||||||
if self.phase == "out" then
|
if self.phase == "out" then
|
||||||
self.phase = "in"
|
self.phase = "in"
|
||||||
if self.onMidpoint then self.onMidpoint() end
|
|
||||||
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
||||||
-- map is simply there on the next frame
|
-- map is simply there on the next frame
|
||||||
if (self.framesIn or 0) <= 0 then self:finish() end
|
if (self.framesIn or 0) <= 0 then
|
||||||
|
self.offStack = true
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.onMidpoint then self.onMidpoint() end
|
||||||
|
self:finish()
|
||||||
|
elseif self.onMidpoint then
|
||||||
|
self.onMidpoint()
|
||||||
|
end
|
||||||
else
|
else
|
||||||
self:finish()
|
self:finish()
|
||||||
end
|
end
|
||||||
|
|||||||
+33
-1
@@ -19,6 +19,8 @@ Zoom.allowSurvey = true
|
|||||||
|
|
||||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||||
|
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
|
||||||
|
-- levels; keep three survey steps so OPTIONS always has zoom-out.
|
||||||
function Zoom.offsetRange(S)
|
function Zoom.offsetRange(S)
|
||||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||||
local lo, hi = 1 - S, S
|
local lo, hi = 1 - S, S
|
||||||
@@ -30,7 +32,11 @@ function Zoom.offsetRange(S)
|
|||||||
end
|
end
|
||||||
-- LOW performance tier: no survey (negative offsets), even if a mod's
|
-- LOW performance tier: no survey (negative offsets), even if a mod's
|
||||||
-- zoom.range widened it. == false so nil/true stays permissive.
|
-- zoom.range widened it. == false so nil/true stays permissive.
|
||||||
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
|
if Zoom.allowSurvey == false then
|
||||||
|
if lo < 0 then lo = 0 end
|
||||||
|
elseif lo > -3 then
|
||||||
|
lo = -3
|
||||||
|
end
|
||||||
return lo, hi
|
return lo, hi
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,6 +50,8 @@ function Zoom.scale(S)
|
|||||||
local maxScale = math.max(minScale, S + hi)
|
local maxScale = math.max(minScale, S + hi)
|
||||||
if s < minScale then s = minScale end
|
if s < minScale then s = minScale end
|
||||||
if s > maxScale then s = maxScale end
|
if s > maxScale then s = maxScale end
|
||||||
|
-- Integer offset below 1px/world (OPTIONS OUT on a 1× window): 1/2, 1/4, …
|
||||||
|
if s < 1 then s = 0.5 ^ (1 - s) end
|
||||||
if s < 0.25 then s = 0.25 end
|
if s < 0.25 then s = 0.25 end
|
||||||
return s
|
return s
|
||||||
end
|
end
|
||||||
@@ -79,6 +87,30 @@ function Zoom.applyOptions(opts)
|
|||||||
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
|
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Integer fit used when OPTIONS has no live renderer (launcher, title).
|
||||||
|
function Zoom.windowFitScale()
|
||||||
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
|
local ww, wh = love.graphics.getDimensions()
|
||||||
|
ww, wh = tonumber(ww) or 0, tonumber(wh) or 0
|
||||||
|
if ww >= 160 and wh >= 144 then
|
||||||
|
return math.max(1, math.floor(math.min(ww / 160, wh / 144)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One step of the OPTIONS ZOOM row (dir +1 in, -1 out). Shared by Red
|
||||||
|
-- and Gold so both ladders offer OUT / FIT / IN.
|
||||||
|
function Zoom.nudgeOptions(options, dir, S)
|
||||||
|
S = math.max(1, math.floor(tonumber(S) or Zoom.windowFitScale()))
|
||||||
|
local lo, hi = Zoom.offsetRange(S)
|
||||||
|
local off = math.floor(tonumber(options and options.zoom) or 0) + (dir or 1)
|
||||||
|
if off > hi then off = lo elseif off < lo then off = hi end
|
||||||
|
if options then options.zoom = off end
|
||||||
|
Zoom.offset = off
|
||||||
|
return off
|
||||||
|
end
|
||||||
|
|
||||||
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
|
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
|
||||||
function Zoom.offsetLabel(offset)
|
function Zoom.offsetLabel(offset)
|
||||||
offset = math.floor(tonumber(offset) or 0)
|
offset = math.floor(tonumber(offset) or 0)
|
||||||
|
|||||||
@@ -1694,7 +1694,8 @@ H.RandomPhoneWildMon = function(vm)
|
|||||||
local entry = contact and contact.map and grass and grass[contact.map]
|
local entry = contact and contact.map and grass and grass[contact.map]
|
||||||
local slots = entry and entry.slots
|
local slots = entry and entry.slots
|
||||||
if not slots then return end
|
if not slots then return end
|
||||||
local daytime = (w and w.daytime) or "DAY"
|
-- wTimeOfDay, not the palette pin (wildmons.asm:861)
|
||||||
|
local daytime = (w and (w.tod or w.daytime)) or "DAY"
|
||||||
if daytime == "DARK" then daytime = "NITE" end
|
if daytime == "DARK" then daytime = "NITE" end
|
||||||
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
|
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
|
||||||
if slot and slot.species then nameSpecies(vm, slot.species) end
|
if slot and slot.species then nameSpecies(vm, slot.species) end
|
||||||
|
|||||||
+15
-6
@@ -543,10 +543,14 @@ local function runCmd(self, cmd, op)
|
|||||||
local species = cmd.species or arg1(cmd)
|
local species = cmd.species or arg1(cmd)
|
||||||
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
||||||
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
||||||
if self.givePokeFn then
|
|
||||||
local mon = self.givePokeFn(species, level, item)
|
|
||||||
-- engine/pokemon/move_mon.asm:1753-1757
|
|
||||||
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
||||||
|
if self.givePokeFn then
|
||||||
|
-- engine/pokemon/move_mon.asm:1695-1736: the trainer arm copies the
|
||||||
|
-- script's own nickname and OT name in instead of asking for one.
|
||||||
|
local named = trainer ~= 0
|
||||||
|
and { nickname = cmd.name, otName = cmd.otName } or nil
|
||||||
|
local mon = self.givePokeFn(species, level, item, named)
|
||||||
|
-- engine/pokemon/move_mon.asm:1753-1757
|
||||||
if mon and trainer == 0 then
|
if mon and trainer == 0 then
|
||||||
Specials.askNickname(self, mon)
|
Specials.askNickname(self, mon)
|
||||||
end
|
end
|
||||||
@@ -1114,7 +1118,9 @@ local function runCmd(self, cmd, op)
|
|||||||
-- really does run here.
|
-- really does run here.
|
||||||
if self.reloadMapFn then self.reloadMapFn(true) end
|
if self.reloadMapFn then self.reloadMapFn(true) end
|
||||||
elseif op == "winlosstext" then
|
elseif op == "winlosstext" then
|
||||||
-- Overrides the struct's win/loss text for this battle only.
|
-- Overrides the struct's win/loss text for this battle only; a 0
|
||||||
|
-- argument zeroes that pointer (engine/overworld/scripting.asm:651)
|
||||||
|
self.winLossArmed = true
|
||||||
self.winTextOverride = cmd.winText
|
self.winTextOverride = cmd.winText
|
||||||
self.lossTextOverride = cmd.lossText
|
self.lossTextOverride = cmd.lossText
|
||||||
elseif op == "trainertext" then
|
elseif op == "trainertext" then
|
||||||
@@ -1122,9 +1128,11 @@ local function runCmd(self, cmd, op)
|
|||||||
local obj = self.trainerObject or {}
|
local obj = self.trainerObject or {}
|
||||||
local key
|
local key
|
||||||
if which == 1 then
|
if which == 1 then
|
||||||
key = self.winTextOverride or obj.winText
|
key = self.winLossArmed and self.winTextOverride
|
||||||
|
or (not self.winLossArmed and obj.winText or nil)
|
||||||
elseif which == 2 then
|
elseif which == 2 then
|
||||||
key = self.lossTextOverride or obj.lossText
|
key = self.winLossArmed and self.lossTextOverride
|
||||||
|
or (not self.winLossArmed and obj.lossText or nil)
|
||||||
else
|
else
|
||||||
key = obj.seenText
|
key = obj.seenText
|
||||||
end
|
end
|
||||||
@@ -2425,6 +2433,7 @@ function Vm:start(scriptKey)
|
|||||||
self.battleOutcome = nil
|
self.battleOutcome = nil
|
||||||
self.winTextOverride = nil
|
self.winTextOverride = nil
|
||||||
self.lossTextOverride = nil
|
self.lossTextOverride = nil
|
||||||
|
self.winLossArmed = nil
|
||||||
-- The whiteout abort is per-run too: a script that ended because the player
|
-- The whiteout abort is per-run too: a script that ended because the player
|
||||||
-- was wiped must not stop the next one before it starts.
|
-- was wiped must not stop the next one before it starts.
|
||||||
self.aborted = false
|
self.aborted = false
|
||||||
|
|||||||
+66
-16
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
|
|||||||
|
|
||||||
SyncEngine.UPLOAD_DEBOUNCE = 5
|
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||||
SyncEngine.AUTO_INTERVAL = 300
|
SyncEngine.AUTO_INTERVAL = 300
|
||||||
|
SyncEngine.RESUME_MIN_GAP = 60
|
||||||
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||||
|
|
||||||
local IDLE_STATUS = "Ready"
|
local IDLE_STATUS = "Ready"
|
||||||
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
|
|||||||
eng.modPlan = nil
|
eng.modPlan = nil
|
||||||
eng.shareCode = nil
|
eng.shareCode = nil
|
||||||
eng.clock = 0
|
eng.clock = 0
|
||||||
|
eng.autoAt = SyncEngine.AUTO_INTERVAL
|
||||||
eng.queue = {}
|
eng.queue = {}
|
||||||
eng.pending = nil
|
eng.pending = nil
|
||||||
eng.uploadAt = nil
|
eng.uploadAt = nil
|
||||||
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
|
|||||||
self.uploadAt = nil
|
self.uploadAt = nil
|
||||||
if self.state.enabled and self:linked() then self:syncNow() end
|
if self.state.enabled and self:linked() then self:syncNow() end
|
||||||
end
|
end
|
||||||
|
if self.clock >= self.autoAt and not self:busy()
|
||||||
|
and (self.phase == "idle" or self.phase == "error")
|
||||||
|
and self.state.enabled and self:linked() then
|
||||||
|
self:syncNow()
|
||||||
|
end
|
||||||
local steps = 0
|
local steps = 0
|
||||||
while not self.pending and #self.queue > 0
|
while not self.pending and #self.queue > 0
|
||||||
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||||
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
|
|||||||
eng.phase = "idle"
|
eng.phase = "idle"
|
||||||
eng.status = "Sync account created"
|
eng.status = "Sync account created"
|
||||||
eng:_persist()
|
eng:_persist()
|
||||||
|
eng:syncNow()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
|
|||||||
return self.state.enabled
|
return self.state.enabled
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SyncEngine:protectPlaythrough(version, playthroughId)
|
||||||
|
self.protectedKey = SyncState.key(version, playthroughId)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:noteResumed()
|
||||||
|
if not (self.state.enabled and self:linked()) then return end
|
||||||
|
if self:busy() or self.phase == "conflict" then return end
|
||||||
|
if self.now() - (tonumber(self.state.lastSyncAt) or 0)
|
||||||
|
< SyncEngine.RESUME_MIN_GAP then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:syncNow()
|
||||||
|
end
|
||||||
|
|
||||||
function SyncEngine:syncNow()
|
function SyncEngine:syncNow()
|
||||||
if not self:linked() then return false, "this device is not linked" end
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
if self.pending then return false, "sync is busy" end
|
if self.pending then return false, "sync is busy" end
|
||||||
|
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
|
||||||
self.queue = {}
|
self.queue = {}
|
||||||
self.conflicts = {}
|
self.conflicts = {}
|
||||||
self.state.pendingConflicts = {}
|
self.state.pendingConflicts = {}
|
||||||
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
|
|||||||
self:_addConflict(entry, key, row)
|
self:_addConflict(entry, key, row)
|
||||||
elseif localChanged then
|
elseif localChanged then
|
||||||
self:_queueUpload(entry, key, false)
|
self:_queueUpload(entry, key, false)
|
||||||
elseif remoteChanged then
|
elseif remoteChanged and key ~= self.protectedKey then
|
||||||
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
for key, row in pairs(remote) do
|
for key, row in pairs(remote) do
|
||||||
if not seen[key] then
|
if not seen[key] and key ~= self.protectedKey then
|
||||||
local version, id = SyncState.splitKey(key)
|
local version, id = SyncState.splitKey(key)
|
||||||
if version and id then
|
if version and id then
|
||||||
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||||
@@ -573,12 +596,13 @@ function SyncEngine:resolveConflict(key, choice)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
function SyncEngine:uploadMods()
|
function SyncEngine:uploadMods(includeOptions)
|
||||||
if not self:linked() then return false, "this device is not linked" end
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
if self:busy() then return false, "sync is busy" end
|
if self:busy() then return false, "sync is busy" end
|
||||||
local manifest = SyncMods.build(self.modDeps)
|
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
||||||
self.phase = "uploading"
|
self.phase = "uploading"
|
||||||
self.status = "Uploading the mod list..."
|
self.status = includeOptions and "Uploading the mod list and options..."
|
||||||
|
or "Uploading the mod list..."
|
||||||
local handle, err = self.client:putMods(manifest)
|
local handle, err = self.client:putMods(manifest)
|
||||||
return self:_request(handle, err, function(eng)
|
return self:_request(handle, err, function(eng)
|
||||||
eng.phase = "idle"
|
eng.phase = "idle"
|
||||||
@@ -595,19 +619,17 @@ function SyncEngine:fetchModPlan()
|
|||||||
return self:_request(handle, err, function(eng, res)
|
return self:_request(handle, err, function(eng, res)
|
||||||
local data = res.data or {}
|
local data = res.data or {}
|
||||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
||||||
eng.phase = "idle"
|
|
||||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
|
||||||
and "Mods already match" or "Mod changes ready to apply"
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function SyncEngine:shareMods()
|
function SyncEngine:shareMods(includeOptions)
|
||||||
if not self:linked() then return false, "this device is not linked" end
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
if self:busy() then return false, "sync is busy" end
|
if self:busy() then return false, "sync is busy" end
|
||||||
local manifest = SyncMods.build(self.modDeps)
|
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
||||||
self.phase = "uploading"
|
self.phase = "uploading"
|
||||||
self.status = "Sharing the mod list..."
|
self.status = includeOptions and "Sharing the mod list and options..."
|
||||||
|
or "Sharing the mod list..."
|
||||||
local handle, err = self.client:shareMods(manifest)
|
local handle, err = self.client:shareMods(manifest)
|
||||||
return self:_request(handle, err, function(eng, res)
|
return self:_request(handle, err, function(eng, res)
|
||||||
local data = res.data or {}
|
local data = res.data or {}
|
||||||
@@ -626,16 +648,44 @@ function SyncEngine:fetchShare(code)
|
|||||||
return self:_request(handle, err, function(eng, res)
|
return self:_request(handle, err, function(eng, res)
|
||||||
local data = res.data or {}
|
local data = res.data or {}
|
||||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
||||||
eng.phase = "idle"
|
|
||||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
|
||||||
and "Mods already match" or "Mod changes ready to apply"
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SyncEngine:_takeModPlan(plan)
|
||||||
|
self.modPlan = plan
|
||||||
|
self.phase = "idle"
|
||||||
|
if SyncMods.planHasOptions(plan) then
|
||||||
|
self.status = ("This list carries options for %d mods.")
|
||||||
|
:format(#plan.options)
|
||||||
|
elseif SyncMods.planEmpty(plan) then
|
||||||
|
self.status = "Mods already match"
|
||||||
|
else
|
||||||
|
self.status = "Mod changes ready to apply"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:modOptionsAsk()
|
||||||
|
local plan = self.modPlan
|
||||||
|
if not SyncMods.planHasOptions(plan) then return nil end
|
||||||
|
if plan.applyOptions ~= nil then return nil end
|
||||||
|
return SyncMods.optionModIds(plan)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:answerModOptions(importThem)
|
||||||
|
local plan = self.modPlan
|
||||||
|
if not SyncMods.planHasOptions(plan) then return false end
|
||||||
|
SyncMods.answerOptions(plan, importThem)
|
||||||
|
self.status = plan.applyOptions
|
||||||
|
and "Their mod options will be imported too"
|
||||||
|
or "Their mod options will be skipped"
|
||||||
|
return plan.applyOptions
|
||||||
|
end
|
||||||
|
|
||||||
function SyncEngine:applyModPlan(progress)
|
function SyncEngine:applyModPlan(progress)
|
||||||
if not self.modPlan then return false, "no mod plan" end
|
if not self.modPlan then return false, "no mod plan" end
|
||||||
if self.modApply then return false, "the mods are already being applied" end
|
if self.modApply then return false, "the mods are already being applied" end
|
||||||
|
self.modPlan.applyOptions = self.modPlan.applyOptions == true
|
||||||
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
||||||
if #steps == 0 then
|
if #steps == 0 then
|
||||||
self.modPlan = nil
|
self.modPlan = nil
|
||||||
|
|||||||
+101
-3
@@ -1,6 +1,8 @@
|
|||||||
local SyncMods = {}
|
local SyncMods = {}
|
||||||
|
|
||||||
SyncMods.REV = 1
|
SyncMods.REV = 2
|
||||||
|
SyncMods.MAX_OPTION_KEYS = 64
|
||||||
|
SyncMods.MAX_OPTION_TEXT = 256
|
||||||
|
|
||||||
local function versions()
|
local function versions()
|
||||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||||
@@ -35,6 +37,12 @@ local function defaultDeps()
|
|||||||
setEnabled = function(id, enabled, version)
|
setEnabled = function(id, enabled, version)
|
||||||
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
|
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
|
||||||
end,
|
end,
|
||||||
|
modOptions = function()
|
||||||
|
return require("src.mods.LauncherMods").modOptions()
|
||||||
|
end,
|
||||||
|
setOptions = function(id, values)
|
||||||
|
return require("src.mods.LauncherMods").setModOptions(id, values)
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,6 +54,41 @@ local function deps(given)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function sanitizeOptions(bucket)
|
||||||
|
if type(bucket) ~= "table" then return nil end
|
||||||
|
local keys = {}
|
||||||
|
for k, v in pairs(bucket) do
|
||||||
|
local t = type(v)
|
||||||
|
if type(k) == "string" and k ~= ""
|
||||||
|
and (t == "string" or t == "number" or t == "boolean") then
|
||||||
|
keys[#keys + 1] = k
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #keys == 0 then return nil end
|
||||||
|
table.sort(keys)
|
||||||
|
local out, n = {}, 0
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
if n >= SyncMods.MAX_OPTION_KEYS then break end
|
||||||
|
local v = bucket[k]
|
||||||
|
if type(v) == "string" then v = v:sub(1, SyncMods.MAX_OPTION_TEXT) end
|
||||||
|
local finite = type(v) ~= "number"
|
||||||
|
or (v == v and v ~= math.huge and v ~= -math.huge)
|
||||||
|
if finite then
|
||||||
|
out[k] = v
|
||||||
|
n = n + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if n == 0 then return nil end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sameOptions(a, b)
|
||||||
|
for k, v in pairs(a) do
|
||||||
|
if (b or {})[k] ~= v then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local function sourceOf(row)
|
local function sourceOf(row)
|
||||||
local github = row.github
|
local github = row.github
|
||||||
or (type(row.manifest) == "table" and row.manifest.github)
|
or (type(row.manifest) == "table" and row.manifest.github)
|
||||||
@@ -55,9 +98,14 @@ local function sourceOf(row)
|
|||||||
return "local"
|
return "local"
|
||||||
end
|
end
|
||||||
|
|
||||||
function SyncMods.build(given)
|
function SyncMods.build(given, includeOptions)
|
||||||
local d = deps(given)
|
local d = deps(given)
|
||||||
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
|
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
|
||||||
|
local stored = {}
|
||||||
|
if includeOptions then
|
||||||
|
local ok, live = pcall(d.modOptions)
|
||||||
|
if ok and type(live) == "table" then stored = live end
|
||||||
|
end
|
||||||
for _, row in ipairs(d.indexes() or {}) do
|
for _, row in ipairs(d.indexes() or {}) do
|
||||||
local url = row.url or row.feed
|
local url = row.url or row.feed
|
||||||
if type(url) == "string" and url ~= "" then
|
if type(url) == "string" and url ~= "" then
|
||||||
@@ -72,11 +120,14 @@ function SyncMods.build(given)
|
|||||||
for _, version in ipairs(versions()) do
|
for _, version in ipairs(versions()) do
|
||||||
if answers[version] then enabledFor[#enabledFor + 1] = version end
|
if answers[version] then enabledFor[#enabledFor + 1] = version end
|
||||||
end
|
end
|
||||||
|
local options = includeOptions and sanitizeOptions(stored[row.id]) or nil
|
||||||
|
if options then manifest.hasOptions = true end
|
||||||
manifest.mods[#manifest.mods + 1] = {
|
manifest.mods[#manifest.mods + 1] = {
|
||||||
id = row.id,
|
id = row.id,
|
||||||
version = row.version,
|
version = row.version,
|
||||||
source = sourceOf(row),
|
source = sourceOf(row),
|
||||||
enabledFor = enabledFor,
|
enabledFor = enabledFor,
|
||||||
|
options = options,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -86,9 +137,16 @@ end
|
|||||||
|
|
||||||
function SyncMods.plan(manifest, given)
|
function SyncMods.plan(manifest, given)
|
||||||
local d = deps(given)
|
local d = deps(given)
|
||||||
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
|
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
|
||||||
|
options = {}, applyOptions = nil }
|
||||||
if type(manifest) ~= "table" then return plan end
|
if type(manifest) ~= "table" then return plan end
|
||||||
|
|
||||||
|
local liveOptions = {}
|
||||||
|
do
|
||||||
|
local ok, live = pcall(d.modOptions)
|
||||||
|
if ok and type(live) == "table" then liveOptions = live end
|
||||||
|
end
|
||||||
|
|
||||||
local haveIndex = {}
|
local haveIndex = {}
|
||||||
for _, row in ipairs(d.indexes() or {}) do
|
for _, row in ipairs(d.indexes() or {}) do
|
||||||
if type(row.url) == "string" then haveIndex[row.url] = true end
|
if type(row.url) == "string" then haveIndex[row.url] = true end
|
||||||
@@ -130,6 +188,10 @@ function SyncMods.plan(manifest, given)
|
|||||||
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
|
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
local wanted = sanitizeOptions(mod.options)
|
||||||
|
if wanted and not sameOptions(wanted, liveOptions[mod.id]) then
|
||||||
|
plan.options[#plan.options + 1] = { id = mod.id, values = wanted }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -138,10 +200,33 @@ end
|
|||||||
|
|
||||||
function SyncMods.planEmpty(plan)
|
function SyncMods.planEmpty(plan)
|
||||||
if type(plan) ~= "table" then return true end
|
if type(plan) ~= "table" then return true end
|
||||||
|
if plan.applyOptions and #(plan.options or {}) > 0 then return false end
|
||||||
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
|
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
|
||||||
and #(plan.toEnable or {}) == 0
|
and #(plan.toEnable or {}) == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SyncMods.planHasOptions(plan)
|
||||||
|
return type(plan) == "table" and #(plan.options or {}) > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.optionsAnswered(plan)
|
||||||
|
return not SyncMods.planHasOptions(plan) or plan.applyOptions ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.answerOptions(plan, importThem)
|
||||||
|
if type(plan) ~= "table" then return false end
|
||||||
|
plan.applyOptions = importThem and true or false
|
||||||
|
return plan.applyOptions
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncMods.optionModIds(plan)
|
||||||
|
local out = {}
|
||||||
|
for _, row in ipairs((type(plan) == "table" and plan.options) or {}) do
|
||||||
|
out[#out + 1] = row.id
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
function SyncMods.steps(plan, given)
|
function SyncMods.steps(plan, given)
|
||||||
local d = deps(given)
|
local d = deps(given)
|
||||||
local out = {}
|
local out = {}
|
||||||
@@ -175,6 +260,19 @@ function SyncMods.steps(plan, given)
|
|||||||
return true
|
return true
|
||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
|
if plan.applyOptions then
|
||||||
|
for _, want in ipairs(plan.options or {}) do
|
||||||
|
out[#out + 1] = { label = want.id, run = function()
|
||||||
|
if broken[want.id] then return true end
|
||||||
|
local ok, err = d.setOptions(want.id, want.values)
|
||||||
|
if ok == false then
|
||||||
|
return nil, want.id .. ": "
|
||||||
|
.. tostring(err or "could not set the mod options")
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
end
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+16
-7
@@ -17,10 +17,13 @@ local function buildItems(game)
|
|||||||
local items = {}
|
local items = {}
|
||||||
for _, id in ipairs(Bag.order(game.save)) do
|
for _, id in ipairs(Bag.order(game.save)) do
|
||||||
local def = game.data.items[id]
|
local def = game.data.items[id]
|
||||||
|
-- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns:
|
||||||
|
-- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641)
|
||||||
|
local unsellable = (def and def.keyItem) or id:find("^HM_") ~= nil
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
value = id,
|
value = id,
|
||||||
label = def and def.name or id,
|
label = def and def.name or id,
|
||||||
right = "x" .. game.save.inventory[id],
|
right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
return items
|
return items
|
||||||
@@ -334,8 +337,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
local Evolution = require("src.pokemon.Evolution")
|
local Evolution = require("src.pokemon.Evolution")
|
||||||
local evoTo, evo = Evolution.pendingFor(game, target,
|
local evoTo, evo = Evolution.pendingFor(game, target,
|
||||||
{ kind = "levelup" })
|
{ kind = "levelup" })
|
||||||
|
-- the party menu stays up through TryEvolvingMon and only
|
||||||
|
-- comes down at RemoveUsedItem (item_effects.asm:1392-1418)
|
||||||
if evoTo then
|
if evoTo then
|
||||||
Evolution.evolve(game, target, evoTo, nil, evo and evo.method)
|
Evolution.evolve(game, target, evoTo, closePicker,
|
||||||
|
evo and evo.method)
|
||||||
|
else
|
||||||
|
closePicker()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -399,10 +407,9 @@ local function pickTargetAndUse(game, battle, id, list)
|
|||||||
local opts = {
|
local opts = {
|
||||||
pickOnly = true,
|
pickOnly = true,
|
||||||
battle = battle,
|
battle = battle,
|
||||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
|
||||||
-- out of battle: the in-battle tail closes the bag list underneath
|
-- the party menu (item_effects.asm:1392-1418)
|
||||||
-- first, which needs the picker already gone.
|
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
|
||||||
keepOpen = (not battle) and ItemEffects.healsHP(id),
|
|
||||||
onSwitch = function(mon, picker)
|
onSwitch = function(mon, picker)
|
||||||
if not wantsMove then
|
if not wantsMove then
|
||||||
useOn(game, battle, id, mon, list, nil, picker)
|
useOn(game, battle, id, mon, list, nil, picker)
|
||||||
@@ -470,7 +477,9 @@ function BagMenu.new(game, opts)
|
|||||||
local list
|
local list
|
||||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||||
kind = "bag",
|
kind = "bag",
|
||||||
footer = ("¥%d"):format(game.save.money),
|
-- StartMenu_Item zeroes wPrintItemPrices and draws no money box: the
|
||||||
|
-- LIST_MENU_BOX floats over the map (engine/menus/start_sub_menus.asm)
|
||||||
|
itemBox = true,
|
||||||
-- B returns to the start menu when the bag was opened from it
|
-- B returns to the start menu when the bag was opened from it
|
||||||
onCancel = opts.onCancel,
|
onCancel = opts.onCancel,
|
||||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||||
|
|||||||
+4
-2
@@ -185,14 +185,16 @@ local function release(game)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
(t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name))
|
||||||
|
:gsub("{RAM:wStringBuffer}", name), nil, {
|
||||||
defaultNo = true, noSound = true,
|
defaultNo = true, noSound = true,
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
table.remove(box, list.index)
|
table.remove(box, list.index)
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
|
((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name))
|
||||||
|
:gsub("{RAM:wStringBuffer}", name))))
|
||||||
list:removeCurrent()
|
list:removeCurrent()
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
|
|||||||
+109
-18
@@ -1,18 +1,77 @@
|
|||||||
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
|
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
|
||||||
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
|
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
|
||||||
-- player's name, shown by the Celadon Mansion 3F game designer once 150
|
-- player's name and character sprite, shown by the Celadon Mansion 3F game designer
|
||||||
-- species are owned. Diploma.render also backs the Yellow-only printed
|
-- once 150 species are owned. Diploma.render also backs the printed copy
|
||||||
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
-- (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
||||||
|
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Diploma = {}
|
local Diploma = {}
|
||||||
Diploma.__index = Diploma
|
Diploma.__index = Diploma
|
||||||
Diploma.isOpaque = true
|
Diploma.isOpaque = true
|
||||||
|
|
||||||
|
-- SGB: PalPacket_Generic (MEWMON), whole screen (engine/events/diploma.asm:67)
|
||||||
|
function Diploma:sgbPalettes(game)
|
||||||
|
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tryImage(path)
|
||||||
|
if not path then return nil end
|
||||||
|
local ok, img = pcall(Assets.image, path)
|
||||||
|
if ok and img then return img end
|
||||||
|
local ok2, img2 = pcall(love.graphics.newImage, path)
|
||||||
|
return ok2 and img2 or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadFrame()
|
||||||
|
local frame = tryImage("assets/generated/trainer_card/trainer_info.png")
|
||||||
|
if not frame then return nil end
|
||||||
|
local quads = {}
|
||||||
|
for i = 0, 8 do
|
||||||
|
quads[i] = love.graphics.newQuad((i % 3) * 8,
|
||||||
|
math.floor(i / 3) * 8,
|
||||||
|
8, 8, frame:getDimensions())
|
||||||
|
end
|
||||||
|
return { img = frame, quads = quads }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawFrameBox(frame, tx, ty, tw, th)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||||
|
if not frame then
|
||||||
|
Font.drawBox(tx, ty, tw, th)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local img = frame.img
|
||||||
|
local q = frame.quads
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
-- corners
|
||||||
|
love.graphics.draw(img, q[0], tx * 8, ty * 8)
|
||||||
|
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
|
||||||
|
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
|
||||||
|
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
|
||||||
|
-- horizontal edges
|
||||||
|
for x = 1, tw - 2 do
|
||||||
|
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
|
||||||
|
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
|
||||||
|
end
|
||||||
|
-- vertical edges
|
||||||
|
for y = 1, th - 2 do
|
||||||
|
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
|
||||||
|
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Diploma.new(game, onDone)
|
function Diploma.new(game, onDone)
|
||||||
return setmetatable({ game = game, onDone = onDone }, Diploma)
|
local self = setmetatable({
|
||||||
|
game = game,
|
||||||
|
onDone = onDone,
|
||||||
|
}, Diploma)
|
||||||
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
function Diploma:update()
|
function Diploma:update()
|
||||||
@@ -23,23 +82,55 @@ function Diploma:update()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
|
-- the DisplayDiploma / DisplayDiplomaTop layout (hlcoord tiles -> x*8, y*8)
|
||||||
function Diploma.render(game)
|
function Diploma.render(game)
|
||||||
|
local frame = loadFrame()
|
||||||
|
local circle = tryImage("assets/generated/trainer_card/circle_tile.png")
|
||||||
|
|
||||||
|
-- 1. Outer ornate frame border: hlcoord 0, 0 / bc 16, 18 -> (0, 0, 20, 18)
|
||||||
|
drawFrameBox(frame, 0, 0, 20, 18)
|
||||||
|
|
||||||
|
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
|
||||||
|
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
|
||||||
|
local picPath, picTrueColor = Sprites.playerPath(
|
||||||
|
game.data, "front", { kind = "diploma" })
|
||||||
|
local pic = tryImage(picPath)
|
||||||
|
if pic then
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.draw(pic, 115, 80)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
if picTrueColor then
|
||||||
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
|
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
|
||||||
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
|
|
||||||
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
|
|
||||||
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
|
|
||||||
local congrats = { -- hlcoord 2,6
|
|
||||||
"Congrats! This", "diploma certifies", "that you have",
|
|
||||||
"completed your", "POKéDEX.",
|
|
||||||
}
|
|
||||||
for i, line in ipairs(congrats) do
|
|
||||||
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
|
|
||||||
end
|
end
|
||||||
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
|
end
|
||||||
|
|
||||||
|
-- 3. Header: hlcoord 5, 2 with flanking circle tiles ($70)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
if circle then
|
||||||
|
love.graphics.draw(circle, 40, 16) -- hlcoord 5, 2
|
||||||
|
love.graphics.draw(circle, 104, 16) -- hlcoord 13, 2
|
||||||
|
end
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
|
||||||
|
|
||||||
|
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
|
||||||
|
Font.draw(Strings("Player"), 24, 32)
|
||||||
|
local playerName = (game.save.player and game.save.player.name) or "RED"
|
||||||
|
Font.draw(playerName, 80, 32)
|
||||||
|
|
||||||
|
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
|
||||||
|
local congrats = {
|
||||||
|
{ text = "Congrats! This", y = 48 }, -- hlcoord 2, 6
|
||||||
|
{ text = "diploma certifies", y = 64 }, -- hlcoord 2, 8
|
||||||
|
{ text = "that you have", y = 80 }, -- hlcoord 2, 10
|
||||||
|
{ text = "completed your", y = 96 }, -- hlcoord 2, 12
|
||||||
|
{ text = "POKéDEX.", y = 112 }, -- hlcoord 2, 14
|
||||||
|
}
|
||||||
|
for _, line in ipairs(congrats) do
|
||||||
|
Font.draw(Strings(line.text), 16, line.y)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 6. Developer signature: hlcoord 9, 16
|
||||||
|
Font.draw(Strings("GAME FREAK"), 72, 128)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+13
-19
@@ -1,6 +1,7 @@
|
|||||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||||
-- flashes back and forth with the evolved form, speeding up, then the
|
-- flashes back and forth with the evolved form, speeding up, then the
|
||||||
-- new form appears with its cry and the congratulations text.
|
-- new form appears with its cry and the "evolved into" text
|
||||||
|
-- (engine/pokemon/evos_moves.asm:120-128).
|
||||||
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||||
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
||||||
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||||
@@ -9,14 +10,13 @@
|
|||||||
-- and stone evolutions, where the B press is read but thrown away because
|
-- and stone evolutions, where the B press is read but thrown away because
|
||||||
-- ItemUseEvoStone left wForceEvolution set (#290).
|
-- ItemUseEvoStone left wForceEvolution set (#290).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Strings = require("src.core.Strings")
|
|
||||||
local romText = require("src.core.RomText")
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
|
-- Not opaque: ClearScreenArea wipes rows 0-11 only (evos_moves.asm:126-128),
|
||||||
|
-- so the "is evolving!" box beneath stays visible through the flash (#1596).
|
||||||
local EvolutionState = {}
|
local EvolutionState = {}
|
||||||
EvolutionState.__index = EvolutionState
|
EvolutionState.__index = EvolutionState
|
||||||
EvolutionState.isOpaque = true
|
|
||||||
|
|
||||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||||
function EvolutionState:sgbPalettes(game)
|
function EvolutionState:sgbPalettes(game)
|
||||||
@@ -127,11 +127,11 @@ function EvolutionState:update(dt)
|
|||||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local newName = game.data.pokemon[self.newSpecies].name
|
local newName = game.data.pokemon[self.newSpecies].name
|
||||||
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
|
-- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox),
|
||||||
-- decoder does not follow), so the engine's wording stands here
|
-- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153)
|
||||||
game.stack:push(TextBox.new(game,
|
local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
|
||||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
.. romText(game.data, "_IntoText", "\ninto %s!", newName)
|
||||||
self.oldName, newName),
|
game.stack:push(TextBox.new(game, msg,
|
||||||
function()
|
function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
game.stack:pop() -- the evolution screen itself
|
game.stack:pop() -- the evolution screen itself
|
||||||
@@ -141,13 +141,15 @@ function EvolutionState:update(dt)
|
|||||||
-- first so the "learned MOVE!" text / forget prompt push onto the
|
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||||
-- overworld / battle-return, not this state.
|
-- overworld / battle-return, not this state.
|
||||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||||
end))
|
end,
|
||||||
|
TextBox.soundOpts(game, "Get_Item2")))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function EvolutionState:draw()
|
function EvolutionState:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
-- rows 0-11 only (hlcoord 0,0 / lb bc, 12, 20, evos_moves.asm:126-128)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 96)
|
||||||
|
|
||||||
-- accelerating flash between the two forms
|
-- accelerating flash between the two forms
|
||||||
local sprite, spriteTrueColor
|
local sprite, spriteTrueColor
|
||||||
@@ -172,14 +174,6 @@ function EvolutionState:draw()
|
|||||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
if not self.done then
|
|
||||||
Font.draw(Strings("What?"), 8, 104)
|
|
||||||
Font.draw(self.oldName .. " is", 8, 114)
|
|
||||||
Font.draw(Strings("evolving!"), 8, 124)
|
|
||||||
end
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return EvolutionState
|
return EvolutionState
|
||||||
|
|||||||
+66
-3
@@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ROWS = 7
|
local ROWS = 7
|
||||||
|
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||||
|
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||||
|
local ITEM_BOX = { tx = 4, ty = 2, tw = 16, th = 11 }
|
||||||
|
local ITEM_ROWS = 4
|
||||||
|
local ITEM_NAME_X, ITEM_TOP_Y = 48, 32
|
||||||
|
local ITEM_CURSOR_X = 40
|
||||||
|
local ITEM_QTY_X, ITEM_QTY_END = 112, 136
|
||||||
|
local ITEM_MORE_X, ITEM_MORE_Y = 144, 88
|
||||||
-- frames to wait before key-repeat kicks in, then between repeats
|
-- frames to wait before key-repeat kicks in, then between repeats
|
||||||
local REPEAT_DELAY = 16
|
local REPEAT_DELAY = 16
|
||||||
local REPEAT_RATE = 4
|
local REPEAT_RATE = 4
|
||||||
@@ -83,7 +91,20 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
||||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||||
self.noSound = opts.noSound or false
|
self.noSound = opts.noSound or false
|
||||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
-- the bag's item list: a partial box the map stays visible around, not a
|
||||||
|
-- screen of its own (home/list_menu.asm:29-31)
|
||||||
|
self.itemBox = opts.itemBox or false
|
||||||
|
if self.itemBox then
|
||||||
|
self.isOpaque = false
|
||||||
|
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
|
||||||
|
-- its own (engine/menus/start_sub_menus.asm:300)
|
||||||
|
self.sgbPalettes = false
|
||||||
|
-- wMaxMenuItem is 2 for item lists; the fourth printed row is a
|
||||||
|
-- look-ahead the cursor cannot reach (home/list_menu.asm:46-48)
|
||||||
|
self.cursorRows = 3
|
||||||
|
end
|
||||||
|
self.rows = opts.rows or (self.itemBox and ITEM_ROWS)
|
||||||
|
or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -100,8 +121,9 @@ local function moveIndex(self, delta)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function syncScroll(self)
|
local function syncScroll(self)
|
||||||
if self.index - self.scroll > self.rows then
|
local maxRow = self.cursorRows or self.rows
|
||||||
self.scroll = self.index - self.rows
|
if self.index - self.scroll > maxRow then
|
||||||
|
self.scroll = self.index - maxRow
|
||||||
end
|
end
|
||||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||||
end
|
end
|
||||||
@@ -206,7 +228,48 @@ function ListMenu:close()
|
|||||||
if top == self then self.game.stack:pop() end
|
if top == self then self.game.stack:pop() end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
|
||||||
|
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
|
||||||
|
function ListMenu:drawItemBox()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
if #self.items == 0 then
|
||||||
|
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
|
||||||
|
end
|
||||||
|
local shown = 0
|
||||||
|
for row = 1, self.rows do
|
||||||
|
local i = self.scroll + row
|
||||||
|
local item = self.items[i]
|
||||||
|
if not item then break end
|
||||||
|
shown = shown + 1
|
||||||
|
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||||
|
Font.draw(item.label, ITEM_NAME_X, y)
|
||||||
|
if item.right then
|
||||||
|
-- '×' at column 14, PrintNumber's two right-aligned digits after it
|
||||||
|
-- (home/list_menu.asm:479-490)
|
||||||
|
local count = item.right:sub(2)
|
||||||
|
Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8)
|
||||||
|
Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8)
|
||||||
|
end
|
||||||
|
if i == self.index then
|
||||||
|
Font.drawCode(self.hollowIndex == i
|
||||||
|
and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y)
|
||||||
|
end
|
||||||
|
if self.swapIndex == i and i ~= self.index then
|
||||||
|
Font.drawCode(Theme.cursorHollow, ITEM_CURSOR_X, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- the terminator prints CANCEL and returns before the '▼'
|
||||||
|
-- (home/list_menu.asm:372, 518-524)
|
||||||
|
if shown == self.rows then
|
||||||
|
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function ListMenu:draw()
|
function ListMenu:draw()
|
||||||
|
if self.itemBox then return self:drawItemBox() end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
|||||||
+21
-5
@@ -34,6 +34,9 @@ function Menu.new(game, items, opts)
|
|||||||
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
||||||
end
|
end
|
||||||
self.rowStep = opts.rowStep or 2
|
self.rowStep = opts.rowStep or 2
|
||||||
|
-- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox)
|
||||||
|
self.title = opts.title
|
||||||
|
self.itemY = opts.itemY
|
||||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||||
-- save state and mod hooks); nil/unset keeps every caller's old
|
-- save state and mod hooks); nil/unset keeps every caller's old
|
||||||
@@ -116,7 +119,17 @@ function Menu:draw()
|
|||||||
self.tw * 8, self.th * 8, self.anchor)
|
self.tw * 8, self.th * 8, self.anchor)
|
||||||
end
|
end
|
||||||
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||||
|
-- PlaceString at hlcoord 3,0 writes over the border row it was just
|
||||||
|
-- drawn on (oak_speech2.asm:162-170)
|
||||||
|
if self.title then
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", (self.tx + 3) * 8, self.ty * 8,
|
||||||
|
#Font.split(self.title) * 8, 8)
|
||||||
|
end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
if self.title then
|
||||||
|
Font.draw(self.title, (self.tx + 3) * 8, self.ty * 8)
|
||||||
|
end
|
||||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||||
or #self.items
|
or #self.items
|
||||||
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
|
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
|
||||||
@@ -130,15 +143,18 @@ function Menu:draw()
|
|||||||
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
||||||
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
||||||
-- the bottom border (#564, #572).
|
-- the bottom border (#564, #572).
|
||||||
|
local function rowY(row)
|
||||||
|
if self.itemY then
|
||||||
|
return (self.ty + self.itemY + (row - 1) * self.rowStep) * 8
|
||||||
|
end
|
||||||
|
return (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8
|
||||||
|
end
|
||||||
for row = 1, visible do
|
for row = 1, visible do
|
||||||
local item = self.items[self.scroll + row]
|
local item = self.items[self.scroll + row]
|
||||||
if not item then break end
|
if not item then break end
|
||||||
Font.draw(item.label, (self.tx + 2) * 8,
|
Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
|
||||||
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
|
|
||||||
end
|
end
|
||||||
local cursorRow = self.index - self.scroll
|
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll))
|
||||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
|
||||||
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
|
|
||||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||||
-- use, sat on the bottom border like TextBox's page-advance cursor. It
|
-- use, sat on the bottom border like TextBox's page-advance cursor. It
|
||||||
-- has to be the border row, not ty + th - 2: that is the last interior
|
-- has to be the border row, not ty + th - 2: that is the last interior
|
||||||
|
|||||||
+14
-2
@@ -67,6 +67,7 @@ function NamingScreen.new(game, opts)
|
|||||||
self.game = game
|
self.game = game
|
||||||
self.title = opts.title or Strings("YOUR NAME?")
|
self.title = opts.title or Strings("YOUR NAME?")
|
||||||
self.presets = opts.presets
|
self.presets = opts.presets
|
||||||
|
self.introBox = opts.introBox
|
||||||
self.maxLen = opts.maxLen or 7
|
self.maxLen = opts.maxLen or 7
|
||||||
self.default = opts.default
|
self.default = opts.default
|
||||||
self.onDone = opts.onDone
|
self.onDone = opts.onDone
|
||||||
@@ -95,14 +96,25 @@ function NamingScreen:enter()
|
|||||||
onSelect = function()
|
onSelect = function()
|
||||||
-- the menu already popped itself; pop the naming screen too
|
-- the menu already popped itself; pop the naming screen too
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onDone then self.onDone(preset) end
|
if self.onDone then self.onDone(preset, false) end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
if self.introBox then
|
||||||
|
-- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at
|
||||||
|
-- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2
|
||||||
|
-- TextBoxBorder's b = $a is a fixed 12-row box, whatever the preset
|
||||||
|
-- list's length (oak_speech2.asm:163-166)
|
||||||
|
self.game.stack:push(Menu.new(self.game, items, {
|
||||||
|
tx = 0, ty = 0, tw = 11, th = 12,
|
||||||
|
itemY = 2, title = Strings("NAME"), cancelable = false,
|
||||||
|
}))
|
||||||
|
else
|
||||||
self.game.stack:push(Menu.new(self.game, items, {
|
self.game.stack:push(Menu.new(self.game, items, {
|
||||||
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
|
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
|
||||||
}))
|
}))
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function NamingScreen:confirm()
|
function NamingScreen:confirm()
|
||||||
@@ -125,7 +137,7 @@ function NamingScreen:confirm()
|
|||||||
end
|
end
|
||||||
Sound.play(self.game.data, "Press_AB")
|
Sound.play(self.game.data, "Press_AB")
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onDone then self.onDone(name) end
|
if self.onDone then self.onDone(name, true) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function NamingScreen:grid()
|
function NamingScreen:grid()
|
||||||
|
|||||||
+89
-2
@@ -35,6 +35,29 @@ OakSpeech.letterboxWhite = true
|
|||||||
local FADE_FRAMES = 24
|
local FADE_FRAMES = 24
|
||||||
local WIPE_FRAMES = 32
|
local WIPE_FRAMES = 32
|
||||||
|
|
||||||
|
-- OakSpeechSlidePicRight / OakSpeechSlidePicLeft (oak_speech2.asm:67-89)
|
||||||
|
local SLIDE_TILES = 6
|
||||||
|
local SLIDE_FRAMES = 3
|
||||||
|
|
||||||
|
local PicSlide = {}
|
||||||
|
PicSlide.__index = PicSlide
|
||||||
|
|
||||||
|
function PicSlide:update(dt)
|
||||||
|
-- OakSpeechSlidePicLeft: ClearScreenArea, ld c, 10 / DelayFrames, Delay3
|
||||||
|
-- before the first slide step (oak_speech2.asm:69-78)
|
||||||
|
if (self.delay or 0) > 0 then
|
||||||
|
self.delay = self.delay - 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.t = self.t + 1
|
||||||
|
local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES))
|
||||||
|
self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8
|
||||||
|
if tiles >= SLIDE_TILES then
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.onDone then self.onDone() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- naming presets are boot config (field.boot.namePresets), which a total
|
-- naming presets are boot config (field.boot.namePresets), which a total
|
||||||
-- conversion replaces; the Red/Blue lists remain the fallback
|
-- conversion replaces; the Red/Blue lists remain the fallback
|
||||||
local function namePresets(game, who, fallback)
|
local function namePresets(game, who, fallback)
|
||||||
@@ -155,6 +178,10 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_IntroducePlayerText",
|
textKey = "_IntroducePlayerText",
|
||||||
pic = "player",
|
pic = "player",
|
||||||
|
-- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText's
|
||||||
|
-- `prompt` (text_2.asm:1730) waits for A and leaves the box up
|
||||||
|
reveal = "wipe",
|
||||||
|
stay = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "name_player",
|
id = "name_player",
|
||||||
@@ -171,12 +198,19 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
id = "confirm_player_name",
|
id = "confirm_player_name",
|
||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_YourNameIsText",
|
textKey = "_YourNameIsText",
|
||||||
|
-- _YourNameIsText's `prompt` (text_2.asm:1766), then GBFadeOutToWhite
|
||||||
|
-- / ClearScreen with the box still up (oak_speech.asm:93-94)
|
||||||
|
fadeOut = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "ask_rival_name",
|
id = "ask_rival_name",
|
||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_IntroduceRivalText",
|
textKey = "_IntroduceRivalText",
|
||||||
pic = "rival",
|
pic = "rival",
|
||||||
|
-- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's
|
||||||
|
-- `prompt` (text_2.asm:1740) leaves the box up for ChooseRivalName
|
||||||
|
reveal = "fade",
|
||||||
|
stay = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "name_rival",
|
id = "name_rival",
|
||||||
@@ -191,6 +225,9 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
id = "confirm_rival_name",
|
id = "confirm_rival_name",
|
||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_HisNameIsText",
|
textKey = "_HisNameIsText",
|
||||||
|
-- _HisNameIsText's `prompt` (text_2.asm:1772) then the .skipSpeech
|
||||||
|
-- fade with the box up (oak_speech.asm:103-104)
|
||||||
|
fadeOut = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "legend",
|
id = "legend",
|
||||||
@@ -378,7 +415,25 @@ function OakSpeech:runStep(step)
|
|||||||
self:applyPic(step)
|
self:applyPic(step)
|
||||||
self:afterReveal(step, function()
|
self:afterReveal(step, function()
|
||||||
self:runCry(step)
|
self:runCry(step)
|
||||||
|
if step.stay or step.fadeOut then
|
||||||
|
local box = TextBox.new(self.game, self:stepText(step), nil,
|
||||||
|
{ stay = { prompt = true, onShown = function()
|
||||||
|
if step.fadeOut then
|
||||||
|
-- GBFadeOutToWhite / ClearScreen (oak_speech.asm:93-94)
|
||||||
|
self.game.stack:push(require("src.render.Transition")
|
||||||
|
.whiteFlash(self.game, nil, function()
|
||||||
|
self:closeHoldBox()
|
||||||
|
self:advance()
|
||||||
|
end))
|
||||||
|
else
|
||||||
|
self:advance()
|
||||||
|
end
|
||||||
|
end } })
|
||||||
|
self.holdBox = box
|
||||||
|
self.game.stack:push(box)
|
||||||
|
else
|
||||||
self:sayText(self:stepText(step), function() self:advance() end)
|
self:sayText(self:stepText(step), function() self:advance() end)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
elseif kind == "demo" then
|
elseif kind == "demo" then
|
||||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||||
@@ -394,20 +449,36 @@ function OakSpeech:runStep(step)
|
|||||||
local presets = step.presets
|
local presets = step.presets
|
||||||
or namePresets(self.game, step.presetsWho or who,
|
or namePresets(self.game, step.presetsWho or who,
|
||||||
step.presetsFallback or { "RED" })
|
step.presetsFallback or { "RED" })
|
||||||
|
local function openNaming()
|
||||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||||
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
||||||
presets = presets,
|
presets = presets,
|
||||||
|
introBox = true,
|
||||||
maxLen = step.maxLen or self.nameLen,
|
maxLen = step.maxLen or self.nameLen,
|
||||||
onDone = function(name)
|
onDone = function(name, custom)
|
||||||
if who == "rival" then
|
if who == "rival" then
|
||||||
self.game.save.player.rival = name
|
self.game.save.player.rival = name
|
||||||
else
|
else
|
||||||
self.game.save.player.name = name
|
self.game.save.player.name = name
|
||||||
end
|
end
|
||||||
self:recordAnswer(step, 1, name, name)
|
self:recordAnswer(step, 1, name, name)
|
||||||
|
-- YourNameIsText / HisNameIsText print into the box this one
|
||||||
|
-- held (oak_speech2.asm:26-28, :59-61)
|
||||||
|
self:closeHoldBox()
|
||||||
|
if custom then
|
||||||
|
-- .customName: ClearScreen / Delay3 / pic recentered, no
|
||||||
|
-- slide-back (oak_speech2.asm:21-25)
|
||||||
|
self.picSlide = 0
|
||||||
self:advance()
|
self:advance()
|
||||||
|
else
|
||||||
|
-- OakSpeechSlidePicLeft's 13-frame pre-slide beat
|
||||||
|
-- (oak_speech2.asm:69-78)
|
||||||
|
self:slidePic(-1, function() self:advance() end, 13)
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
end
|
||||||
|
self:slidePic(1, openNaming)
|
||||||
elseif kind == "choice" then
|
elseif kind == "choice" then
|
||||||
self:applyPic(step)
|
self:applyPic(step)
|
||||||
self:afterReveal(step, function()
|
self:afterReveal(step, function()
|
||||||
@@ -518,6 +589,22 @@ function OakSpeech:revealPic(kind, next)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67)
|
||||||
|
function OakSpeech:slidePic(dir, onDone, delay)
|
||||||
|
self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8)
|
||||||
|
self.game.stack:push(setmetatable({
|
||||||
|
game = self.game, speech = self, dir = dir, t = 0, onDone = onDone,
|
||||||
|
delay = delay,
|
||||||
|
}, PicSlide))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- IntroducePlayerText's text_end box (oak_speech.asm:90) is ours to close
|
||||||
|
function OakSpeech:closeHoldBox()
|
||||||
|
local box = self.holdBox
|
||||||
|
self.holdBox = nil
|
||||||
|
if box and self.game.stack:top() == box then self.game.stack:pop() end
|
||||||
|
end
|
||||||
|
|
||||||
function OakSpeech:advance()
|
function OakSpeech:advance()
|
||||||
self.step = self.step + 1
|
self.step = self.step + 1
|
||||||
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
||||||
@@ -615,7 +702,7 @@ function OakSpeech:draw()
|
|||||||
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
||||||
-- bottom-aligned
|
-- bottom-aligned
|
||||||
local w, h = self.pic:getDimensions()
|
local w, h = self.pic:getDimensions()
|
||||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0)
|
||||||
local y = 32 + (7 - h / 8) * 8
|
local y = 32 + (7 - h / 8) * 8
|
||||||
local reveal = self.picReveal
|
local reveal = self.picReveal
|
||||||
local off = 0
|
local off = 0
|
||||||
|
|||||||
+3
-10
@@ -381,14 +381,7 @@ local function buildRows(game)
|
|||||||
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
||||||
end,
|
end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local o = g.save.options
|
Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale())
|
||||||
local S = Renderer:fitScale()
|
|
||||||
local lo, hi = Zoom.offsetRange(S)
|
|
||||||
local off = (o.zoom or 0) + dir
|
|
||||||
if off > hi then off = lo
|
|
||||||
elseif off < lo then off = hi end
|
|
||||||
o.zoom = off
|
|
||||||
Zoom.offset = off
|
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "voidFill", label = Strings("VOID FILL"),
|
{ id = "voidFill", label = Strings("VOID FILL"),
|
||||||
@@ -580,8 +573,8 @@ local function buildRows(game)
|
|||||||
end
|
end
|
||||||
rows = filtered
|
rows = filtered
|
||||||
end
|
end
|
||||||
-- ORIENTATION only on Android, the one platform Orientation.apply reaches.
|
-- ORIENTATION only on the platforms Orientation.apply reaches (#1638).
|
||||||
if not Orientation.isAndroid() then
|
if not (Orientation.isAndroid() or Orientation.isIOS()) then
|
||||||
local filtered = {}
|
local filtered = {}
|
||||||
for _, row in ipairs(rows) do
|
for _, row in ipairs(rows) do
|
||||||
if row.id ~= "orientation" then filtered[#filtered + 1] = row end
|
if row.id ~= "orientation" then filtered[#filtered + 1] = row end
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ end
|
|||||||
|
|
||||||
function PadCursor.joystickpressed(joystick, button)
|
function PadCursor.joystickpressed(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return nil end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return nil end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return nil end
|
||||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||||
if padButton then return PadCursor.gamepadpressed(joystick, padButton) end
|
if padButton then return PadCursor.gamepadpressed(joystick, padButton) end
|
||||||
return nil
|
return nil
|
||||||
@@ -158,12 +159,14 @@ end
|
|||||||
|
|
||||||
function PadCursor.joystickreleased(joystick, button)
|
function PadCursor.joystickreleased(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||||
if padButton then PadCursor.gamepadreleased(joystick, padButton) end
|
if padButton then PadCursor.gamepadreleased(joystick, padButton) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function PadCursor.joystickaxis(joystick, axisIndex, value)
|
function PadCursor.joystickaxis(joystick, axisIndex, value)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
if axisIndex == 1 then
|
if axisIndex == 1 then
|
||||||
PadCursor.gamepadaxis(joystick, "leftx", value)
|
PadCursor.gamepadaxis(joystick, "leftx", value)
|
||||||
elseif axisIndex == 2 then
|
elseif axisIndex == 2 then
|
||||||
@@ -173,6 +176,7 @@ end
|
|||||||
|
|
||||||
function PadCursor.joystickhat(joystick, hat, direction)
|
function PadCursor.joystickhat(joystick, hat, direction)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
if GamepadMap.isAccelerometer(joystick) then return end
|
||||||
for _, d in ipairs(rawHatDirs[hat] or {}) do
|
for _, d in ipairs(rawHatDirs[hat] or {}) do
|
||||||
dir[d] = nil
|
dir[d] = nil
|
||||||
end
|
end
|
||||||
|
|||||||
+5
-2
@@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu")
|
|||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local ShopMenu = {}
|
local ShopMenu = {}
|
||||||
|
|
||||||
@@ -57,7 +58,8 @@ local function buy(game, stock)
|
|||||||
end
|
end
|
||||||
local cost = qty * def.price
|
local cost = qty * def.price
|
||||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
-- _PokemartTellBuyPriceText + yes/no confirm
|
||||||
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
list.footer = romText(game.data, "_PokemartTellBuyPriceText",
|
||||||
|
"%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
@@ -147,7 +149,8 @@ local function sell(game)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- _PokemartTellSellPriceText + yes/no confirm
|
-- _PokemartTellSellPriceText + yes/no confirm
|
||||||
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty)
|
list.footer = romText(game.data, "_PokemartTellSellPriceText",
|
||||||
|
"I can pay you\n¥%d for that.", unit * qty)
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
|
|||||||
+838
-50
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local SlotMachine = {}
|
local SlotMachine = {}
|
||||||
SlotMachine.__index = SlotMachine
|
SlotMachine.__index = SlotMachine
|
||||||
@@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win)
|
|||||||
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
||||||
-- shows it in the box while the screen flashes. LinedUpText follows.
|
-- shows it in the box while the screen flashes. LinedUpText follows.
|
||||||
self.yeah = (sym == "7")
|
self.yeah = (sym == "7")
|
||||||
self.message = Strings("%s lined up!\nScored %d coins!", sym, pay)
|
self.message = sym .. romText(self.game.data, "_LinedUpText",
|
||||||
|
" lined up!\nScored %d coins!", pay)
|
||||||
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
||||||
-- credited until the player dismisses the "lined up" text (see startPayout).
|
-- credited until the player dismisses the "lined up" text (see startPayout).
|
||||||
self.stage = "flash"
|
self.stage = "flash"
|
||||||
|
|||||||
+53
-9
@@ -11,6 +11,7 @@ local Renderer = require("src.render.Renderer")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Theme = require("src.ui.Theme")
|
||||||
|
|
||||||
local StartMenu = {}
|
local StartMenu = {}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ local function sameItems(_, items) return items end
|
|||||||
function StartMenu.new(game)
|
function StartMenu.new(game)
|
||||||
local flags = game.save.flags or {}
|
local flags = game.save.flags or {}
|
||||||
local items = {}
|
local items = {}
|
||||||
|
local menu
|
||||||
|
|
||||||
-- vanilla start submenus return here on B (RedisplayStartMenu): the
|
-- vanilla start submenus return here on B (RedisplayStartMenu): the
|
||||||
-- generic Menu pops the start menu when a row is selected, so each
|
-- generic Menu pops the start menu when a row is selected, so each
|
||||||
@@ -51,22 +53,62 @@ function StartMenu.new(game)
|
|||||||
end })
|
end })
|
||||||
|
|
||||||
-- SAVE shows the player/badges/dex/time panel then asks to confirm
|
-- SAVE shows the player/badges/dex/time panel then asks to confirm
|
||||||
-- (PrintSaveScreenText)
|
-- (PrintSaveScreenText); StartMenu_SaveReset never clears the START menu
|
||||||
table.insert(items, { label = Strings("SAVE"), onSelect = function()
|
-- box, so it stays on screen beside the panel (start_sub_menus.asm:641-647)
|
||||||
|
table.insert(items, { label = Strings("SAVE"), keepOpen = true,
|
||||||
|
onSelect = function()
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local badges = require("src.inventory.Badges").count(game.data, game.save)
|
local badges = require("src.inventory.Badges").count(game.data, game.save)
|
||||||
local owned = 0
|
local owned = 0
|
||||||
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
|
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
|
||||||
owned = owned + 1
|
owned = owned + 1
|
||||||
end
|
end
|
||||||
|
-- the panel is a static snapshot; the cart prints it once
|
||||||
|
-- (main_menu.asm:390-401)
|
||||||
local t = math.floor(game.save.playTime or 0)
|
local t = math.floor(game.save.playTime or 0)
|
||||||
local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
|
-- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and
|
||||||
game.save.player.name or "RED", badges, owned,
|
-- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405
|
||||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
local panel
|
||||||
|
panel = {
|
||||||
|
-- the panel overlaps the kept-open START menu box (start_sub_menus.asm:
|
||||||
|
-- 641-647), so neither can be docked to a screen edge on its own
|
||||||
|
holdsUIAnchors = true,
|
||||||
|
delay = 0,
|
||||||
|
update = function()
|
||||||
|
-- ld c, 30 / jp DelayFrames: the bare panel holds before the
|
||||||
|
-- prompt (main_menu.asm:404-405)
|
||||||
|
panel.delay = panel.delay + 1
|
||||||
|
if panel.delay == 30 then panel.openPrompt() end
|
||||||
|
end,
|
||||||
|
draw = function()
|
||||||
|
Font.drawBox(4, 0, 16, 10)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8)
|
||||||
|
Font.draw(game.save.player.name or "RED", 12 * 8, 2 * 8)
|
||||||
|
Font.draw(Strings("BADGES"), 5 * 8, 4 * 8)
|
||||||
|
Font.draw(("%2d"):format(badges), 17 * 8, 4 * 8)
|
||||||
|
Font.draw(Strings("POKéDEX"), 5 * 8, 6 * 8)
|
||||||
|
Font.draw(("%3d"):format(owned), 16 * 8, 6 * 8)
|
||||||
|
Font.draw(Strings("TIME"), 5 * 8, 8 * 8)
|
||||||
|
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
|
||||||
|
math.floor(t / 60) % 60), 13 * 8, 8 * 8)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local function closePanel()
|
||||||
|
if game.stack:top() == panel then game.stack:pop() end
|
||||||
|
-- SaveMenu returns into HoldTextDisplayOpen, not RedisplayStartMenu
|
||||||
|
-- (start_sub_menus.asm:645-647): the kept-open START menu goes too
|
||||||
|
if menu and game.stack:top() == menu then game.stack:pop() end
|
||||||
|
end
|
||||||
|
panel.openPrompt = function()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
|
Strings("Would you like to\nSAVE the game?"), nil, {
|
||||||
|
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather
|
||||||
|
-- than the shared right-hand one -- engine/menus/save.asm:186-192
|
||||||
|
choiceBox = Theme.saveBox,
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if not yes then return end
|
if not yes then closePanel() return end
|
||||||
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
|
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
|
||||||
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
|
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
|
||||||
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
|
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
|
||||||
@@ -78,7 +120,7 @@ function StartMenu.new(game)
|
|||||||
game:writeSave()
|
game:writeSave()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
|
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
|
||||||
nil, { auto = {
|
closePanel, { auto = {
|
||||||
sound = function()
|
sound = function()
|
||||||
return require("src.core.Sound").play(game.data, "Save")
|
return require("src.core.Sound").play(game.data, "Save")
|
||||||
end,
|
end,
|
||||||
@@ -87,6 +129,8 @@ function StartMenu.new(game)
|
|||||||
end, { auto = { delay = 120 } }))
|
end, { auto = { delay = 120 } }))
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
|
end
|
||||||
|
game.stack:push(panel)
|
||||||
end })
|
end })
|
||||||
|
|
||||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||||
@@ -142,7 +186,7 @@ function StartMenu.new(game)
|
|||||||
-- with Menu's moreArrow showing while there's more below.
|
-- with Menu's moreArrow showing while there's more below.
|
||||||
local rowStep = 2
|
local rowStep = 2
|
||||||
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
||||||
local menu = Menu.new(game, items,
|
menu = Menu.new(game, items,
|
||||||
-- the START menu hugs the top-right corner of the SCREEN, not of a
|
-- the START menu hugs the top-right corner of the SCREEN, not of a
|
||||||
-- centred letterbox: at 9,0 x 11 it is already flush with the top and
|
-- centred letterbox: at 9,0 x 11 it is already flush with the top and
|
||||||
-- right of the 20x18 grid, so the anchor keeps it flush when the view
|
-- right of the 20x18 grid, so the anchor keeps it flush when the view
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ local Theme = {
|
|||||||
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
|
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
|
||||||
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
|
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
|
||||||
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||||
|
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 too --
|
||||||
|
-- engine/menus/save.asm:186-192
|
||||||
|
saveBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function Theme.load(data)
|
function Theme.load(data)
|
||||||
|
|||||||
+107
-18
@@ -43,6 +43,28 @@ local function withWhiteOf(pal, ref)
|
|||||||
return { ref[1], pal[2], pal[3], pal[4] }
|
return { ref[1], pal[2], pal[3], pal[4] }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Every drawn box, not just the topmost state's: DisplayContinueGameInfo
|
||||||
|
-- leaves the menu box up behind the info window (main_menu.asm:36-39), so both
|
||||||
|
-- are on screen and both need the overlay below.
|
||||||
|
local function titleUiBoxes(game)
|
||||||
|
local stack = game and game.stack
|
||||||
|
local states = stack and stack.states
|
||||||
|
if not states then
|
||||||
|
local top = stack and stack.top and stack:top()
|
||||||
|
local box = top and top.titleUiBox
|
||||||
|
return box and { box } or {}
|
||||||
|
end
|
||||||
|
local boxes = {}
|
||||||
|
for i = (stack.visibleBase and stack:visibleBase() or 1), #states do
|
||||||
|
local state = states[i]
|
||||||
|
local shown = not stack.renderVisible or stack:renderVisible(state)
|
||||||
|
if shown and state and state.titleUiBox then
|
||||||
|
boxes[#boxes + 1] = state.titleUiBox
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return boxes
|
||||||
|
end
|
||||||
|
|
||||||
function TitleState:sgbPalettes(game)
|
function TitleState:sgbPalettes(game)
|
||||||
local P = require("src.render.PaletteFX")
|
local P = require("src.render.PaletteFX")
|
||||||
local z
|
local z
|
||||||
@@ -65,9 +87,6 @@ function TitleState:sgbPalettes(game)
|
|||||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
local top = game.stack and game.stack:top()
|
|
||||||
local box = top and top.titleUiBox
|
|
||||||
if box then
|
|
||||||
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
|
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
|
||||||
-- shader GRAYS is the identity for the box's four shades, so SGB /
|
-- shader GRAYS is the identity for the box's four shades, so SGB /
|
||||||
-- ADVANCED / OG modes keep #133's white paper and black ink exactly,
|
-- ADVANCED / OG modes keep #133's white paper and black ink exactly,
|
||||||
@@ -75,6 +94,7 @@ function TitleState:sgbPalettes(game)
|
|||||||
-- modes -- a trueColor rect skipped the shader entirely, leaving the
|
-- modes -- a trueColor rect skipped the shader entirely, leaving the
|
||||||
-- main menu and CONTINUE info box a raw white hole over a CLASSIC
|
-- main menu and CONTINUE info box a raw white hole over a CLASSIC
|
||||||
-- pea-green title instead of matching it like the START menu does (#870).
|
-- pea-green title instead of matching it like the START menu does (#870).
|
||||||
|
for _, box in ipairs(titleUiBoxes(game)) do
|
||||||
z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4])
|
z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4])
|
||||||
end
|
end
|
||||||
return z[3] and z or nil
|
return z[3] and z or nil
|
||||||
@@ -175,20 +195,21 @@ end
|
|||||||
local function replayObjSprite(game, image, quad, x, y)
|
local function replayObjSprite(game, image, quad, x, y)
|
||||||
local P = require("src.render.PaletteFX")
|
local P = require("src.render.PaletteFX")
|
||||||
if not P.usesSpriteObp() then return end
|
if not P.usesSpriteObp() then return end
|
||||||
local top = game.stack and game.stack:top()
|
local boxes = titleUiBoxes(game)
|
||||||
local box = top and top.titleUiBox
|
if boxes[1] then
|
||||||
if box then
|
|
||||||
local w, h
|
local w, h
|
||||||
if quad then
|
if quad then
|
||||||
w, h = select(3, quad:getViewport())
|
w, h = select(3, quad:getViewport())
|
||||||
else
|
else
|
||||||
w, h = image:getDimensions()
|
w, h = image:getDimensions()
|
||||||
end
|
end
|
||||||
|
for _, box in ipairs(boxes) do
|
||||||
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
|
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
|
||||||
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
|
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
P.markUiSpriteRedraw(image, quad, x, y)
|
P.markUiSpriteRedraw(image, quad, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -392,6 +413,17 @@ function TitleState:updateSequence()
|
|||||||
self.phase = "loop"
|
self.phase = "loop"
|
||||||
self.blinkTimer = 0
|
self.blinkTimer = 0
|
||||||
end
|
end
|
||||||
|
elseif self.phase == "exitCry" then
|
||||||
|
-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the
|
||||||
|
-- white-out (engine/movie/title.asm:241-243)
|
||||||
|
self.timer = self.timer + 1
|
||||||
|
local playing = self.exitCrySrc and self.exitCrySrc.isPlaying
|
||||||
|
and self.exitCrySrc:isPlaying()
|
||||||
|
if self.timer >= 3 and (not playing or self.timer > 180) then
|
||||||
|
self.exitCrySrc = nil
|
||||||
|
self.phase = "loop"
|
||||||
|
self:toMenu()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -460,8 +492,8 @@ function ContinueInfo:update(dt)
|
|||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.title.onContinue then self.title.onContinue() end
|
if self.title.onContinue then self.title.onContinue() end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
|
-- the CONTINUE / NEW GAME menu is still open underneath (main_menu.asm:91-92)
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
self.title:openMenu()
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -497,7 +529,10 @@ function TitleState:openMenu()
|
|||||||
local game = self.game
|
local game = self.game
|
||||||
local items = {}
|
local items = {}
|
||||||
if hasSave() then
|
if hasSave() then
|
||||||
table.insert(items, { label = Strings("CONTINUE"), onSelect = function()
|
-- DisplayContinueGameInfo leaves the menu box up behind the info window
|
||||||
|
-- (engine/menus/main_menu.asm:36-39, :91-92)
|
||||||
|
table.insert(items, { label = Strings("CONTINUE"), keepOpen = true,
|
||||||
|
onSelect = function()
|
||||||
-- peek at the save for the info window; fall through if the
|
-- peek at the save for the info window; fall through if the
|
||||||
-- file can't be read
|
-- file can't be read
|
||||||
local ok, loaded = pcall(require("src.core.SaveData").load)
|
local ok, loaded = pcall(require("src.core.SaveData").load)
|
||||||
@@ -511,7 +546,13 @@ function TitleState:openMenu()
|
|||||||
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
||||||
if self.onNewGame then self.onNewGame() end
|
if self.onNewGame then self.onNewGame() end
|
||||||
end })
|
end })
|
||||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
-- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box
|
||||||
|
-- (engine/menus/main_menu.asm ln 87-90)
|
||||||
|
local menu
|
||||||
|
table.insert(items, { label = Strings("OPTION"), keepOpen = true,
|
||||||
|
onSelect = function()
|
||||||
|
-- .mainMenuLoop re-zeroes wCurrentMenuItem on re-entry (main_menu.asm:56-57)
|
||||||
|
if menu then menu.index = 1 end
|
||||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||||
end })
|
end })
|
||||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||||
@@ -529,7 +570,14 @@ function TitleState:openMenu()
|
|||||||
type(hooked))
|
type(hooked))
|
||||||
end
|
end
|
||||||
local th = #items * 2 + 2
|
local th = #items * 2 + 2
|
||||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||||
|
-- .mainMenuLoop's B branch jumps back to DisplayTitleScreen, which opens
|
||||||
|
-- with GBPalWhiteOut and reruns the whole boot cinematic
|
||||||
|
-- (engine/menus/main_menu.asm:69-70, title.asm:29)
|
||||||
|
menu.onCancel = function()
|
||||||
|
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
|
||||||
|
function() self:restartSequence() end))
|
||||||
|
end
|
||||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
|
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
|
||||||
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
|
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
|
||||||
-- recolor zone follows the box's real width instead of the vanilla 13.
|
-- recolor zone follows the box's real width instead of the vanilla 13.
|
||||||
@@ -537,6 +585,38 @@ function TitleState:openMenu()
|
|||||||
game.stack:push(menu)
|
game.stack:push(menu)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- .mainMenuLoop's B branch: DisplayTitleScreen from the top
|
||||||
|
-- (main_menu.asm:70, title.asm:39-222)
|
||||||
|
function TitleState:restartSequence()
|
||||||
|
self.menuOpen = false
|
||||||
|
pcall(Music.stop)
|
||||||
|
self.scy = 0x40
|
||||||
|
self.phase = "drop"
|
||||||
|
self.dropStep, self.dropLeft = 1, nil
|
||||||
|
self.showBubble = not self.yellowLayout
|
||||||
|
self.timer = 0
|
||||||
|
self.blinkTimer = 0
|
||||||
|
self.blinkAt = nil
|
||||||
|
self.cycleIndex = 1
|
||||||
|
self.scrollPhase = "hold"
|
||||||
|
self.scrollFrame = 1
|
||||||
|
self.monOffset = 0
|
||||||
|
self.ballY = BALL_REST
|
||||||
|
self.ribbonOffset = nil
|
||||||
|
self.whooshSrc, self.crySrc, self.exitCrySrc = nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- .finishedWaiting: GBPalWhiteOutWithDelay3 then ClearScreen before MainMenu,
|
||||||
|
-- which clears again itself (engine/movie/title.asm ln 243, main_menu.asm ln 26)
|
||||||
|
function TitleState:toMenu()
|
||||||
|
local game = self.game
|
||||||
|
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
|
||||||
|
function()
|
||||||
|
self.menuOpen = true
|
||||||
|
self:openMenu()
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
-- ..(engine/movie/title.asm ln 271)
|
-- ..(engine/movie/title.asm ln 271)
|
||||||
function TitleState:pickNewMon()
|
function TitleState:pickNewMon()
|
||||||
if #self.cycleSpecies < 2 then return end
|
if #self.cycleSpecies < 2 then return end
|
||||||
@@ -589,6 +669,11 @@ function TitleState:updateCycle()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TitleState:update(dt)
|
function TitleState:update(dt)
|
||||||
|
-- an onSelect that handed control back without a new state (a failed
|
||||||
|
-- CONTINUE load, a mod row) re-runs DisplayTitleScreen (main_menu.asm:70)
|
||||||
|
if self.menuOpen and self.game.stack:top() == self then
|
||||||
|
self:restartSequence()
|
||||||
|
end
|
||||||
if self.phase ~= "loop" then
|
if self.phase ~= "loop" then
|
||||||
self:updateSequence()
|
self:updateSequence()
|
||||||
return
|
return
|
||||||
@@ -599,10 +684,10 @@ function TitleState:update(dt)
|
|||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
-- .go_to_main_menu voices PikachuCry11 on the way out
|
-- .go_to_main_menu voices PikachuCry11 on the way out
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
if not Sound.playPikaCry(self.game.data, 11) then
|
self.exitCrySrc = Sound.playPikaCry(self.game.data, 11)
|
||||||
Sound.playCry(self.game.data, "PIKACHU")
|
or Sound.playCry(self.game.data, "PIKACHU")
|
||||||
end
|
self.phase = "exitCry"
|
||||||
self:openMenu()
|
self.timer = 0
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -613,23 +698,27 @@ function TitleState:update(dt)
|
|||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
-- the title mon cries when you leave the title (.finishedWaiting);
|
-- the title mon cries when you leave the title (.finishedWaiting);
|
||||||
-- Yellow's fixed Pikachu title always cries Pikachu.
|
-- Yellow's fixed Pikachu title always cries Pikachu.
|
||||||
require("src.core.Sound").playCry(self.game.data,
|
self.exitCrySrc = require("src.core.Sound").playCry(self.game.data,
|
||||||
self.yellowLayout and "PIKACHU"
|
self.yellowLayout and "PIKACHU"
|
||||||
or self.cycleSpecies[self.cycleIndex])
|
or self.cycleSpecies[self.cycleIndex])
|
||||||
self:openMenu()
|
self.phase = "exitCry"
|
||||||
|
self.timer = 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ..(engine/movie/title.asm ln 28)
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
function TitleState:draw()
|
function TitleState:draw()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
-- MainMenu's own ClearScreen wipes the logo, mon and sprites before the
|
||||||
|
-- CONTINUE / NEW GAME border is drawn (engine/menus/main_menu.asm ln 26)
|
||||||
|
if self.menuOpen then return end
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local playerImage = self.player
|
local playerImage = self.player
|
||||||
if playerImage and PaletteFX.usesSpriteObp() then
|
if playerImage and PaletteFX.usesSpriteObp() then
|
||||||
playerImage = require("src.render.SpriteRenderer").obpImage(
|
playerImage = require("src.render.SpriteRenderer").obpImage(
|
||||||
self.playerPath, PaletteFX.ogObj())
|
self.playerPath, PaletteFX.ogObj())
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
||||||
local scrollY = -(self.scy or 0)
|
local scrollY = -(self.scy or 0)
|
||||||
-- ..(engine/movie/title.asm ln 28)
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
local preRibbon = not self.yellowLayout
|
local preRibbon = not self.yellowLayout
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user