mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 347838619b | |||
| 9307488dc1 |
@@ -214,8 +214,21 @@ jobs:
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
# Platform-independent update payload, built alongside the desktop
|
||||
# apps above (same game.love that gets fused into each of them).
|
||||
love_file=".bazinga/work/game.love"
|
||||
[ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; }
|
||||
cp "$love_file" "$outdir/gen1recomp-${v}.love"
|
||||
|
||||
ls -lh "$outdir"
|
||||
|
||||
# Checksums for every staged release asset (sums file itself is
|
||||
# written after this and named outside the gen1recomp-* glob, so it
|
||||
# never lists itself).
|
||||
(cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt)
|
||||
cat "$outdir/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -266,7 +279,9 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
|
||||
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
|
||||
--
|
||||
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
|
||||
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
|
||||
-- omitted here.
|
||||
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
|
||||
-- data/scripts/story4.lua.
|
||||
|
||||
return {
|
||||
COPYCATS_HOUSE_2F = {
|
||||
|
||||
+90
-45
@@ -59,52 +59,97 @@ end
|
||||
|
||||
return {
|
||||
talk = {
|
||||
-- Oak: accepts the parcel and hands over the Pokédex, then (once the
|
||||
-- player has beaten the Route 22 rival) hands over the real POKé
|
||||
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
|
||||
-- recall / dex-rating / "pokemon can fight" / "around the world"
|
||||
-- branches -- see docs/known-differences.md)
|
||||
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
|
||||
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
|
||||
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
|
||||
-- (DisplayDexRating) is still skipped.
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
||||
{ "jump_if_false", 16 }, -- 3
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
||||
{ "jump_if_true", 16 }, -- 5
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
||||
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
|
||||
-- so the table sprites leave with the gift (#106).
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
|
||||
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
|
||||
{ "jump", "end" }, -- 15
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
|
||||
{ "jump_if_false", 31 }, -- 17
|
||||
{ "check_item", "POKE_BALL" }, -- 18
|
||||
{ "jump_if_true", 29 }, -- 19
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
|
||||
{ "jump_if_false", 33 }, -- 21
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||
{ "jump_if_true", 29 }, -- 23
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 25
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
|
||||
{ "jump", "end" }, -- 28
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
|
||||
{ "jump", "end" }, -- 30
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
|
||||
{ "jump", "end" }, -- 32
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
|
||||
{ "face_player" },
|
||||
{ "check_item", "POKE_BALL" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||
{ "jump_if_true", "give_balls" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_true", "around_world" },
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||
{ "jump_if_false", "pre_lab_battle" },
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
|
||||
-- when the player stands below Oak (the usual desk talk).
|
||||
{ "place_npc", 1, 4, 7, "up" },
|
||||
{ "move_npc_to", 1, 4, 3 },
|
||||
{ "play_music", "Music_OaksLab" },
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakIHaveARequestText" },
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
|
||||
{ "face_object", 1, "right" },
|
||||
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "move_npc_to", 1, 4, 7 },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||
{ "play_music", "Music_OaksLab" },
|
||||
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
|
||||
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
|
||||
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
|
||||
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "raise_young" },
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "pre_lab_battle" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_true", "can_fight" },
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "can_fight" },
|
||||
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "around_world" },
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "give_balls" },
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "come_see" },
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
|
||||
+16
-9
@@ -878,16 +878,23 @@ M.HALL_OF_FAME = {
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
talk = {
|
||||
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
|
||||
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
|
||||
TEXT_CERULEANCITY_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "jump", 10 }, -- 9
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", "end" }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||
{ "walk_npc", 1, { "right", "down", "down", "down",
|
||||
"down", "down", "down" } }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||
{ "jump", "end" }, -- 12
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+60
-20
@@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone)
|
||||
ow:engageTrainer(nerd, onDone)
|
||||
end
|
||||
|
||||
local function mtMoonFossil(itemId, otherName)
|
||||
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then
|
||||
-- UP (MoveRight falls through into MoveUp); near-helix walks UP only.
|
||||
local function mtMoonNerdWalk(px, py, itemId)
|
||||
if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then
|
||||
return { "right", "up" }
|
||||
end
|
||||
if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then
|
||||
return { "up" }
|
||||
end
|
||||
return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" }
|
||||
end
|
||||
|
||||
-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd +
|
||||
-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the
|
||||
-- other, "All right. Then this is mine!", hide the other.
|
||||
local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
if game.save.flags.EVENT_GOT_A_FOSSIL then
|
||||
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
|
||||
local t = game.data.text
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
|
||||
if not superNerdBeaten(ow) then
|
||||
engageSuperNerd(game, ow, done)
|
||||
return
|
||||
end
|
||||
local name = game.data.items[itemId].name
|
||||
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then done() return end
|
||||
game.save.inventory[itemId] = 1
|
||||
game.save.flags.EVENT_GOT_A_FOSSIL = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
|
||||
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
|
||||
local want = (itemId == "DOME_FOSSIL")
|
||||
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
|
||||
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
|
||||
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
|
||||
if not yes then done() return end
|
||||
if not require("src.inventory.Bag").add(game.save, itemId, 1) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s got the\n%s!"):format(game.save.player.name, name), done))
|
||||
end))
|
||||
end))
|
||||
t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
|
||||
done))
|
||||
return
|
||||
end
|
||||
local idef = game.data.items[itemId]
|
||||
game.stringBuffer = idef and idef.name or itemId
|
||||
require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._MtMoonB2FReceivedFossilText
|
||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||
function()
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object(
|
||||
{ save = game.save, overworld = ow, game = game },
|
||||
"MT_MOON_B2F", npc.def.name)
|
||||
flags[gotFlag] = true
|
||||
ow.runner:run({
|
||||
{ "walk_npc", 1, dirs },
|
||||
{ "text_opts", { auto = true } },
|
||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||
}, { onDone = done })
|
||||
end))
|
||||
end }))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -486,8 +520,10 @@ M.MT_MOON_B2F = {
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
|
||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
|
||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
|
||||
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
|
||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
|
||||
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -822,6 +858,10 @@ M.DAYCARE = {
|
||||
if def then
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = mon.stats.hp
|
||||
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
Pokemon.learnMovesFromDayCare(
|
||||
game.data, mon, def, startLevel, newLevel)
|
||||
end
|
||||
table.insert(game.save.party, mon)
|
||||
game.save.daycare = nil
|
||||
|
||||
+18
-17
@@ -212,30 +212,31 @@ M.COPYCATS_HOUSE_2F = {
|
||||
talk = {
|
||||
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if game.save.flags.EVENT_GOT_TM31 then
|
||||
push(game, t._CopycatsHouse2FCopycatWhirlingText
|
||||
or "Huh? Huh? Huh?", done)
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
|
||||
return
|
||||
end
|
||||
if (game.save.inventory.POKE_DOLL or 0) > 0 then
|
||||
ask(game, t._CopycatsHouse2FCopycatPokeDollText
|
||||
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
|
||||
if not yes then done() return end
|
||||
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1
|
||||
if game.save.inventory.POKE_DOLL == 0 then
|
||||
game.save.inventory.POKE_DOLL = nil
|
||||
end
|
||||
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then
|
||||
push(game, "You don't have\nroom for TM31!", done)
|
||||
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
|
||||
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
|
||||
if (game.save.inventory.POKE_DOLL or 0) <= 0 then
|
||||
done()
|
||||
return
|
||||
end
|
||||
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
|
||||
if not Bag.add(game.save, "TM_MIMIC", 1) then
|
||||
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
|
||||
return
|
||||
end
|
||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||
game.save.flags.EVENT_GOT_TM31 = true
|
||||
push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
|
||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
push(game, t._CopycatsHouse2FCopycatText
|
||||
or "I like to mimic\npeople!", done)
|
||||
end)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
+38
-15
@@ -457,6 +457,19 @@ end
|
||||
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
|
||||
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
||||
-- surrounding story flags so old saves work too.
|
||||
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
|
||||
local function route22ExitDirs(n, py)
|
||||
if n == 2 then
|
||||
if py == 5 then return { "left", "left", "left", "left" } end
|
||||
return { "left", "left", "left" }
|
||||
end
|
||||
if py == 5 then
|
||||
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
return { "up", "right", "right", "right",
|
||||
"down", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
||||
return {
|
||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||
@@ -468,7 +481,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
{ "set_flag", beatFlag }, -- 7
|
||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||
{ "move_npc_to", objIndex, 25, 5 }, -- 10
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||
}
|
||||
end
|
||||
@@ -495,6 +508,14 @@ M.ROUTE_22 = {
|
||||
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
|
||||
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
|
||||
-- cave guard who steps aside once you are Champion.
|
||||
-- Post-battle: Bill text then CeruleanCityMovement3/4 into town.
|
||||
local function ceruleanRivalExitDirs(px)
|
||||
if px == 20 then
|
||||
return { "right", "down", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
return { "left", "down", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
local function ceruleanRivalScene(px, py)
|
||||
return {
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
|
||||
@@ -502,41 +523,43 @@ local function ceruleanRivalScene(px, py)
|
||||
{ "face_object", 1, "down" }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "move_npc_to", 1, 20, 2 }, -- 9
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||
}
|
||||
end
|
||||
|
||||
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
||||
-- then he returns TM28 (DIG) and hurries off
|
||||
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
||||
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
|
||||
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
{ "jump_if_true", 15 }, -- 3
|
||||
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", 18 }, -- 8
|
||||
{ "jump_if_false", "end" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
|
||||
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
|
||||
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
|
||||
-- disappears. GUARD2 stands on the only walkable neighbour of the
|
||||
-- trashed house's south door, which is one of the two ways through the
|
||||
-- fence dividing Cerulean, so this swap is what reconnects the city.
|
||||
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
|
||||
-- both are idempotent, so whichever the player reaches first opens it.
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour — the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
|
||||
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
|
||||
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
|
||||
--
|
||||
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
||||
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won — checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -31,28 +37,76 @@ return {
|
||||
hide = {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
},
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
"_PewterGymBrockWaitTakeThisText",
|
||||
"_PewterGymReceivedTM34Text",
|
||||
"_TM34ExplanationText",
|
||||
} },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
"_CeruleanGymMistyCascadeBadgeInfoText",
|
||||
"_CeruleanGymMistyReceivedTM11Text",
|
||||
} },
|
||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||
item = "TM_THUNDERBOLT",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
"_VermilionGymLTSurgeThunderBadgeInfoText",
|
||||
"_VermilionGymLTSurgeReceivedTM24Text",
|
||||
"_TM24ExplanationText",
|
||||
} },
|
||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||
item = "TM_MEGA_DRAIN",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
"_CeladonGymRainbowBadgeInfoText",
|
||||
"_CeladonGymReceivedTM21Text",
|
||||
"_TM21ExplanationText",
|
||||
} },
|
||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||
item = "TM_TOXIC",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
"_FuchsiaGymKogaSoulBadgeInfoText",
|
||||
"_FuchsiaGymKogaReceivedTM06Text",
|
||||
"_FuchsiaGymKogaTM06ExplanationText",
|
||||
} },
|
||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||
item = "TM_PSYWAVE",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
"_SaffronGymSabrinaMarshBadgeInfoText",
|
||||
"_SaffronGymSabrinaReceivedTM46Text",
|
||||
"_TM46ExplanationText",
|
||||
} },
|
||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||
item = "TM_FIRE_BLAST",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
"_CinnabarGymBlaineVolcanoBadgeInfoText",
|
||||
"_CinnabarGymBlaineReceivedTM38Text",
|
||||
"_CinnabarGymBlaineTM38ExplanationText",
|
||||
} },
|
||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||
item = "TM_FISSURE",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
"_ViridianGymGiovanniEarthBadgeInfoText",
|
||||
"_ViridianGymGiovanniReceivedTM27Text",
|
||||
"_ViridianGymGiovanniTM27ExplanationText",
|
||||
} },
|
||||
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Launcher
|
||||
|
||||
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
|
||||
that runs before `Game:load`. Besides ROM import (see the file's own header)
|
||||
it hosts a tabbed shell covering per-game save slots and a mod manager. This
|
||||
file documents the runtime model; the visual spec lives separately.
|
||||
|
||||
## Android multi-ROM import
|
||||
|
||||
On Android, `love.system.pickFile()` opens the Storage Access Framework
|
||||
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
|
||||
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
|
||||
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
|
||||
to a version that is **not** yet ready counts as pending. A leftover
|
||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||
After a successful import the consumed save-dir `.gb` is removed.
|
||||
|
||||
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
|
||||
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
||||
Blue becomes ready beside Red.
|
||||
|
||||
## Tab structure
|
||||
|
||||
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
|
||||
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
|
||||
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
|
||||
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
|
||||
|
||||
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
|
||||
Play button, and the SAVE SLOT card in a responsive two-column grid (see
|
||||
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
|
||||
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
|
||||
centered pill in a reserved band just above the footer, on every tab. That
|
||||
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
|
||||
|
||||
## Save slot model
|
||||
|
||||
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
|
||||
abstraction (`persistFs`) every other save/options call uses, so portable
|
||||
mode (an `io.*` filesystem used when `portable.txt` marks the install)
|
||||
keeps working unchanged.
|
||||
|
||||
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
|
||||
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
|
||||
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
|
||||
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
|
||||
`slot%d+`; `createSlot` allocates one past the highest existing number so a
|
||||
reused id can never collide with a lingering file.
|
||||
- **Registry.** The ordered slot list and which one is active persist in
|
||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||
Resolved once per version per process (`ensureVersionSlots`, cached in
|
||||
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
|
||||
legacy migration may create one; otherwise the flat legacy path is used
|
||||
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
|
||||
- **Legacy migration.** One-time per version, lazy on first
|
||||
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
|
||||
file exists and no `saves/<version>/` registry does, its main + `.bak` are
|
||||
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
|
||||
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
|
||||
originals removed and `slot1` registered as active. A copy that fails to
|
||||
verify leaves the originals in place; migration never loses data.
|
||||
|
||||
The launcher-facing API:
|
||||
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
|
||||
every registered slot. `name` is the save's player name, or `nil` for an
|
||||
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
|
||||
title screen's `ContinueInfo` shows) or `nil`. The pure part,
|
||||
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
|
||||
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
|
||||
it as active, and updates the process cache so the very next save/load
|
||||
lands there. The launcher calls this the moment a slot row is clicked
|
||||
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
|
||||
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
|
||||
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
||||
save file written**. An empty slot means the title screen offers NEW GAME
|
||||
only, which needs no further changes.
|
||||
|
||||
## Launcher mod manager
|
||||
|
||||
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
|
||||
before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
|
||||
`Loader:_discover` finds mods without running them. The real loader
|
||||
(`src/mods/Loader.lua`) still owns the actual load at boot.
|
||||
|
||||
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
|
||||
duplicate) and returns one row per mod:
|
||||
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
||||
enabled, matching the loader's own default).
|
||||
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||
and the validated manifests: `conflict` when enabling this mod collides
|
||||
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||
`love.*` calls, this half is table-driven by the test suite on its own.
|
||||
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
|
||||
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
||||
and the in-game `ManagerState` see the change on next boot. The mods panel
|
||||
calls this on every toggle and re-derives the list right away
|
||||
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||
shows without waiting for a reload.
|
||||
- `LauncherMods.installZip(path)` mounts the archive with
|
||||
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
|
||||
the zip root, or inside one top-level folder), validates its manifest, and
|
||||
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
|
||||
duplicate of an already-installed mod id, and accepts either an external
|
||||
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
|
||||
temp first (mount only reaches save-dir-relative paths), the same way
|
||||
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
||||
back, and every path unmounts and clears the staged temp file.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
|
||||
through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
||||
|
||||
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
|
||||
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
|
||||
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
|
||||
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
||||
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||
The meta stamp is re-stamped off `gen1_import` to the current numeric format
|
||||
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
|
||||
panel is refreshed with the new slot selected.
|
||||
- **Export save** is live only when the active slot actually holds a save
|
||||
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
|
||||
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
|
||||
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
|
||||
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||
placeholder) is showing it defaults to red, the always-present first game
|
||||
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
|
||||
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
|
||||
nothing to export, ROM not imported yet) surfaces as a red notice line on the
|
||||
card. Nothing raises and nothing silently no-ops.
|
||||
|
||||
`SaveFileIO` is love-free enough to unit-test through the same in-memory
|
||||
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
|
||||
|
||||
## Responsiveness
|
||||
|
||||
Every measurement derives from `love.graphics.getDimensions()` each frame
|
||||
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
|
||||
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
|
||||
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
|
||||
column, slot card below Play, when the window is too narrow for both
|
||||
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
|
||||
(wheel, or drag on touch/desktop) clamped to their own content extent,
|
||||
recomputed every draw. The tab bar labels only the active chip so it stays
|
||||
narrow-safe, and content caps out at `~1440 * s` wide, centered.
|
||||
+105
@@ -18,6 +18,111 @@ Regenerate the reference straight into a wiki checkout:
|
||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||
```
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
*drawing*: a pipeline is a display mode a mod owns, which may replace the
|
||||
overworld's world pass with geometry of its own and/or post-process the
|
||||
finished image. `mods/voxel_world` is the worked example — a 3D diorama
|
||||
overworld plus a tilt-shift miniature pass, in about 120 lines of glue over
|
||||
its renderer.
|
||||
|
||||
A record declares what the mode *is*; the engine
|
||||
(`src/render/Pipelines.lua`) supplies everything about *being a display
|
||||
mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey,
|
||||
persistence in `save.options.pipelines`, and the rule that a world pipeline
|
||||
and the engine's own TILT are mutually exclusive.
|
||||
|
||||
```lua
|
||||
mod.content.render_pipelines:register("diorama", {
|
||||
label = "DIORAMA", -- options row label
|
||||
levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON
|
||||
hotkey = "6", -- checked after the engine's keys
|
||||
priority = 20, -- highest eligible wins the world
|
||||
available = function() return Renderer3D.ok() end,
|
||||
update = function(dt, level) Camera.ease(dt, level) end,
|
||||
drawWorld = function(ctx) return renderScene(ctx) end,
|
||||
})
|
||||
```
|
||||
|
||||
Three draw stages, each optional; a record needs at least one:
|
||||
|
||||
| stage | signature | runs |
|
||||
| --- | --- | --- |
|
||||
| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass |
|
||||
| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites |
|
||||
| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike |
|
||||
|
||||
`worldPresent` is the one to reach for when an effect must leave dialog
|
||||
boxes and menus crisp — a depth-of-field or colour grade on the world only.
|
||||
`present` is for effects that genuinely own the screen, like a CRT curve.
|
||||
|
||||
`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view),
|
||||
`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and
|
||||
`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it
|
||||
with your own projection and the engine draws every active field effect
|
||||
(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing
|
||||
rod, Rock Tunnel darkness) at its correct anchor under your camera. There
|
||||
is exactly one copy of each effect, so a new engine effect works in your
|
||||
pipeline without you touching anything.
|
||||
|
||||
Three rules worth knowing:
|
||||
|
||||
- **`gate` governs input, never the draw.** It decides whether the player
|
||||
may *change* the mode (default: free-roam overworld only). A mode that
|
||||
stopped rendering during a warp would flash the flat 2D world every time
|
||||
the player walked through a door.
|
||||
- **`available` is re-read every frame** and is the only thing that decides
|
||||
whether the mode can render at all. Answer `false` on a headless run or a
|
||||
driver with no depth canvas and the engine silently keeps the vanilla 2D
|
||||
path — which is why shipping a pipeline enabled is safe.
|
||||
- **A callback that throws retires its pipeline**, attributed to your mod in
|
||||
the manager's error feed, and the frame falls back to 2D. A broken
|
||||
renderer costs the player a display mode, never the game.
|
||||
|
||||
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||
frame"; the engine draws the vanilla world instead.
|
||||
|
||||
## Battle sprite scaling
|
||||
|
||||
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||
the Game Boy did. A mod can override either, per species or per image.
|
||||
|
||||
Per species, on the `pokemon` record:
|
||||
|
||||
```lua
|
||||
-- MEW's back pic renders 1.5x; its front pic is untouched
|
||||
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
|
||||
```
|
||||
|
||||
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
|
||||
both take a number in `0.25 .. 4.0`.
|
||||
|
||||
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
|
||||
exactly as the data references it:
|
||||
|
||||
```lua
|
||||
mod.content.battle_sprite_scales:register("abra_back", {
|
||||
path = "assets/generated/battle/back/abrab.png",
|
||||
scale = 1.5,
|
||||
})
|
||||
```
|
||||
|
||||
An image-level entry beats the species scale for that one pic, and it is
|
||||
the only way to scale a pic that is not species-keyed — the player's
|
||||
trainer back sprite, held on screen until "Go!", is a bare image path.
|
||||
|
||||
The resolution order at draw time is **image-level → species-level →
|
||||
default** (1x front, 2x back).
|
||||
|
||||
- **The pic stays grounded at every scale.** The player pic keeps its feet
|
||||
flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge
|
||||
and horizontal centre pinned in its 7×7 slot. A larger pic grows upward
|
||||
and outward from that anchor, never off the shelf.
|
||||
- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon`
|
||||
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.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# Updater
|
||||
|
||||
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
|
||||
baked into the executable, but that bundled copy is only ever the *fallback*.
|
||||
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
|
||||
looks in the save directory's `updates/` folder for a downloaded
|
||||
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
|
||||
engine version and runnable on this shell. If one qualifies, it is mounted
|
||||
over `/` (so its files win over the fused source for every subsequent
|
||||
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
|
||||
run as if they had shipped in the executable. A dev/source checkout is never
|
||||
fused, so `Boot.run` no-ops there and the working tree always runs itself.
|
||||
|
||||
The pieces are deliberately layered so the risky part is small. `Boot.select`
|
||||
is a pure function (no `love.*` calls) that, given probed candidates and the
|
||||
bundled `engine`/`shell`, decides what to run and what stale payloads to
|
||||
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
|
||||
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
|
||||
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
|
||||
orchestrates the crash guard, enumeration, selection, and the mount +
|
||||
chainload, with full rollback on any failure. Checking for and fetching a
|
||||
new payload is a separate, slower path: `src/update/Check.lua` is a thin
|
||||
main-thread state machine the launcher screen polls, while the curl calls,
|
||||
JSON parsing, and sha256 verification run on a background `love.thread`
|
||||
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
|
||||
|
||||
## Version.lua fields
|
||||
|
||||
`src/core/Version.lua` carries three fields the updater reads directly (the
|
||||
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
|
||||
untouched):
|
||||
|
||||
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
|
||||
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
|
||||
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
|
||||
reports itself up to date (it never chases a release, and it never counts
|
||||
as a valid payload to chainload).
|
||||
- `shell` - the native-shell contract this build's fused executable
|
||||
implements.
|
||||
- `minShell` - the lowest shell contract required to *run* this payload.
|
||||
|
||||
Bump `minShell` only when a payload needs something the currently-shipped
|
||||
native shell cannot provide, for example a LOVE version bump, a new required
|
||||
system binary, or a change to `love.run` itself (see Known limitations
|
||||
below). An older shell refuses to chainload a payload whose `minShell`
|
||||
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
|
||||
rather than deleting it, in case a future shell upgrade can run it, and
|
||||
`Check`'s worker reports `needs_full` so the player is pointed at a full
|
||||
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
|
||||
that is exactly the case the updater exists to avoid a reinstall for.
|
||||
|
||||
## Release assets
|
||||
|
||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||
`-android.apk`) plus two assets the updater itself consumes:
|
||||
|
||||
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||
`Check.parseRelease`).
|
||||
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
|
||||
filenames) covering at least the `.love` payload. `Check.parseSums`
|
||||
tolerates a leading `*` binary marker and a `./` prefix but expects the
|
||||
filename otherwise to match the asset name exactly.
|
||||
|
||||
A release missing either asset is treated as "no in-place update available":
|
||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
Under the save directory (identity `pokemon-love2d`):
|
||||
|
||||
```
|
||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||
updates/pending.txt crash-guard marker
|
||||
```
|
||||
|
||||
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
|
||||
on both a successful handoff and a clean rollback. If it is still present the
|
||||
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
|
||||
named payload is distrusted: it and the marker are deleted before candidates
|
||||
are enumerated. Boot may still fall back to an older valid payload, or to the
|
||||
bundled game, in that case.
|
||||
|
||||
## Update flow
|
||||
|
||||
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
|
||||
and probe every `updates/*.love`, pick the highest engine that is
|
||||
strictly newer than the bundled one and whose `minShell` this shell
|
||||
satisfies, delete stale payloads, chainload the winner (or run the
|
||||
bundled game if none qualifies).
|
||||
2. **Check** (launcher screen): `Check.start()` kicks off an async check
|
||||
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()`
|
||||
reports `idle | checking | uptodate | available | downloading | ready |
|
||||
needs_full | error` plus the latest version and download progress.
|
||||
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||
worker to fetch the payload, polling the growing `.part` file for
|
||||
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||
the payload's sha256, and probes it with `Boot.probePayload` to gate its
|
||||
`minShell` against this shell's `shell`. A verified, runnable payload is
|
||||
renamed into place and reported as `ready`; anything else reports
|
||||
`error` or `needs_full` and leaves `updates/` clean.
|
||||
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
|
||||
mounts and runs it. There is no in-session hot-swap.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **`love.run` persists across handoff.** By the time `chainload` runs, the
|
||||
bundled `love.run` has already returned its stepper to LOVE; redefining the
|
||||
global `love.run` from the payload's `main.lua` does not affect the loop
|
||||
already driving the frame. A payload that must change `love.run` itself
|
||||
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||
than running with half its intended behavior.
|
||||
- **Android has no in-app download transport yet.** `check_worker.lua`
|
||||
shells out to curl for both the release check and the download; curl is
|
||||
absent on Android, so `Check` degrades to `status = "error"` there (the
|
||||
launcher UI hides on that status) and the player is directed to the
|
||||
releases page via `Check.releaseUrl()` instead.
|
||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||
`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
|
||||
checkout is always "the game" itself; updating it means pulling the repo.
|
||||
@@ -56,6 +56,12 @@ local function bootGame(version)
|
||||
end
|
||||
|
||||
function love.load(args)
|
||||
-- Self-updater boot shell: a fused build may mount and chainload a newer
|
||||
-- downloaded payload here. True means it took over, so we must stop. A
|
||||
-- dev / source checkout no-ops (see src/update/Boot.lua).
|
||||
local Boot = require("src.update.Boot")
|
||||
if Boot.run(args) then return end
|
||||
|
||||
local savePath
|
||||
for i, a in ipairs(args or {}) do
|
||||
if a == "--editor" then
|
||||
|
||||
+6
-3
@@ -43,9 +43,12 @@ The embedded `game.love` deliberately excludes `data/generated/`,
|
||||
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
||||
`tools/rom_manifest.json`.
|
||||
|
||||
The current importer has desktop file pickers only. A production Android
|
||||
release still needs a Storage Access Framework handoff that passes the chosen
|
||||
ROM to LÖVE; the APK packaging itself is data-free.
|
||||
ROM import on Android uses `love.system.pickFile()` →
|
||||
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
|
||||
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
|
||||
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
|
||||
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
|
||||
itself remains data-free (no embedded ROM or generated cache).
|
||||
|
||||
### SDK / NDK
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "BALANCE",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "TOOL",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "AUDIO",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"permissions": ["engine_internals"],
|
||||
"dependencies": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "QUEST",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"permissions": ["engine_internals"],
|
||||
"dependencies": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"entry": "main.lua",
|
||||
"profile": "total_conversion",
|
||||
"category": "TOTAL_CONVERSION",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 900,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GRAPHICS",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"assets_transforms": "transforms.lua",
|
||||
"dependencies": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"entry": "main.lua",
|
||||
"profile": "overhaul",
|
||||
"category": "MECHANIC",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
|
||||
@@ -68,6 +68,31 @@ if unzip -Z1 "$LOVE_FILE" \
|
||||
fi
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||
|
||||
# ------------------------------------------------------- stamp release version
|
||||
# The working tree ships Version.lua with engine "0.0.0-dev"; the real release
|
||||
# number only ever lives inside the packed archive. When --version is a strict
|
||||
# X.Y.Z, patch a copy of Version.lua (engine set to that number) under a staging
|
||||
# dir and replace the entry inside game.love in place -- never the source tree.
|
||||
# Short-hash / "dev" builds are left with the "-dev" default so they cannot be
|
||||
# mistaken for a release. The stamp is then read back out of the archive and the
|
||||
# build fails if it did not take.
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION into game.love"
|
||||
stamp_dir="$WORK/stamp"
|
||||
rm -rf "$stamp_dir"
|
||||
mkdir -p "$stamp_dir/src/core"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||
say "stamped engine version: $VERSION"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- macOS
|
||||
build_mac() {
|
||||
say "building macOS app"
|
||||
|
||||
@@ -145,6 +145,32 @@ pack_game_love() {
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
# stamps the release version the same way: patch a copy of Version.lua
|
||||
# (engine set to $VERSION) under a throwaway staging dir and replace the
|
||||
# entry inside the archive in place -- never the source tree. VERSION is
|
||||
# already validated as X.Y.Z above; when it is empty the packaged game keeps
|
||||
# the "0.0.0-dev" default. The stamp is read back out and the build fails if
|
||||
# it did not take.
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION into game.love"
|
||||
local stamp_dir
|
||||
stamp_dir="$(mktemp -d)"
|
||||
mkdir -p "$stamp_dir/src/core"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||
local version_re
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||
rm -rf "$stamp_dir"
|
||||
say "stamped engine version: $VERSION"
|
||||
else
|
||||
say "no X.Y.Z --version, shipping default engine (no stamp)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- SDK check
|
||||
|
||||
+330
-159
@@ -173,6 +173,14 @@ local function grayImage(img)
|
||||
return getImage(meta.path) or img
|
||||
end
|
||||
|
||||
-- the asset path a loaded battle image came from (nil for the headless
|
||||
-- stub images), so the battle_sprite_scales registry can be looked up by
|
||||
-- the same path data references
|
||||
local function imagePathOf(img)
|
||||
local m = imageMeta[img]
|
||||
return m and m.path
|
||||
end
|
||||
|
||||
-- the image a battler pic actually draws with this frame
|
||||
function BattleState:picImage(img)
|
||||
if self.grayPics then return grayImage(img) end
|
||||
@@ -267,6 +275,10 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
badgeBoosts = badgeBoosts,
|
||||
statuses = data.statuses,
|
||||
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
||||
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
||||
-- while the tilemap still shows the prior condition until the next
|
||||
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||
shownStatus = mon.status,
|
||||
stages = {},
|
||||
-- volatile state; Transform/Conversion/Mimic override the cur* fields
|
||||
curStats = mon.stats,
|
||||
@@ -548,6 +560,12 @@ function BattleState:say(text)
|
||||
table.insert(self.queue, { text = text })
|
||||
end
|
||||
|
||||
-- Message that opens YES/NO once typed out, keeping the text visible
|
||||
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
|
||||
function BattleState:sayChoice(text, onChoose)
|
||||
table.insert(self.queue, { text = text, choice = onChoose })
|
||||
end
|
||||
|
||||
function BattleState:act(fn)
|
||||
table.insert(self.queue, { fn = fn })
|
||||
end
|
||||
@@ -808,8 +826,24 @@ function BattleState:updateQueue()
|
||||
if self.charIndex < self.total then
|
||||
self.charIndex = math.min(self.total, self.charIndex + 2)
|
||||
else
|
||||
local item = self.current
|
||||
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
|
||||
-- overlays the still-visible "Will … change POKéMON?" page.
|
||||
if item and item.choice and not item.choiceOpen then
|
||||
item.choiceOpen = true
|
||||
self.waitingUI = true
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local battle = self
|
||||
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
|
||||
local fn = item.choice
|
||||
battle.current = nil
|
||||
fn(yes)
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
if not (item and item.choice)
|
||||
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.current = nil
|
||||
end
|
||||
end
|
||||
@@ -1060,11 +1094,14 @@ function BattleState:update(dt)
|
||||
self:updateFx()
|
||||
local input = self.game.input
|
||||
|
||||
-- safety net: HP changed outside a queued drain (level-up heals,
|
||||
-- field effects) snaps once the queue is idle
|
||||
-- safety net: HP/status changed outside a queued drain (level-up heals,
|
||||
-- field effects, bag cures) snaps once the queue is idle
|
||||
if self.phase == "menu" then
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and b.shownHP then b.shownHP = b.mon.hp end
|
||||
if b then
|
||||
if b.shownHP then b.shownHP = b.mon.hp end
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2145,90 +2182,105 @@ end
|
||||
-- move execution pipeline
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the
|
||||
-- current action's queued anim/text have played (PoisonEffect sets the
|
||||
-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw
|
||||
-- waits until after Execute*Move returns).
|
||||
function BattleState:syncShownStatus()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then b.shownStatus = b.mon.status end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState:executeAction(user, target, action)
|
||||
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
||||
if not action then return end
|
||||
|
||||
-- ghost battles: the ghost never attacks; its whole turn is the
|
||||
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
|
||||
if self.ghost and not user.isPlayer then
|
||||
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
|
||||
return
|
||||
end
|
||||
|
||||
-- refresh the held-in-place mirror before the status checks (see
|
||||
-- lockedAction): the victim is held exactly while the opponent's
|
||||
-- trapping bit is set -- including a counter sitting at 0 until the
|
||||
-- end-of-turn CheckNumAttacksLeft clear
|
||||
user.boundTurns = target.trappingTurns
|
||||
and math.max(1, target.trappingTurns) or nil
|
||||
|
||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||
if action.special == "aiItem" then
|
||||
self.aiUses = (self.aiUses or 1) - 1
|
||||
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
|
||||
self:sayNext(prefixEnemy(m, self.enemy))
|
||||
end
|
||||
self:drainNext()
|
||||
require("src.core.Sound").play(self.data, "Heal_Ailment")
|
||||
return
|
||||
end
|
||||
if action.special == "aiSwitch" then
|
||||
self.aiUses = (self.aiUses or 1) - 1
|
||||
local previous = self.enemy
|
||||
local oldName = self.enemy.name
|
||||
self.enemyIndex = action.index
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
previous = previous,
|
||||
})
|
||||
self.aiUses = self:aiUsesFor()
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
|
||||
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
|
||||
return
|
||||
end
|
||||
|
||||
-- special locked actions. All of them still run the status gauntlet:
|
||||
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
|
||||
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
|
||||
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
|
||||
if action.special == "recharge" then
|
||||
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
|
||||
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
|
||||
if self:preRechargeChecks(user, target) then return end
|
||||
user.mustRecharge = nil
|
||||
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
if action.special == "bound" then
|
||||
if not target.trappingTurns then
|
||||
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
|
||||
-- simply lost (ExecutePlayerMove returns immediately on $ff)
|
||||
local function run()
|
||||
-- ghost battles: the ghost never attacks; its whole turn is the
|
||||
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
|
||||
if self.ghost and not user.isPlayer then
|
||||
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
|
||||
return
|
||||
end
|
||||
-- sleep/freeze take precedence over the held-in-place message
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
return
|
||||
end
|
||||
if action.special == "trapping" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
self:continueTrapping(user, target)
|
||||
return
|
||||
end
|
||||
if action.special == "bide" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
self:continueBide(user, target)
|
||||
return
|
||||
end
|
||||
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
self:performMove(user, target, action, false)
|
||||
-- refresh the held-in-place mirror before the status checks (see
|
||||
-- lockedAction): the victim is held exactly while the opponent's
|
||||
-- trapping bit is set -- including a counter sitting at 0 until the
|
||||
-- end-of-turn CheckNumAttacksLeft clear
|
||||
user.boundTurns = target.trappingTurns
|
||||
and math.max(1, target.trappingTurns) or nil
|
||||
|
||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||
if action.special == "aiItem" then
|
||||
self.aiUses = (self.aiUses or 1) - 1
|
||||
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
|
||||
self:sayNext(prefixEnemy(m, self.enemy))
|
||||
end
|
||||
self:drainNext()
|
||||
require("src.core.Sound").play(self.data, "Heal_Ailment")
|
||||
return
|
||||
end
|
||||
if action.special == "aiSwitch" then
|
||||
self.aiUses = (self.aiUses or 1) - 1
|
||||
local previous = self.enemy
|
||||
local oldName = self.enemy.name
|
||||
self.enemyIndex = action.index
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
previous = previous,
|
||||
})
|
||||
self.aiUses = self:aiUsesFor()
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
|
||||
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
|
||||
return
|
||||
end
|
||||
|
||||
-- special locked actions. All of them still run the status gauntlet:
|
||||
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
|
||||
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
|
||||
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
|
||||
if action.special == "recharge" then
|
||||
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
|
||||
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
|
||||
if self:preRechargeChecks(user, target) then return end
|
||||
user.mustRecharge = nil
|
||||
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
if action.special == "bound" then
|
||||
if not target.trappingTurns then
|
||||
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
|
||||
-- simply lost (ExecutePlayerMove returns immediately on $ff)
|
||||
return
|
||||
end
|
||||
-- sleep/freeze take precedence over the held-in-place message
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
return
|
||||
end
|
||||
if action.special == "trapping" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
self:continueTrapping(user, target)
|
||||
return
|
||||
end
|
||||
if action.special == "bide" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
self:continueBide(user, target)
|
||||
return
|
||||
end
|
||||
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
self:performMove(user, target, action, false)
|
||||
end
|
||||
run()
|
||||
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
|
||||
self:actNext(function() self:syncShownStatus() end)
|
||||
end
|
||||
|
||||
-- Sleep / confusion onomatopoeia from Check*StatusConditions
|
||||
@@ -2724,51 +2776,37 @@ function BattleState:enemyMonFainted()
|
||||
end
|
||||
if nextIndex then
|
||||
self.enemyIndex = nextIndex
|
||||
-- SHIFT battle style (the default): announce the next mon and
|
||||
-- offer a free switch (SET skips the prompt)
|
||||
-- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
|
||||
-- switch when party count > 1, the active mon is alive, and the
|
||||
-- battle-style bit is clear. pokered counts party slots (not
|
||||
-- remaining HP); SET / single-mon / fainted active skip the prompt.
|
||||
local nextMon = self.enemyParty[self.enemyIndex]
|
||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||
local style = (self.game.save.options or {}).battleStyle or "shift"
|
||||
local healthy = 0
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
if mon.hp > 0 then healthy = healthy + 1 end
|
||||
end
|
||||
if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then
|
||||
self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName))
|
||||
self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name))
|
||||
local game = self.game
|
||||
self:ui(function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
return ChoiceBox.new(game, function(yes)
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local partyCount = #self.game.save.party
|
||||
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
||||
local shiftSwitchMon = nil
|
||||
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
|
||||
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
|
||||
self:say(("%s is\nabout to use"):format(self.trainer.name))
|
||||
self:say(("%s!"):format(nextName))
|
||||
self:sayChoice(
|
||||
("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
|
||||
function(yes)
|
||||
if not yes then return end
|
||||
local game = self.game
|
||||
Screens.push(game, "PartyMenu", {
|
||||
battle = self,
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, game.save)
|
||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
||||
end)
|
||||
shiftSwitchMon = mon
|
||||
end
|
||||
end,
|
||||
})
|
||||
end)
|
||||
end)
|
||||
end
|
||||
self:act(function()
|
||||
local previous = self.enemy
|
||||
@@ -2797,6 +2835,29 @@ function BattleState:enemyMonFainted()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
|
||||
self:act(function()
|
||||
local mon = shiftSwitchMon
|
||||
if not mon then return end
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
self:startGrowIn(self.player)
|
||||
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
||||
@@ -2920,6 +2981,8 @@ function BattleState:openReplacementMenu()
|
||||
self:ui(function()
|
||||
return self:buildScreen("PartyMenu", {
|
||||
battle = self,
|
||||
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon.hp <= 0 then
|
||||
self:say("There's no will\nto fight!")
|
||||
@@ -3130,6 +3193,9 @@ end
|
||||
|
||||
-- called by BagMenu after an item is used in battle (consumes the turn)
|
||||
function BattleState:itemUsed(messages)
|
||||
-- bag cures clear mon.status before the message UI; refresh the HUD
|
||||
-- once control returns (pokered DrawHUDsAndHPBars after item use)
|
||||
self:syncShownStatus()
|
||||
for _, m in ipairs(messages or {}) do self:say(m) end
|
||||
table.insert(self.queue, { drain = true }) -- potions animate the bar
|
||||
self:act(function()
|
||||
@@ -3151,10 +3217,39 @@ function BattleState:ballMissMessage(shakes)
|
||||
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
||||
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
|
||||
-- AddPartyMon and SendNewMonToBox (#172).
|
||||
function BattleState:askNicknameUI(mon, displayName)
|
||||
local game = self.game
|
||||
self.lockedBall = nil
|
||||
self.blankForAskName = true
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName)
|
||||
local label = game.data.text and game.data.text._DoYouWantToNicknameText
|
||||
if label then
|
||||
-- extractor CONT is \t; TextBox scrolls on \n/\v
|
||||
text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName)
|
||||
end
|
||||
return TextBox.new(game, text, nil, {
|
||||
choice = function(yes)
|
||||
self.blankForAskName = false
|
||||
if not yes then return end
|
||||
pcall(Screens.push, game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
onDone = function(name)
|
||||
if name and #name > 0 then mon.nickname = name end
|
||||
end,
|
||||
})
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- The caught mon joins the party or a PC box (ItemUseBall .captured,
|
||||
-- item_effects.asm:518-566): the caught text, then for a NEW species
|
||||
-- "New POKéDEX data will be added" + the dex entry page, then the
|
||||
-- party add (with the nickname ask) or the PC transfer text.
|
||||
-- "New POKéDEX data will be added" + the dex entry page, then
|
||||
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
||||
-- transfer text when the party was full.
|
||||
function BattleState:storeCaughtMon()
|
||||
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||
@@ -3174,31 +3269,20 @@ function BattleState:storeCaughtMon()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
end)
|
||||
end
|
||||
if Party.add(game.save.party, self.enemy.mon) then
|
||||
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
|
||||
-- never offered a nickname)
|
||||
local function askCaughtNickname()
|
||||
local caught = self.enemy.mon
|
||||
local enemyName = self.enemy.name
|
||||
self:uiNext(function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?")
|
||||
:format(enemyName), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then return end
|
||||
pcall(Screens.push, game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
onDone = function(name)
|
||||
if name and #name > 0 then caught.nickname = name end
|
||||
end,
|
||||
})
|
||||
end))
|
||||
end)
|
||||
return self:askNicknameUI(caught, enemyName)
|
||||
end)
|
||||
end
|
||||
if Party.add(game.save.party, self.enemy.mon) then
|
||||
askCaughtNickname()
|
||||
else
|
||||
destination = "box"
|
||||
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
||||
if boxNum then
|
||||
askCaughtNickname()
|
||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
||||
and "BILL's PC" or "someone's PC"
|
||||
@@ -3449,12 +3533,15 @@ function BattleState:fxFaintActive(battler)
|
||||
and fx.faint.frames > 0 or false
|
||||
end
|
||||
|
||||
-- vertical slide offset for a fainting battler (the player's pic is
|
||||
-- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
|
||||
function BattleState:fxFaintOffset(battler)
|
||||
-- vertical slide offset for a fainting battler. The offset is in screen
|
||||
-- pixels, so it scales with the pic's draw scale (the player's default 2x
|
||||
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes
|
||||
-- the same way. scale defaults to the vanilla side scale when unknown.
|
||||
function BattleState:fxFaintOffset(battler, scale)
|
||||
local fx = self.fx
|
||||
if self:fxFaintActive(battler) then
|
||||
return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1)
|
||||
scale = scale or (battler.isPlayer and 2 or 1)
|
||||
return (30 - fx.faint.frames) * 2 * scale
|
||||
end
|
||||
return 0
|
||||
end
|
||||
@@ -3550,7 +3637,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
||||
return
|
||||
end
|
||||
if self:fxFaintActive(battler) then
|
||||
local off = self:fxFaintOffset(battler)
|
||||
local off = self:fxFaintOffset(battler, scale)
|
||||
local visible = img:getHeight() - math.floor(off / scale)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
|
||||
@@ -3847,6 +3934,63 @@ function BattleState:drawAnimLayer(colorized)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and
|
||||
-- the player back pic at 2x on the GB; a mod can override either per
|
||||
-- species (pokemon.battleScaleFront / battleScaleBack) or per image path
|
||||
-- (the battle_sprite_scales registry, which is the only handle on the
|
||||
-- non-species pics like the trainer back). These resolvers and the
|
||||
-- placement math are pure (no love.*) so the grounding contract -- feet
|
||||
-- pinned at any scale -- is unit-tested directly.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- the vanilla scale for a side: enemy front 1x, player back 2x
|
||||
BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 }
|
||||
|
||||
-- image-level override for an asset path, or nil. scales is the merged
|
||||
-- data.battle_sprite_scales table (record id -> { path, scale }).
|
||||
function BattleState.imageBattleScale(scales, path)
|
||||
if not scales or not path then return nil end
|
||||
for id, rec in pairs(scales) do
|
||||
if id ~= "_owners" and type(rec) == "table" and rec.path == path then
|
||||
return rec.scale
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- effective battle scale for a pic: image-level override, else the
|
||||
-- species-level override for the side, else the side default. side is
|
||||
-- "front" (enemy) or "back" (player); species may be nil (a non-species
|
||||
-- pic like the trainer back, which only image-level scaling reaches).
|
||||
function BattleState.resolveBattleScale(data, side, path, species)
|
||||
local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path)
|
||||
if img then return img end
|
||||
local def = species and data and data.pokemon and data.pokemon[species]
|
||||
local field = side == "back" and "battleScaleBack" or "battleScaleFront"
|
||||
local override = def and def[field]
|
||||
if override then return override end
|
||||
return BattleState.BATTLE_SCALE_DEFAULT[side] or 1
|
||||
end
|
||||
|
||||
-- Player (back) placement: feet flush on the text-box top (y=96) at any
|
||||
-- scale, with the left transparent columns pulled back so opaque pixels
|
||||
-- land where hardware's white-on-white columns left them. Returns the
|
||||
-- top-left x, y and the scale (slide/shake offsets are added by the
|
||||
-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96.
|
||||
function BattleState.backPlacement(w, h, pad, padL, scale)
|
||||
return 8 - padL * scale, 96 - (h - pad) * scale, scale
|
||||
end
|
||||
|
||||
-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the
|
||||
-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as
|
||||
-- the pic scales -- the same compensation AnimateSendingOutMon's grow
|
||||
-- uses. Returns top-left x, y and the scale. The bottom edge stays put
|
||||
-- for every scale: y + h * scale == ey + h.
|
||||
function BattleState.frontPlacement(ex, ey, w, h, scale)
|
||||
return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale
|
||||
end
|
||||
|
||||
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
|
||||
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
||||
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
|
||||
@@ -3890,16 +4034,23 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
local img = self:picImage(self.enemy.sprite)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||
local s = BattleState.resolveBattleScale(self.data, "front",
|
||||
imagePathOf(img), self.enemy.mon and self.enemy.mon.species)
|
||||
local gs = self:growInScale(self.enemy)
|
||||
if gs then
|
||||
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
||||
-- and horizontal center pinned to the mon's slot while it grows
|
||||
if gs > 0 then
|
||||
love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
|
||||
ey + img:getHeight() * (1 - gs), 0, gs, gs)
|
||||
-- and horizontal center pinned to the mon's slot while it grows --
|
||||
-- the mod scale composes multiplicatively with the grow stage
|
||||
local eff = s * gs
|
||||
if eff > 0 then
|
||||
local dx, dy = BattleState.frontPlacement(ex, ey,
|
||||
img:getWidth(), img:getHeight(), eff)
|
||||
love.graphics.draw(img, dx, dy, 0, eff, eff)
|
||||
end
|
||||
else
|
||||
self:drawBattlerPic(self.enemy, ex, ey, 1)
|
||||
local dx, dy = BattleState.frontPlacement(ex, ey,
|
||||
img:getWidth(), img:getHeight(), s)
|
||||
self:drawBattlerPic(self.enemy, dx, dy, s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3913,9 +4064,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
local img = self:picImage(self.playerBackPic)
|
||||
local pad = imagePadBottom[self.playerBackPic] or 0
|
||||
local padL = imagePadLeft[self.playerBackPic] or 0
|
||||
-- the trainer back is a bare pic, not species-keyed, so only an
|
||||
-- image-level battle_sprite_scales entry can rescale it
|
||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||
imagePathOf(self.playerBackPic), nil)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
||||
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
|
||||
pad, padL, s)
|
||||
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
|
||||
elseif self.player and self.player.sprite and not hidePlayer
|
||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||
local img = self:picImage(self.player.sprite)
|
||||
@@ -3923,19 +4079,24 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
||||
local pad = imagePadBottom[self.player.sprite] or 0
|
||||
local padL = imagePadLeft[self.player.sprite] or 0
|
||||
local px = 8 - padL * 2 + sx
|
||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||
imagePathOf(self.player.sprite),
|
||||
self.player.mon and self.player.mon.species)
|
||||
local gs = self:growInScale(self.player)
|
||||
if gs then
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
|
||||
if gs > 0 then
|
||||
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
|
||||
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
||||
0, 2 * gs, 2 * gs)
|
||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||
-- pinned, mod scale composed with the grow stage
|
||||
local eff = s * gs
|
||||
if eff > 0 then
|
||||
love.graphics.draw(img,
|
||||
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||
end
|
||||
else
|
||||
self:drawBattlerPic(self.player, px,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
||||
local dx, dy = BattleState.backPlacement(img:getWidth(),
|
||||
img:getHeight(), pad, padL, s)
|
||||
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
|
||||
end
|
||||
end
|
||||
if clipped then
|
||||
@@ -3956,8 +4117,11 @@ function BattleState:drawHUDs(slide)
|
||||
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
|
||||
local fx = self.fx
|
||||
local hudShake = (fx and fx.hudShakeX) or 0
|
||||
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
||||
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
|
||||
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
||||
and not self:growInScale(self.enemy) and slide == 0 then
|
||||
and not self:growInScale(self.enemy) and slide == 0
|
||||
and not self.enemy.fainted then
|
||||
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
||||
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
|
||||
-- AnimationShakeEnemyHUD nudges just this block via SCX
|
||||
@@ -3967,8 +4131,8 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
|
||||
if self.enemy.mon.status then
|
||||
Font.draw(self:statusLabel(self.enemy.mon), 40, 8)
|
||||
if self.enemy.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||
else
|
||||
hudTile(0x6E, 32, 8) -- <LV>
|
||||
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
||||
@@ -4015,8 +4179,8 @@ function BattleState:drawHUDs(slide)
|
||||
-- the tick at (18,10) and the triangle at (9,11)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
||||
if self.player.mon.status then
|
||||
Font.draw(self:statusLabel(self.player.mon), 120, 64)
|
||||
if self.player.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
|
||||
else
|
||||
hudTile(0x6E, 112, 64) -- <LV>
|
||||
Font.draw(tostring(self.player.mon.level), 120, 64)
|
||||
@@ -4122,6 +4286,13 @@ function BattleState:drawTextArea()
|
||||
end
|
||||
|
||||
function BattleState:draw()
|
||||
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
|
||||
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
|
||||
if self.blankForAskName then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
return
|
||||
end
|
||||
local fx = self.fx
|
||||
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
|
||||
-- the animations-off fallback keeps the old +-2 alternation
|
||||
|
||||
@@ -102,6 +102,8 @@ function Data:seedDefaults()
|
||||
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
||||
self:seedCinnabarGymTrainerHeaders()
|
||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||
require("src.world.SsAnneLayout").apply(self.maps)
|
||||
end
|
||||
|
||||
function Data:seedCinnabarGymTrainerHeaders()
|
||||
|
||||
@@ -37,6 +37,9 @@ function Game:load()
|
||||
self.mods = ModLoader.new()
|
||||
self.mods:load(Data)
|
||||
self.modStatus = self.mods:status()
|
||||
-- render pipelines dispatch off the merged dataset; point them at the
|
||||
-- one the mods just merged into before anything can draw a frame
|
||||
require("src.render.Pipelines").install(Data)
|
||||
|
||||
self.input = Input
|
||||
Input:init()
|
||||
@@ -198,6 +201,9 @@ function Game:update(dt)
|
||||
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||
require("src.render.Tilt").update(dt)
|
||||
-- mod render pipelines tween on the same real-frame clock, for the same
|
||||
-- reason: they are presentational, so fast-forward must not speed them up
|
||||
require("src.render.Pipelines").update(dt)
|
||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||
-- Steady-state memory backstop: advance the incremental collector one
|
||||
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||
@@ -341,6 +347,17 @@ function Game:keypressed(key)
|
||||
self:writeOptions()
|
||||
return
|
||||
end
|
||||
-- Mod render pipelines claim their hotkeys last, so one can never shadow
|
||||
-- an engine display key however a mod declares it (12 §rendering
|
||||
-- pipelines). syncOptions writes the whole ladder back, including the
|
||||
-- tilt exclusion a world pipeline forces.
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
|
||||
Pipelines.syncOptions(self.save.options)
|
||||
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
|
||||
self:writeOptions()
|
||||
return
|
||||
end
|
||||
Input:keypressed(key)
|
||||
end
|
||||
|
||||
@@ -457,6 +474,9 @@ function Game:applyOptions(opts)
|
||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||
require("src.render.PaletteFX").applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
-- after Tilt, so a persisted world pipeline can switch the tilt level it
|
||||
-- just restored back off (the two are mutually exclusive)
|
||||
require("src.render.Pipelines").applyOptions(opts)
|
||||
require("src.render.Zoom").applyOptions(opts)
|
||||
require("src.render.TileRenderer").applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
|
||||
+284
-4
@@ -17,6 +17,7 @@ local Runtime = require("src.mods.Runtime")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Badges = require("src.inventory.Badges")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
@@ -31,10 +32,11 @@ local OPTIONS_FILENAME = "options.lua"
|
||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
||||
-- load promotes either when the main file is missing or fails to parse.
|
||||
local function saveNames(version)
|
||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
-- Forward-declared here so saveFilename (just below) and every save/load
|
||||
-- caller share the one upvalue; the body is filled in under "save slots"
|
||||
-- once the options IO it depends on exists, because it now resolves the
|
||||
-- ACTIVE slot for a version rather than the fixed flat name.
|
||||
local saveNames
|
||||
|
||||
-- The main save filename for a version -- used by the title screen's
|
||||
-- CONTINUE gate so it looks for the right game's save.
|
||||
@@ -91,6 +93,18 @@ local function makePortableFs(dir)
|
||||
os.remove(full(name))
|
||||
return true
|
||||
end,
|
||||
createDirectory = function(name)
|
||||
-- portable mode writes real files through io.*, which will not
|
||||
-- create missing parent directories; mkdir the tree so a slot path
|
||||
-- like "saves/red" exists before a write lands inside it
|
||||
local osPath = full(name):gsub("/", SEP)
|
||||
if SEP == "\\" then
|
||||
os.execute('mkdir "' .. osPath .. '" 2>nul')
|
||||
else
|
||||
os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null')
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -198,6 +212,11 @@ function SaveData.defaultOptions()
|
||||
videoMode = "windowed",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
||||
-- the player left it in.
|
||||
pipelines = {},
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
mods = {},
|
||||
@@ -283,6 +302,264 @@ function SaveData.loadOptions(fs)
|
||||
return SaveData.mergeOptions(data)
|
||||
end
|
||||
|
||||
-- ------- save slots
|
||||
|
||||
-- A version's playthroughs live in numbered slots under saves/<version>/;
|
||||
-- the active slot is where in-game SAVE and CONTINUE land. The registry
|
||||
-- (the ordered slot list plus which one is active) persists in options.lua
|
||||
-- under options.saveSlots[version]; the active slot is also cached
|
||||
-- process-wide (like GameVersion.current) so the hot saveNames path does
|
||||
-- not re-read options every call. A false cache entry means "no slot in
|
||||
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
|
||||
-- keeps a brand-new install and every pre-slots caller working unchanged.
|
||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- version -> true once resolved this process
|
||||
|
||||
local function slotDir(version) return "saves/" .. version end
|
||||
|
||||
local function slotNames(version, id)
|
||||
local main = slotDir(version) .. "/" .. id .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
-- the pre-slots flat names a version always used (save.lua for Red,
|
||||
-- save_blue.lua for Blue); still the destination before any slot exists
|
||||
local function legacyNames(version)
|
||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
-- Slot resolution is only meaningful for versions GameVersion actually knows
|
||||
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
|
||||
-- that has no info entry and therefore no saveSuffix; resolving its legacy
|
||||
-- names would index a nil info table and crash. Treat any unknown version as
|
||||
-- having no slots so the slot APIs degrade to empty/no-op instead.
|
||||
local function knownVersion(version)
|
||||
return GameVersion.info(version) ~= nil
|
||||
end
|
||||
|
||||
-- Create the parent directory of a slot path when the fs supports it.
|
||||
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
|
||||
-- stub keys files by full path and exposes no such method, so this is a
|
||||
-- no-op there.
|
||||
local function ensureParentDir(fs, name)
|
||||
local dir = name:match("^(.*)/[^/]+$")
|
||||
if dir and fs.createDirectory then fs.createDirectory(dir) end
|
||||
end
|
||||
|
||||
-- Decode a slot's save using the same recovery order load() uses -- main,
|
||||
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
|
||||
-- summarizes. nil when nothing readable is present.
|
||||
local function decodeSlot(fs, version, id)
|
||||
local main, bak, tmp = slotNames(version, id)
|
||||
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
|
||||
if data then return data end
|
||||
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
|
||||
if data then return data end
|
||||
data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "")
|
||||
return data or nil
|
||||
end
|
||||
|
||||
-- One-time legacy consolidation: a pre-slots install has a flat save file
|
||||
-- (+ .bak) and no saves/<version>/ registry. Copy both into slot1, verify
|
||||
-- the copy reads back, then remove the originals and register slot1 as the
|
||||
-- active slot. Returns the new slot id, or nil when there is nothing to
|
||||
-- migrate or the copy could not be verified (originals left in place so no
|
||||
-- data is ever lost to a failed move).
|
||||
local function tryMigrateLegacy(version, fs)
|
||||
local lmain, lbak, ltmp = legacyNames(version)
|
||||
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
|
||||
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
|
||||
if not (mainBody or bakBody) then return nil end
|
||||
local id = "slot1"
|
||||
local dmain, dbak = slotNames(version, id)
|
||||
ensureParentDir(fs, dmain)
|
||||
if mainBody then fs.write(dmain, mainBody) end
|
||||
if bakBody then fs.write(dbak, bakBody) end
|
||||
-- refuse to delete the originals unless the new slot is loadable (from
|
||||
-- the main copy or, failing that, the backup)
|
||||
if not decodeSlot(fs, version, id) then return nil end
|
||||
remove(fs, lmain)
|
||||
remove(fs, lbak)
|
||||
remove(fs, ltmp)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = { id }, active = id }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||
-- slot1; otherwise false, meaning the flat legacy path.
|
||||
local function ensureVersionSlots(version, fs)
|
||||
if slotsChecked[version] then return end
|
||||
slotsChecked[version] = true
|
||||
if not knownVersion(version) then
|
||||
activeSlotCache[version] = false
|
||||
return
|
||||
end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||
if reg and type(reg.list) == "table" and #reg.list > 0 then
|
||||
activeSlotCache[version] = reg.active or reg.list[1]
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
|
||||
end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||
function saveNames(version)
|
||||
version = version or GameVersion.get()
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
local slot = activeSlotCache[version]
|
||||
if slot then return slotNames(version, slot) end
|
||||
return legacyNames(version)
|
||||
end
|
||||
|
||||
-- Pure extraction of the launcher's per-slot summary from a decoded save,
|
||||
-- factored out so it is unit-testable with no filesystem: the player name
|
||||
-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same
|
||||
-- fields the title screen's ContinueInfo derives. Badges resolve against
|
||||
-- the vanilla gym list (launcher has no loaded Data), which is what the
|
||||
-- flat launcher meta line needs.
|
||||
function SaveData.slotSummary(save)
|
||||
if type(save) ~= "table" then return nil, nil end
|
||||
local name = save.player and save.player.name or nil
|
||||
local dexCount = 0
|
||||
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
||||
dexCount = dexCount + 1
|
||||
end
|
||||
local t = math.floor(save.playTime or 0)
|
||||
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60)
|
||||
return name, {
|
||||
badges = Badges.count(nil, save),
|
||||
timeText = timeText,
|
||||
dexCount = dexCount,
|
||||
}
|
||||
end
|
||||
|
||||
-- Slots visible to the launcher: every registered slot for a version, each
|
||||
-- with whether it holds a save and the cheap summary above. A fresh
|
||||
-- install with nothing registered returns an empty array; a legacy install
|
||||
-- is migrated to slot1 first.
|
||||
function SaveData.listSlots(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return {} end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||
local list = (reg and reg.list) or {}
|
||||
local out = {}
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, version, id)
|
||||
local name, meta = SaveData.slotSummary(save)
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Point the active slot at slotId (registering it if new) and persist the
|
||||
-- choice to options.lua; also update the process-global cache so the very
|
||||
-- next save/load lands in the chosen slot.
|
||||
function SaveData.setActiveSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then reg.list[#reg.list + 1] = slotId end
|
||||
reg.active = slotId
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = slotId
|
||||
return slotId
|
||||
end
|
||||
|
||||
-- Register a new empty slot for the version and return its id. Does NOT
|
||||
-- write a save file and does NOT change the active slot: an empty slot
|
||||
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
|
||||
-- allocated one past the highest existing number so a reused id can never
|
||||
-- collide with a lingering file.
|
||||
function SaveData.createSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||
local maxN = 0
|
||||
for _, id in ipairs(reg.list) do
|
||||
local n = tonumber(tostring(id):match("^slot(%d+)$"))
|
||||
if n and n > maxN then maxN = n end
|
||||
end
|
||||
local id = "slot" .. (maxN + 1)
|
||||
reg.list[#reg.list + 1] = id
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- The active slot id in use for a version (resolved once per process like
|
||||
-- saveNames does), or nil when none is registered and the flat legacy path is
|
||||
-- in use. Public so the launcher's save Import/Export glue can name an export
|
||||
-- after the slot it came from without reaching into the private cache.
|
||||
function SaveData.activeSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
return activeSlotCache[version] or nil
|
||||
end
|
||||
|
||||
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
|
||||
-- the same fs seam every other save/load call uses (so portable mode keeps
|
||||
-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save
|
||||
-- uses for the flat path. Used by the launcher's save-import glue, which has
|
||||
-- already registered the slot via createSlot but written no bytes yet; unlike
|
||||
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
|
||||
-- options. Returns true, or false + an error string on a failed write.
|
||||
function SaveData.writeSlot(version, slotId, saveTable)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
if type(slotId) ~= "string" then return false, "missing slot id" end
|
||||
if type(saveTable) ~= "table" then return false, "missing save table" end
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
local encoded = SaveSerializer.encode(saveTable)
|
||||
local fs = persistFs(nil)
|
||||
ensureParentDir(fs, main)
|
||||
if fs.getInfo(main) then
|
||||
local prev = fs.read(main)
|
||||
if prev then fs.write(bak, prev) end
|
||||
end
|
||||
local ok, err = fs.write(tmp, encoded)
|
||||
if not ok then return false, err end
|
||||
remove(fs, main)
|
||||
ok, err = fs.write(main, encoded)
|
||||
if not ok then return false, err end
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Test seam: drop the process-global slot cache so a suite can exercise
|
||||
-- migration/resolution against a freshly injected filesystem. Unused by
|
||||
-- the game, which resolves each version exactly once per boot.
|
||||
function SaveData.resetSlotState()
|
||||
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
|
||||
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
||||
end
|
||||
|
||||
-- ------- meta
|
||||
|
||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||
@@ -525,6 +802,9 @@ function SaveData.save(data, mods)
|
||||
end
|
||||
local encoded = SaveSerializer.encode(gameOnly)
|
||||
local fs = persistFs(nil)
|
||||
-- the active slot may live in saves/<version>/, which must exist before
|
||||
-- the .tmp/.bak/main writes land (a no-op for the flat legacy path)
|
||||
ensureParentDir(fs, FILENAME)
|
||||
if fs.getInfo(FILENAME) then
|
||||
local prev = fs.read(FILENAME)
|
||||
if prev then fs.write(BACKUP_FILENAME, prev) end
|
||||
|
||||
+10
-2
@@ -4,14 +4,22 @@
|
||||
-- tests.
|
||||
|
||||
local Version = {
|
||||
engine = "1.0.0", -- game/engine release (semver triple)
|
||||
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the
|
||||
-- "-dev" placeholder; CI stamps the real X.Y.Z into
|
||||
-- the packed game.love only, never the working tree.
|
||||
shell = 1, -- native-shell contract this build implements
|
||||
minShell = 1, -- lowest shell contract that can RUN this payload.
|
||||
-- Bump only when a payload needs a newer native
|
||||
-- binary (e.g. a LOVE version bump); an older shell
|
||||
-- refuses to chainload a payload whose minShell
|
||||
-- exceeds the shell it provides.
|
||||
modApi = 2, -- mod API major (manifest `api`)
|
||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||
saveFormat = 4, -- save.meta.format
|
||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||
}
|
||||
|
||||
-- "gen1recomp v1.0.0"
|
||||
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
|
||||
function Version.title(base)
|
||||
return (base or "gen1recomp")
|
||||
.. " v" .. Version.engine
|
||||
|
||||
@@ -375,6 +375,8 @@ function RomExtractor:extractMaps()
|
||||
}
|
||||
self:tick("Maps", mapIndex, #keys)
|
||||
end
|
||||
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
|
||||
-- S.S. Anne 1F cabin reorder at load time
|
||||
self:write("maps", out)
|
||||
return out
|
||||
end
|
||||
|
||||
+1516
-290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and
|
||||
-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just
|
||||
-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome.
|
||||
--
|
||||
-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw
|
||||
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
|
||||
-- validated), then registers a fresh slot, writes it, and makes it active.
|
||||
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
|
||||
-- drops it in the save directory's exports/ folder, returning the absolute path
|
||||
-- so the launcher can offer an "open folder" affordance.
|
||||
--
|
||||
-- Every failure returns false + a friendly one-line message (never raises), so
|
||||
-- the card can surface it as a red notice line rather than crashing.
|
||||
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveFileIO = {}
|
||||
|
||||
local SAVE_SIZE = SaveConvert.SAVE_SIZE
|
||||
|
||||
-- Resolve raw save bytes from whatever the launcher hands us:
|
||||
-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the
|
||||
-- way RomImporter reads a dropped ROM;
|
||||
-- * a raw 32768-byte string (the tests and the in-memory path) used as-is;
|
||||
-- * any other string treated as an absolute picker path opened with io.open.
|
||||
-- A picker path is never 32768 bytes long, so the length test disambiguates it
|
||||
-- from a raw image cleanly. Returns bytes, or nil + an error string.
|
||||
local function readSource(source)
|
||||
local t = type(source)
|
||||
if t == "table" or t == "userdata" then
|
||||
if type(source.read) ~= "function" then
|
||||
return nil, "that file could not be read"
|
||||
end
|
||||
local ok, openErr = source:open("r")
|
||||
if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end
|
||||
local data, readErr = source:read(source:getSize())
|
||||
source:close()
|
||||
if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end
|
||||
return data
|
||||
end
|
||||
if t ~= "string" then
|
||||
return nil, "no save file was provided"
|
||||
end
|
||||
if #source == SAVE_SIZE then
|
||||
return source
|
||||
end
|
||||
local f, openErr = io.open(source, "rb")
|
||||
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||
return data
|
||||
end
|
||||
|
||||
-- importToSlot(source, version) -> ok, slotIdOrErr
|
||||
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
|
||||
-- registers a new slot for the version, writes the imported save into it, makes
|
||||
-- it the active slot, and returns true + the new slot id. On any failure
|
||||
-- returns false + a friendly message.
|
||||
function SaveFileIO.importToSlot(source, version)
|
||||
version = version or GameVersion.get()
|
||||
local bytes, readErr = readSource(source)
|
||||
if not bytes then return false, readErr end
|
||||
if #bytes ~= SAVE_SIZE then
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
end
|
||||
local save, convertErr = SaveConvert.importSav(bytes, version)
|
||||
if not save then return false, convertErr end
|
||||
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
||||
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
||||
-- the format numerically, so re-stamp it to the current format (the imported
|
||||
-- table is already current-shaped, so no migration is skipped by doing so).
|
||||
save.version = version
|
||||
save.meta = SaveData.buildMeta(nil, save.meta)
|
||||
local slotId = SaveData.createSlot(version)
|
||||
if not slotId then return false, "this game has no save slots to import into" end
|
||||
local ok, writeErr = SaveData.writeSlot(version, slotId, save)
|
||||
if not ok then
|
||||
return false, "could not write the imported save: " .. tostring(writeErr)
|
||||
end
|
||||
SaveData.setActiveSlot(version, slotId)
|
||||
return true, slotId
|
||||
end
|
||||
|
||||
-- exportActiveSlot(version) -> ok, pathOrErr
|
||||
-- Loads the version's active slot save (SaveData.load semantics), encodes it
|
||||
-- back to a 32768-byte SRAM image, and writes it to
|
||||
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
|
||||
-- absent). Returns true + the absolute path on success, false + a friendly
|
||||
-- message otherwise.
|
||||
function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.write) then return false, "no filesystem available to export to" end
|
||||
if fs.createDirectory then fs.createDirectory("exports") end
|
||||
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
|
||||
local ok, writeErr = fs.write(rel, bytes)
|
||||
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
||||
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
|
||||
if base ~= "" then return true, base .. "/" .. rel end
|
||||
return true, rel
|
||||
end
|
||||
|
||||
return SaveFileIO
|
||||
@@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local itemDef = data.items[itemId]
|
||||
local name = itemDef and itemDef.name or itemId
|
||||
|
||||
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
|
||||
-- all refuse mid-battle (jp nz, ItemUseNotTime)
|
||||
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
|
||||
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
|
||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||
or (itemDef and itemDef.machine)) then
|
||||
return "failed", { "OAK: " .. save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
|
||||
@@ -501,6 +501,10 @@ function LinkState:updateTrade(input)
|
||||
require("src.core.Sound").play(game.data, "Trade_Machine")
|
||||
Screens.push(game, "TradeAnim", {
|
||||
sent = sent, received = received,
|
||||
enemyName = (self.peerName or "TRAINER"),
|
||||
playerOt = game.save.player.name,
|
||||
playerOtId = sent.otId or game.save.player.id,
|
||||
enemyOtId = received.otId,
|
||||
onDone = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
|
||||
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
|
||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
||||
-- load at boot; this reads the same options.mods enable-state the loader
|
||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
|
||||
--
|
||||
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery and
|
||||
-- install paths reach for love.filesystem and SaveData.
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
-- ------- pure status derivation
|
||||
|
||||
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
||||
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
|
||||
-- conflictSpecs, version and game_version are exactly the fields the loader's
|
||||
-- Manifest.validate produced); enabledSet is the current desired enable-set.
|
||||
local function statusFor(mods, id, enabledSet, enabled)
|
||||
local m = mods[id]
|
||||
-- conflict only bites an enabled mod: resolveToggle's conflict list is
|
||||
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
|
||||
-- enabled other's spec vs this mod), which is exactly the launcher chip.
|
||||
if enabled then
|
||||
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
|
||||
if #r.conflicts > 0 then
|
||||
local otherId = r.conflicts[1]
|
||||
local other = mods[otherId]
|
||||
return "conflict",
|
||||
"Conflicts with " .. ((other and other.name) or otherId)
|
||||
end
|
||||
end
|
||||
-- warn: the engine is outside the mod's game_version range
|
||||
if m.game_version
|
||||
and not Semver.satisfies(Version.engine, m.game_version) then
|
||||
return "warn", "Needs engine " .. m.game_version
|
||||
.. " (have " .. Version.engine .. ")"
|
||||
end
|
||||
-- warn: a hard dependency is absent, switched off, or the wrong version.
|
||||
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
|
||||
-- it, so the disabled case is judged straight off the manifest here.
|
||||
for _, spec in ipairs(m.dependencySpecs or {}) do
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
end
|
||||
end
|
||||
return "ok", "Ready"
|
||||
end
|
||||
|
||||
-- deriveList(manifests, options) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (only options.mods is read). Rows come back
|
||||
-- sorted by id so the panel order is stable.
|
||||
function LauncherMods.deriveList(manifests, options)
|
||||
local mods = options and options.mods or {}
|
||||
local ordered = {}
|
||||
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
||||
table.sort(ordered, function(a, b) return a.id < b.id end)
|
||||
|
||||
local byId, enabledSet = {}, {}
|
||||
for _, m in ipairs(ordered) do
|
||||
byId[m.id] = m
|
||||
-- missing entry means enabled, matching the loader's default
|
||||
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, m in ipairs(ordered) do
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||
local raw = m.raw or {}
|
||||
out[#out + 1] = {
|
||||
id = m.id,
|
||||
name = m.name or m.id,
|
||||
version = m.version,
|
||||
-- category, then profile, then a generic fallback -- uppercased
|
||||
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
|
||||
-- paths is a shallow listing: top-level file names as-is, and for a top-level
|
||||
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
|
||||
-- the manifest sits at the archive root, "<dir>" when a single top-level
|
||||
-- folder holds it, or nil + a user-presentable reason.
|
||||
function LauncherMods.locateRoot(paths)
|
||||
for _, p in ipairs(paths) do
|
||||
if p == "manifest.json" then return "" end
|
||||
end
|
||||
local topDirs, seen, hasManifest = {}, {}, {}
|
||||
for _, p in ipairs(paths) do
|
||||
local top, rest = p:match("^([^/]+)/(.+)$")
|
||||
if top then
|
||||
if not seen[top] then
|
||||
seen[top] = true
|
||||
topDirs[#topDirs + 1] = top
|
||||
end
|
||||
if rest == "manifest.json" then hasManifest[top] = true end
|
||||
end
|
||||
end
|
||||
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
|
||||
if #topDirs > 1 then
|
||||
return nil, "the .zip must contain a single mod folder"
|
||||
end
|
||||
return nil, "no manifest.json found in the .zip"
|
||||
end
|
||||
|
||||
-- ------- discovery (love.filesystem)
|
||||
|
||||
local function decodeManifest(raw, path)
|
||||
local Json = require("src.link.Json")
|
||||
local data, decodeErr = Json.decode(raw)
|
||||
if not data then return nil, decodeErr end
|
||||
local ok, manifest = pcall(Manifest.validate, data, path)
|
||||
if not ok then return nil, manifest end
|
||||
return manifest
|
||||
end
|
||||
|
||||
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
|
||||
-- but validates only -- no entry chunk is ever loaded). First id wins on a
|
||||
-- duplicate. Returns an array of validated manifests.
|
||||
local function discover()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
if not fs.getInfo("mods") then return out end
|
||||
local seen = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
local info = fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
if raw then
|
||||
local manifest = decodeManifest(raw, path)
|
||||
if manifest and not seen[manifest.id] then
|
||||
seen[manifest.id] = true
|
||||
out[#out + 1] = manifest
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- list() -> the mods-panel rows for the current install. Reads the same
|
||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||
-- game sees on its next boot.
|
||||
function LauncherMods.list()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||
-- Loader:_saveState writes (a plain boolean), so the running game and the
|
||||
-- in-game ManagerState pick it up unchanged.
|
||||
function LauncherMods.setEnabled(id, enabled)
|
||||
local options = SaveData.loadOptions()
|
||||
options.mods = options.mods or {}
|
||||
options.mods[id] = enabled and true or false
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- install (love.filesystem)
|
||||
|
||||
-- Read a .zip source into bytes. A string is an external absolute path (like
|
||||
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
|
||||
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
|
||||
-- ingests dropped ROMs.
|
||||
local function readArchive(source)
|
||||
local t = type(source)
|
||||
if (t == "userdata" or t == "table") and type(source.open) == "function" then
|
||||
local ok = source:open("r")
|
||||
if not ok then return nil, "could not open the dropped file" end
|
||||
local data = source:read(source:getSize())
|
||||
source:close()
|
||||
if not data then return nil, "the dropped file could not be read" end
|
||||
return data
|
||||
end
|
||||
if t == "string" then
|
||||
local f = io.open(source, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if not data then return nil, "could not read " .. source end
|
||||
return data
|
||||
end
|
||||
if love and love.filesystem then
|
||||
local data = love.filesystem.read(source)
|
||||
if data then return data end
|
||||
end
|
||||
return nil, "could not open " .. source
|
||||
end
|
||||
return nil, "unsupported archive source"
|
||||
end
|
||||
|
||||
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
|
||||
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
|
||||
-- actually holds one (so a lone folder with no manifest still reads as empty).
|
||||
local function topLevelPaths(mount)
|
||||
local fs = love.filesystem
|
||||
local paths = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems(mount)) do
|
||||
local info = fs.getInfo(mount .. "/" .. name)
|
||||
if info and info.type == "directory" then
|
||||
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
|
||||
paths[#paths + 1] = name .. "/manifest.json"
|
||||
end
|
||||
else
|
||||
paths[#paths + 1] = name
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
local function copyTree(src, dst)
|
||||
local fs = love.filesystem
|
||||
if not fs.createDirectory(dst) then
|
||||
return nil, "could not create " .. dst
|
||||
end
|
||||
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||
local s = src .. "/" .. name
|
||||
local d = dst .. "/" .. name
|
||||
local info = fs.getInfo(s)
|
||||
if info and info.type == "directory" then
|
||||
local ok, err = copyTree(s, d)
|
||||
if not ok then return nil, err end
|
||||
else
|
||||
local data = fs.read(s)
|
||||
if data == nil then return nil, "could not read " .. name end
|
||||
local ok, err = fs.write(d, data)
|
||||
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function removeTree(path)
|
||||
local fs = love.filesystem
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
if info.type == "directory" then
|
||||
for _, child in ipairs(fs.getDirectoryItems(path)) do
|
||||
removeTree(path .. "/" .. child)
|
||||
end
|
||||
end
|
||||
fs.remove(path)
|
||||
end
|
||||
|
||||
-- installZip(source) -> true, id | nil, errString
|
||||
-- source is an external path or a love DroppedFile. The archive is validated
|
||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||
-- the save dir is staged into a save-dir temp first, because
|
||||
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||
function LauncherMods.installZip(source)
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "mod install needs LOVE"
|
||||
end
|
||||
local fs = love.filesystem
|
||||
local data, readErr = readArchive(source)
|
||||
if not data then return nil, readErr end
|
||||
|
||||
-- stage into a save-dir temp so mount can reach it
|
||||
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
|
||||
local ok, writeErr = fs.write(tmp, data)
|
||||
if not ok then
|
||||
return nil, "could not stage the .zip: " .. tostring(writeErr)
|
||||
end
|
||||
local mount = "mod_import_mount"
|
||||
if not fs.mount(tmp, mount) then
|
||||
fs.remove(tmp)
|
||||
return nil, "that .zip could not be opened"
|
||||
end
|
||||
local function cleanup()
|
||||
pcall(fs.unmount, tmp)
|
||||
fs.remove(tmp)
|
||||
end
|
||||
|
||||
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
|
||||
if not prefix then
|
||||
cleanup()
|
||||
return nil, rootErr
|
||||
end
|
||||
local root = prefix == "" and mount or (mount .. "/" .. prefix)
|
||||
|
||||
local raw = fs.read(root .. "/manifest.json")
|
||||
if not raw then
|
||||
cleanup()
|
||||
return nil, "the .zip has no readable manifest.json"
|
||||
end
|
||||
local manifest, manifestErr = decodeManifest(raw, root)
|
||||
if not manifest then
|
||||
cleanup()
|
||||
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||
end
|
||||
|
||||
-- reject a duplicate before touching the mods tree
|
||||
local dest = "mods/" .. manifest.id
|
||||
if fs.getInfo(dest) then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
|
||||
fs.createDirectory("mods")
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then
|
||||
removeTree(dest)
|
||||
cleanup()
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
cleanup()
|
||||
return true, manifest.id
|
||||
end
|
||||
|
||||
return LauncherMods
|
||||
@@ -40,6 +40,19 @@ function f.int(min, max)
|
||||
end }
|
||||
end
|
||||
|
||||
-- a bounded float; like f.int but keeps the fractional part (scales,
|
||||
-- gains). Rejects out-of-range with the "expected number a..b" message.
|
||||
function f.numRange(min, max)
|
||||
local desc = "number"
|
||||
if min and max then desc = ("number %s..%s"):format(min, max)
|
||||
elseif min then desc = ("number >= %s"):format(min) end
|
||||
return { kind = "num", min = min, max = max, desc = desc,
|
||||
check = function(v)
|
||||
return type(v) == "number"
|
||||
and (min == nil or v >= min) and (max == nil or v <= max)
|
||||
end }
|
||||
end
|
||||
|
||||
function f.enum(values)
|
||||
local set = {}
|
||||
for _, value in ipairs(values) do set[value] = true end
|
||||
@@ -420,6 +433,12 @@ R.pokemon = {
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
||||
trueColor = f.opt(f.bool),
|
||||
-- battle-pic scale overrides for this species' own pics: front is the
|
||||
-- enemy pic (default 1x), back is the player pic (default 2x). An
|
||||
-- image-level battle_sprite_scales entry for the same path beats these.
|
||||
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
|
||||
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
|
||||
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
|
||||
},
|
||||
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
|
||||
}
|
||||
@@ -791,6 +810,118 @@ R.transitions = {
|
||||
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
|
||||
}
|
||||
|
||||
-- ------- rendering pipelines
|
||||
--
|
||||
-- A pipeline is a display mode that owns part of the frame: it may replace
|
||||
-- the overworld's world pass with geometry of its own (drawWorld) and/or
|
||||
-- post-process the finished composite (present). Everything around that --
|
||||
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
|
||||
-- save.options.pipelines and the gating that keeps it out of battles and
|
||||
-- menus -- is engine plumbing driven from this record, so a renderer mod
|
||||
-- declares what it is and writes only the two draw functions.
|
||||
--
|
||||
-- Both callbacks are optional and independent: a present-only pipeline is a
|
||||
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
|
||||
-- rendered the frame alone, and a drawWorld-only pipeline is a world
|
||||
-- renderer that composites straight. See src/render/Pipelines.lua for the
|
||||
-- ctx each receives and docs/modding.md for the worked example.
|
||||
R.render_pipelines = {
|
||||
semantics = "record", target = "render_pipelines",
|
||||
fields = {
|
||||
-- shown in the options menu; the ladder labels default to OFF/ON
|
||||
label = f.str,
|
||||
levels = f.opt(f.list(f.str)),
|
||||
-- keyboard key that cycles the ladder, checked after the engine's own
|
||||
-- display hotkeys so a pipeline can never shadow one
|
||||
hotkey = f.opt(f.str),
|
||||
-- higher wins when two world pipelines are somehow active at once;
|
||||
-- also the options-row order, so a mode and its post-process sort
|
||||
-- together instead of by id
|
||||
priority = f.opt(f.num),
|
||||
-- hardware/driver gate, checked every frame: false keeps the vanilla
|
||||
-- 2D path, which is what a headless run and a driver with no depth
|
||||
-- canvas both get
|
||||
available = f.opt(f.fn),
|
||||
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
|
||||
-- right now. Defaults to the survey-zoom gate (free-roam overworld
|
||||
-- only), which keeps a hotkey press from switching modes mid-warp or
|
||||
-- mid-cutscene. It has no say over whether an already-on mode draws:
|
||||
-- a mode that stopped rendering during a warp would flash the flat 2D
|
||||
-- world every time the player walked through a door.
|
||||
gate = f.opt(f.fn),
|
||||
-- (dt, level): presentational tweens, ticked on real frame time
|
||||
update = f.opt(f.fn),
|
||||
-- (ctx) -> canvas | nil: render the world. nil falls back to the
|
||||
-- vanilla flat/tilt draw for this frame.
|
||||
drawWorld = f.opt(f.fn),
|
||||
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
|
||||
-- composites over it -- a depth-of-field or colour grade that must not
|
||||
-- touch the dialog boxes and menus sitting on top. Only runs when some
|
||||
-- pipeline rendered the world, since the vanilla world pass has no
|
||||
-- single finished image to hand over.
|
||||
worldPresent = f.opt(f.fn),
|
||||
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
|
||||
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
|
||||
-- canvas; the input unchanged is the correct answer when the effect is
|
||||
-- off.
|
||||
present = f.opt(f.fn),
|
||||
-- drop GPU objects (window resize, hot reload, mode switch)
|
||||
invalidate = f.opt(f.fn),
|
||||
},
|
||||
-- a pipeline that does neither half is dead weight and would silently
|
||||
-- occupy an options row and a hotkey
|
||||
extra = function(_, value)
|
||||
if value.drawWorld == nil and value.present == nil
|
||||
and value.worldPresent == nil then
|
||||
return "a render pipeline needs drawWorld, worldPresent or present"
|
||||
end
|
||||
end,
|
||||
-- A pipeline callback fails at play time, long after the load phase has
|
||||
-- handed its report to the mod manager, so the merge leaves behind who
|
||||
-- wrote each record for Pipelines to name in the failure -- the same
|
||||
-- provenance trick the audio registries use (Loader.stampAudioOwners).
|
||||
-- Placement is otherwise the default record merge.
|
||||
write = function(target, registry)
|
||||
local owners, tombstones = {}, {}
|
||||
for id in pairs(registry.ops) do
|
||||
local value = registry:get(id)
|
||||
if value == nil then
|
||||
tombstones[#tombstones + 1] = id
|
||||
else
|
||||
target[id] = value
|
||||
local owner = registry.owners[id]
|
||||
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
|
||||
end
|
||||
end
|
||||
for _, id in ipairs(tombstones) do target[id] = nil end
|
||||
target._owners = owners
|
||||
end,
|
||||
example = 'mod.content.render_pipelines:register("voxel", ' ..
|
||||
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
|
||||
}
|
||||
|
||||
-- ------- battle sprite scales
|
||||
--
|
||||
-- Per-image battle-pic scale overrides, keyed by record id and consulted
|
||||
-- by asset path at draw time. Where a species' battleScaleFront /
|
||||
-- battleScaleBack scales its own front/back pic, this scales ANY battle
|
||||
-- pic by the path it is drawn from -- the only handle on the non-species
|
||||
-- pics like the player's trainer back sprite. Image-level beats
|
||||
-- species-level; both compose with the send-out grow and keep the sprite
|
||||
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
|
||||
R.battle_sprite_scales = {
|
||||
semantics = "record", target = "battle_sprite_scales",
|
||||
fields = {
|
||||
-- the asset path exactly as data references it, e.g.
|
||||
-- "assets/generated/battle/back/abrab.png"
|
||||
path = f.path,
|
||||
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
||||
scale = f.numRange(0.25, 4.0),
|
||||
},
|
||||
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
||||
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
||||
}
|
||||
|
||||
-- ------- progression
|
||||
|
||||
R.evolution_methods = {
|
||||
|
||||
@@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level)
|
||||
return moves
|
||||
end
|
||||
|
||||
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
|
||||
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
|
||||
-- the oldest slot out when full. Silent — no LearnMove prompts.
|
||||
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
|
||||
if not (speciesDef and speciesDef.learnset and mon) then return end
|
||||
mon.moves = mon.moves or {}
|
||||
for _, entry in ipairs(speciesDef.learnset) do
|
||||
local moveLevel = entry.level
|
||||
if moveLevel > newLevel then break end
|
||||
if moveLevel > startLevel then
|
||||
local known = false
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == entry.move then known = true break end
|
||||
end
|
||||
if not known then
|
||||
local mdef = data.moves[entry.move]
|
||||
local slot = { id = entry.move, pp = mdef and mdef.pp or 0 }
|
||||
if #mon.moves < 4 then
|
||||
mon.moves[#mon.moves + 1] = slot
|
||||
else
|
||||
table.remove(mon.moves, 1)
|
||||
mon.moves[#mon.moves + 1] = slot
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Pokemon.new(data, species, level, rng)
|
||||
local def = data.pokemon[species]
|
||||
assert(def, "unknown species " .. tostring(species))
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
-- Rendering pipelines: the engine side of the render_pipelines registry.
|
||||
--
|
||||
-- A pipeline is a display mode a mod owns. It may replace the overworld's
|
||||
-- world pass with geometry of its own (drawWorld) and/or post-process the
|
||||
-- finished composite (present). Everything else about being a display mode
|
||||
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
|
||||
-- free-roam gate, and never letting a mod's error take the frame down -- is
|
||||
-- engine plumbing and lives here, so a renderer mod writes the two draw
|
||||
-- functions and declares the rest.
|
||||
--
|
||||
-- The two halves compose independently and in priority order: the highest
|
||||
-- priority eligible drawWorld renders the world, then every eligible
|
||||
-- present folds over whatever came out (the world pipeline's canvas, or the
|
||||
-- vanilla flat/tilt composite when none ran). A present that is switched
|
||||
-- off returns its input, so a full ladder of them costs nothing at level 0.
|
||||
--
|
||||
-- Nothing here reaches collision, movement, triggers or scripts: like
|
||||
-- survey zoom and tilt, a pipeline is purely presentational, which is why
|
||||
-- its level rides in save.options rather than the save proper.
|
||||
--
|
||||
-- Spec: docs/modding.md (rendering pipelines)
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
|
||||
local Pipelines = {}
|
||||
|
||||
-- id -> level. Levels live here rather than on the records because the
|
||||
-- records are merged content: frozen after load, and shared with whatever
|
||||
-- else reads Data.
|
||||
local levels = {}
|
||||
|
||||
-- ids whose callbacks have already thrown, so a pipeline that fails every
|
||||
-- frame reports once instead of filling the log at 60Hz
|
||||
local broken = {}
|
||||
|
||||
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
|
||||
|
||||
-- The merged dataset the records live in. The boot singleton is the
|
||||
-- default -- Game.data is that very table -- and install() lets a headless
|
||||
-- caller (the SDK harness, a tool) point this at a dataset of its own.
|
||||
local source = Data
|
||||
|
||||
function Pipelines.install(data)
|
||||
source = data or Data
|
||||
Pipelines.reset()
|
||||
end
|
||||
|
||||
-- ------- catalog
|
||||
|
||||
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
|
||||
-- (descending, ties by id) so selection, the options rows and the present
|
||||
-- fold all walk the same sequence.
|
||||
--
|
||||
-- Memoized on the namespace table's identity. Content freezes at the merge
|
||||
-- boundary, so the answer cannot change for a given table -- and this is
|
||||
-- read several times per frame by update(), worldPipeline() and the
|
||||
-- endFrame present check, which is no place to allocate and sort. An empty
|
||||
-- list is cached too, so a mod-free boot pays one table for the process.
|
||||
local listCache, listSource = nil, nil
|
||||
|
||||
function Pipelines.list()
|
||||
local defs = source and source.render_pipelines
|
||||
if defs == listSource and listCache then return listCache end
|
||||
local out = {}
|
||||
if type(defs) == "table" then
|
||||
for id, def in pairs(defs) do
|
||||
-- the merge writes provenance under _owners; skip the bookkeeping
|
||||
-- keys rather than treating them as pipelines
|
||||
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
|
||||
out[#out + 1] = { id = id, def = def }
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
local pa, pb = a.def.priority or 0, b.def.priority or 0
|
||||
if pa ~= pb then return pa > pb end
|
||||
return a.id < b.id
|
||||
end)
|
||||
end
|
||||
listCache, listSource = out, defs
|
||||
return out
|
||||
end
|
||||
|
||||
function Pipelines.get(id)
|
||||
local defs = source and source.render_pipelines
|
||||
local def = type(defs) == "table" and defs[id] or nil
|
||||
return type(def) == "table" and def or nil
|
||||
end
|
||||
|
||||
-- the mod that registered a pipeline, so a runtime failure lands in the
|
||||
-- feed the mod manager shows instead of only in the console
|
||||
local function ownerOf(id)
|
||||
local defs = source and source.render_pipelines
|
||||
local owners = type(defs) == "table" and defs._owners or nil
|
||||
return owners and owners[id] or nil
|
||||
end
|
||||
|
||||
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
|
||||
-- frame must not take the frame with it: the pipeline is marked broken,
|
||||
-- attributed once, and treated as absent from then on -- which degrades to
|
||||
-- the vanilla 2D path rather than a black screen.
|
||||
local function guard(id, fn, ...)
|
||||
if broken[id] then return nil end
|
||||
local ok, result = pcall(fn, ...)
|
||||
if ok then return result end
|
||||
broken[id] = true
|
||||
Logger.error("render pipeline %s failed: %s -- disabled for this session",
|
||||
id, tostring(result))
|
||||
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether a callback's return is a real Canvas we can composite. A mod that
|
||||
-- forgets a return, or hands back a shade string / flag / number, must be
|
||||
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
|
||||
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
|
||||
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
|
||||
-- shape (love.graphics.newCanvas), so accept either and nothing else.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
|
||||
-- before and pop() after, so a callback that returns cleanly but leaves a
|
||||
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
|
||||
-- the engine composite that follows. guard() catches a callback that throws;
|
||||
-- this catches one that dirties state. A pipeline already retired skips the
|
||||
-- push/pop entirely, so the stack stays balanced.
|
||||
local function guardRender(id, fn, ...)
|
||||
if broken[id] then return nil end
|
||||
love.graphics.push("all")
|
||||
local out = guard(id, fn, ...)
|
||||
love.graphics.pop()
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- levels
|
||||
|
||||
function Pipelines.levelLabels(id)
|
||||
local def = Pipelines.get(id)
|
||||
local labels = def and def.levels
|
||||
if type(labels) ~= "table" or labels[1] == nil then
|
||||
return Pipelines.DEFAULT_LEVELS
|
||||
end
|
||||
return labels
|
||||
end
|
||||
|
||||
-- highest selectable level: one less than the label count, so a two-label
|
||||
-- ladder is a plain OFF/ON toggle
|
||||
function Pipelines.maxLevel(id)
|
||||
return #Pipelines.levelLabels(id) - 1
|
||||
end
|
||||
|
||||
function Pipelines.level(id)
|
||||
return levels[id] or 0
|
||||
end
|
||||
|
||||
function Pipelines.levelLabel(id, level)
|
||||
local labels = Pipelines.levelLabels(id)
|
||||
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
|
||||
end
|
||||
|
||||
-- A world pipeline and the engine's own tilt mode are two answers to the
|
||||
-- same question, so switching one on switches the other off -- the rule
|
||||
-- tilt and survey zoom already follow between themselves. Present-only
|
||||
-- pipelines (post-processes) compose with tilt and are left alone.
|
||||
local function excludeTilt(id, level)
|
||||
local def = Pipelines.get(id)
|
||||
if not (def and def.drawWorld) or level <= 0 then return end
|
||||
local Tilt = require("src.render.Tilt")
|
||||
if Tilt.level > 0 then Tilt.setLevel(0) end
|
||||
-- one world pipeline at a time, for the same reason
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
||||
levels[entry.id] = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Pipelines.setLevel(id, level)
|
||||
if not Pipelines.get(id) then return 0 end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
local max = Pipelines.maxLevel(id)
|
||||
if level > max then level = max end
|
||||
levels[id] = level
|
||||
excludeTilt(id, level)
|
||||
return level
|
||||
end
|
||||
|
||||
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
|
||||
function Pipelines.cycle(id, dir)
|
||||
local max = Pipelines.maxLevel(id)
|
||||
if max < 1 then return 0 end
|
||||
local span = max + 1
|
||||
local target = (Pipelines.level(id) + (dir or 1)) % span
|
||||
if target < 0 then target = target + span end
|
||||
return Pipelines.setLevel(id, target)
|
||||
end
|
||||
|
||||
-- Turning a world pipeline on must switch tilt off in the save too, not
|
||||
-- just in the live module, or the next boot restores both. Call sites hand
|
||||
-- over the options table so this stays the one place that rule lives.
|
||||
function Pipelines.syncOptions(opts)
|
||||
if type(opts) ~= "table" then return end
|
||||
local bucket = opts.pipelines
|
||||
if type(bucket) ~= "table" then
|
||||
bucket = {}
|
||||
opts.pipelines = bucket
|
||||
end
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
bucket[entry.id] = Pipelines.level(entry.id)
|
||||
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
||||
opts.tilt = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Restore levels from a loaded options table. A pipeline whose mod is gone
|
||||
-- keeps its stored level untouched in the bucket (so re-enabling the mod
|
||||
-- restores the mode) but contributes nothing while absent.
|
||||
function Pipelines.applyOptions(opts)
|
||||
local bucket = type(opts) == "table" and opts.pipelines or nil
|
||||
levels = {}
|
||||
broken = {}
|
||||
local world = nil
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
local stored = type(bucket) == "table" and bucket[entry.id] or 0
|
||||
local level = math.floor(tonumber(stored) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
local max = Pipelines.maxLevel(entry.id)
|
||||
if level > max then level = max end
|
||||
-- list() is priority order, so the first world pipeline with a stored
|
||||
-- level is the one that wins; the rest restore to OFF rather than
|
||||
-- sitting on a level that can never render
|
||||
if entry.def.drawWorld and level > 0 then
|
||||
if world then level = 0 else world = entry.id end
|
||||
end
|
||||
levels[entry.id] = level
|
||||
end
|
||||
-- a restored world pipeline and tilt are two answers to the same
|
||||
-- question; the pipeline wins, as it does at every place that sets one
|
||||
if world then require("src.render.Tilt").setLevel(0) end
|
||||
end
|
||||
|
||||
function Pipelines.reset()
|
||||
levels = {}
|
||||
broken = {}
|
||||
end
|
||||
|
||||
-- ------- per-frame
|
||||
|
||||
-- Presentational tweens run on real frame time, like Tilt's. Every
|
||||
-- pipeline ticks, not just the active ones: a mode easing back OUT still
|
||||
-- has an angle to retire.
|
||||
function Pipelines.update(dt)
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.update then
|
||||
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- A pipeline may run this frame when it is switched on, has not thrown, and
|
||||
-- its hardware gate says yes. `available` is consulted every frame rather
|
||||
-- than cached: a driver that loses its depth canvas on a resize has to be
|
||||
-- able to change its mind.
|
||||
--
|
||||
-- Deliberately NOT gated on the state stack. `gate` governs whether the
|
||||
-- player may CHANGE the mode, never whether it draws -- a display mode that
|
||||
-- stopped rendering during a warp, a scripted cutscene or an open menu
|
||||
-- would flash the flat 2D world for those frames every time the player
|
||||
-- walked through a door. Once a mode is on it renders until it is off.
|
||||
function Pipelines.eligible(id)
|
||||
local def = Pipelines.get(id)
|
||||
if not def or broken[id] then return false end
|
||||
if Pipelines.level(id) <= 0 then return false end
|
||||
if def.available and guard(id, def.available) ~= true then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Whether the player may cycle this mode right now: the free-roam gate,
|
||||
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
|
||||
-- Input only -- see eligible() for why the draw path does not consult it.
|
||||
function Pipelines.canToggle(id, top, overworld)
|
||||
local def = Pipelines.get(id)
|
||||
if not def then return false end
|
||||
local gate = def.gate or Zoom.gateOK
|
||||
return guard(id, gate, top, overworld) == true
|
||||
end
|
||||
|
||||
-- The pipeline that owns the world pass right now, or nil for the vanilla
|
||||
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
|
||||
-- there is normally only one candidate anyway.
|
||||
function Pipelines.worldPipeline()
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
|
||||
return entry.id, entry.def
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Render the world through `id`. Returns the canvas to composite, or nil
|
||||
-- when the pipeline declined this frame (nothing to draw, a transient
|
||||
-- failure), which the caller treats as "fall back to the 2D path".
|
||||
function Pipelines.drawWorld(id, ctx)
|
||||
local def = Pipelines.get(id)
|
||||
if not (def and def.drawWorld) then return nil end
|
||||
return guardRender(id, def.drawWorld, ctx)
|
||||
end
|
||||
|
||||
-- Fold every eligible world post-process over a pipeline's world image,
|
||||
-- before the UI composites on top. This is where a depth-of-field or a
|
||||
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
|
||||
-- `present` below is the whole-frame counterpart. Only reachable once some
|
||||
-- pipeline rendered the world, so it is gated on the overworld state the
|
||||
-- same way drawWorld is.
|
||||
function Pipelines.worldPresent(canvas, ctx)
|
||||
if canvas == nil then return nil end
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
|
||||
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
|
||||
-- accept only a real Canvas: a pass that returns a non-canvas (a
|
||||
-- forgotten return, a shade string) is ignored, not folded in
|
||||
if isCanvas(out) then canvas = out end
|
||||
end
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- Fold every eligible post-process over the finished frame. Present
|
||||
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
|
||||
-- grade applies to menus and battles too -- so eligibility here is just
|
||||
-- "switched on and available". A pass that returns a non-canvas is
|
||||
-- ignored rather than trusted, so a mod cannot blank the screen by
|
||||
-- forgetting a return.
|
||||
function Pipelines.present(canvas, ctx)
|
||||
if canvas == nil then return nil end
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.present and Pipelines.eligible(entry.id) then
|
||||
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
|
||||
-- accept only a real Canvas: a pass that returns a non-canvas is
|
||||
-- ignored (docstring above), so a mod cannot blank or crash the frame
|
||||
-- by forgetting a return or handing back a truthy non-canvas
|
||||
if isCanvas(out) then canvas = out end
|
||||
end
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- true when any present-only pass wants to run, so the composite path can
|
||||
-- skip allocating a target it would not use
|
||||
function Pipelines.wantsPresent()
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.present and Pipelines.eligible(entry.id) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------- input and UI
|
||||
|
||||
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
|
||||
-- the caller knows the key was consumed. Checked after the engine's own
|
||||
-- display hotkeys, so a mod can never shadow one.
|
||||
function Pipelines.hotkey(key, top, overworld)
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.hotkey == key then
|
||||
-- the gate belongs here and nowhere else: it stops the player
|
||||
-- flipping modes mid-warp or mid-cutscene, and has no say over
|
||||
-- whether an already-on mode draws
|
||||
if Pipelines.canToggle(entry.id, top, overworld) then
|
||||
Pipelines.cycle(entry.id)
|
||||
return entry.id
|
||||
end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Options rows for every registered pipeline, in the same priority order,
|
||||
-- in the descriptor shape src/ui/OptionRows.lua renders.
|
||||
function Pipelines.rows(game)
|
||||
local rows = {}
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
local id = entry.id
|
||||
rows[#rows + 1] = {
|
||||
id = "pipeline:" .. id,
|
||||
label = entry.def.label or id:upper(),
|
||||
value = function() return Pipelines.levelLabel(id) end,
|
||||
step = function(g, dir)
|
||||
Pipelines.cycle(id, dir)
|
||||
local opts = g and g.save and g.save.options
|
||||
if opts then
|
||||
Pipelines.syncOptions(opts)
|
||||
-- the exclusion above may have switched tilt off; keep the live
|
||||
-- module in step with the option it just wrote
|
||||
require("src.render.Tilt").setLevel(opts.tilt or 0)
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- Drop every pipeline's GPU objects (window resize, hot reload).
|
||||
function Pipelines.invalidate()
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
|
||||
end
|
||||
end
|
||||
|
||||
return Pipelines
|
||||
+76
-4
@@ -9,12 +9,28 @@
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
Renderer.WIDTH = 160
|
||||
Renderer.HEIGHT = 144
|
||||
|
||||
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
|
||||
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
|
||||
-- tables carrying the Canvas method shape. A mod pipeline handing back a
|
||||
-- non-canvas must be rejected before it reaches love.graphics.draw, which
|
||||
-- would otherwise take the frame down with it.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Tilt mode: the upright billboard canvas is grown by this many world
|
||||
-- pixels on every side beyond the ground world view, so a structure or
|
||||
-- sprite standing near a view edge still draws in full instead of being
|
||||
@@ -57,6 +73,22 @@ function Renderer:init()
|
||||
-- the projected ground in endFrame; never touched while tilt is off.
|
||||
self.uprightCanvas = nil
|
||||
self.uprightActive = false
|
||||
-- a render pipeline's finished world image, already at window resolution
|
||||
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
|
||||
-- frame", which is every vanilla frame.
|
||||
self.worldOverride = nil
|
||||
end
|
||||
|
||||
-- Hand endFrame a pipeline's world image to composite instead of the world
|
||||
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
|
||||
-- straight back to the 2D path rather than showing a stale image.
|
||||
function Renderer:setWorldOverride(canvas)
|
||||
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
|
||||
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
|
||||
-- love.graphics.draw on it would crash the frame. Reject it and fall back
|
||||
-- to the 2D path rather than trust it.
|
||||
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
|
||||
self.worldOverride = canvas
|
||||
end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
@@ -91,6 +123,7 @@ end
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
@@ -380,7 +413,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
zones = withTrueColor(zones, "ui")
|
||||
worldZones = withTrueColor(worldZones, "world")
|
||||
|
||||
local needPresent = GBCFX.active()
|
||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||
-- same reason GBC FX does, so either one alone is enough to take the
|
||||
-- present path; with neither, the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
||||
local present = nil
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
@@ -441,7 +478,27 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
if self.worldActive then
|
||||
if self.worldOverride then
|
||||
-- A render pipeline already produced the whole world -- terrain,
|
||||
-- characters and its own FX overlay -- as one window-resolution image,
|
||||
-- so it composites with a straight 1:1 blit and the world canvas is
|
||||
-- skipped entirely (nothing drew into it). The UI blit below still
|
||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
|
||||
love.graphics.setScissor()
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
|
||||
end
|
||||
elseif self.worldActive then
|
||||
local sp = Zoom.scale(Sp)
|
||||
local s = sp / dpi
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
@@ -526,11 +583,26 @@ function Renderer:endFrame(zones, worldZones)
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(present, Sp)
|
||||
-- Post-process pipelines run over the finished composite -- world, UI
|
||||
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||
-- LCD grid is then drawn over rather than something that smears the
|
||||
-- grid itself. Each pass hands back a canvas; with none registered
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
local composed = Pipelines.present(present,
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
end
|
||||
end
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
PaletteFX.setPass(nil)
|
||||
end
|
||||
|
||||
|
||||
@@ -65,8 +65,12 @@ end
|
||||
|
||||
Assets.register(SpriteRenderer.invalidate)
|
||||
|
||||
-- exported: a render pipeline's own sprite geometry picks frames by the
|
||||
-- same tables, so a 3D pose can never drift from the 2D one
|
||||
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||
SpriteRenderer.STAND = STAND
|
||||
SpriteRenderer.WALK = WALK
|
||||
|
||||
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||
@@ -83,6 +87,29 @@ function SpriteRenderer.new(spriteDef, seed)
|
||||
return self
|
||||
end
|
||||
|
||||
-- The image this sprite would draw from right now: the plain sheet, or the
|
||||
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
||||
-- own geometry from the very same image -- the geometry carries sheet pixel
|
||||
-- coordinates rather than baked colors, so sharing this one resolver is
|
||||
-- what makes palette modes and sprite-replacing mods apply to 2D and 3D
|
||||
-- alike.
|
||||
--
|
||||
-- Deliberately free of draw's bookkeeping: markTrueColor and
|
||||
-- markSpriteRedraw exist to patch up the screen-space zone shader, and a
|
||||
-- pipeline that renders into its own canvas never runs through it. For the
|
||||
-- same reason the OG-RED bake is returned unconditionally here rather than
|
||||
-- only during a redraw pass -- there is no later pass to restore it.
|
||||
function SpriteRenderer:resolveImage()
|
||||
if self.def.trueColor then return self.image end
|
||||
if PaletteFX.usesGbcPack() then
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then return getObpImage(self.def.image, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() then
|
||||
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
return self.image
|
||||
end
|
||||
|
||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
||||
|
||||
+68
-23
@@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.charIndex = 0
|
||||
self.shown = {} -- visible lines (max 2), each a list of glyph codes
|
||||
self.waiting = false
|
||||
self.contAdvance = false
|
||||
self.done = false
|
||||
self.blink = 0
|
||||
self:beginLine()
|
||||
@@ -65,7 +66,11 @@ TextBox.TOKENS = {
|
||||
PLAYER = function(game) return game.save.player.name or "RED" end,
|
||||
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
|
||||
RAM = function(game, arg)
|
||||
return arg == "wStringBuffer" and game.stringBuffer or nil
|
||||
if arg == "wStringBuffer" then return game.stringBuffer end
|
||||
if arg == "wBoxNumString" then return game.boxNumString end
|
||||
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
||||
if arg == "wBoxMonNicks" then return game.boxMonNicks end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -83,31 +88,55 @@ end
|
||||
|
||||
-- Split marked-up text into pages of lines. \v-scrolled lines become
|
||||
-- additional lines on the same page (the box scrolls them).
|
||||
-- pages.contBefore[p][i] is true when line i was preceded by \v (cont):
|
||||
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
||||
function TextBox.paginate(text, maxCols)
|
||||
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
||||
local pages = {}
|
||||
local contBefore = {}
|
||||
local function pushLine(lines, conts, line, wait)
|
||||
while #line > maxCols do
|
||||
local cut = maxCols
|
||||
for i = maxCols, 1, -1 do
|
||||
if line:sub(i, i) == " " then cut = i break end
|
||||
end
|
||||
table.insert(lines, line:sub(1, cut))
|
||||
table.insert(conts, wait)
|
||||
wait = false
|
||||
line = line:sub(cut + 1)
|
||||
end
|
||||
table.insert(lines, line)
|
||||
table.insert(conts, wait)
|
||||
end
|
||||
for pageText in (text .. "\f"):gmatch("(.-)\f") do
|
||||
if pageText ~= "" then
|
||||
local lines = {}
|
||||
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
|
||||
local line = chunk
|
||||
-- wrap long lines defensively (the source rarely needs it)
|
||||
while #line > maxCols do
|
||||
local cut = maxCols
|
||||
for i = maxCols, 1, -1 do
|
||||
if line:sub(i, i) == " " then cut = i break end
|
||||
end
|
||||
table.insert(lines, line:sub(1, cut))
|
||||
line = line:sub(cut + 1)
|
||||
local lines, conts = {}, {}
|
||||
local pos, waitNext = 1, false
|
||||
while true do
|
||||
local npos = pageText:find("[\n\v]", pos)
|
||||
if not npos then
|
||||
pushLine(lines, conts, pageText:sub(pos), waitNext)
|
||||
break
|
||||
end
|
||||
table.insert(lines, line)
|
||||
pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext)
|
||||
waitNext = pageText:sub(npos, npos) == "\v"
|
||||
pos = npos + 1
|
||||
end
|
||||
if lines[#lines] == "" then
|
||||
table.remove(lines)
|
||||
table.remove(conts)
|
||||
end
|
||||
if #lines > 0 then
|
||||
table.insert(pages, lines)
|
||||
table.insert(contBefore, conts)
|
||||
end
|
||||
-- drop trailing empty line from the final gmatch round
|
||||
if lines[#lines] == "" then table.remove(lines) end
|
||||
if #lines > 0 then table.insert(pages, lines) end
|
||||
end
|
||||
end
|
||||
if #pages == 0 then pages = { { "" } } end
|
||||
if #pages == 0 then
|
||||
pages = { { "" } }
|
||||
contBefore = { { false } }
|
||||
end
|
||||
pages.contBefore = contBefore
|
||||
return pages
|
||||
end
|
||||
|
||||
@@ -179,10 +208,17 @@ function TextBox:update(dt)
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.waiting = false
|
||||
self.shown = {}
|
||||
self.pageIndex = self.pageIndex + 1
|
||||
self.lineIndex = 1
|
||||
self:beginLine()
|
||||
if self.contAdvance then
|
||||
-- ContText / ManualTextScroll: keep the box, scroll one line
|
||||
self.contAdvance = false
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
self:beginLine()
|
||||
else
|
||||
self.shown = {}
|
||||
self.pageIndex = self.pageIndex + 1
|
||||
self.lineIndex = 1
|
||||
self:beginLine()
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -203,10 +239,19 @@ function TextBox:update(dt)
|
||||
-- line finished
|
||||
local page = self.pages[self.pageIndex]
|
||||
if self.lineIndex < #page then
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
self:beginLine()
|
||||
local nextIdx = self.lineIndex + 1
|
||||
local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex]
|
||||
if conts and conts[nextIdx] then
|
||||
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
|
||||
self.waiting = true
|
||||
self.contAdvance = true
|
||||
else
|
||||
self.lineIndex = nextIdx
|
||||
self:beginLine()
|
||||
end
|
||||
elseif self.pageIndex < #self.pages then
|
||||
self.waiting = true
|
||||
self.contAdvance = false
|
||||
else
|
||||
self.done = true
|
||||
end
|
||||
|
||||
@@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors)
|
||||
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||
end
|
||||
|
||||
-- exported: a render pipeline bakes a map's palette into its own texture
|
||||
-- atlas the same way, and has to land on the identical colors as the 2D
|
||||
-- tiles it is standing in for
|
||||
TileRenderer.recolorSample = recolorSample
|
||||
|
||||
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
|
||||
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
||||
local shiftVariants = {}
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
-- Vanilla Gen1 (Red/Blue, international) raw SRAM save (32768 bytes) <->
|
||||
-- this project's save.lua shape (src/core/SaveData.lua / SaveData.newGame).
|
||||
--
|
||||
-- Pure Lua, no love.* dependency -- runs under plain luajit for the CLI
|
||||
-- (tools/save_convert/convert.lua) and headless tests alike.
|
||||
--
|
||||
-- Every offset below was derived mechanically from the authoritative
|
||||
-- source (../pokered/ram/wram.asm, ram/sram.asm, macros/ram.asm) and
|
||||
-- cross-checked against three independently well-known Gen1 save
|
||||
-- addresses: money @ 0x25F3, badges @ 0x2602, party data @ 0x2F2C --
|
||||
-- all three fall out exactly right from the single sPlayerName anchor
|
||||
-- below, strong triangulated confirmation the whole chain (SRAM bank 1
|
||||
-- layout, wMainData field order, party_struct/box_struct sizes) is right.
|
||||
--
|
||||
-- SRAM layout (32768 bytes = 4 banks x 8192): bank 0 is sprite buffers +
|
||||
-- Hall of Fame (not modeled -- see "explicitly out of scope" in the
|
||||
-- save-converter plan); bank 1 is "Save Data" (sPlayerName through
|
||||
-- sMainDataCheckSum); banks 2/3 are the 12 PC boxes (6 each) + checksums.
|
||||
--
|
||||
-- Fields with no equivalent in save.lua (current sprite/animation state,
|
||||
-- connection-header cache, Day Care, Safari Zone, HOF roster) are
|
||||
-- intentionally not modeled: on export, encode() starts from the
|
||||
-- ORIGINAL imported bytes as a template when available (GenSave.decode
|
||||
-- stashes them) so that scratch state round-trips untouched instead of
|
||||
-- being invented; with no template (a save that originated in this
|
||||
-- project) those bytes stay zero-filled, which is safe because the real
|
||||
-- game regenerates all of it from wCurMap on the next map load anyway.
|
||||
|
||||
local bit = require("bit")
|
||||
|
||||
local GenSave = {}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Absolute byte offsets (0-based, matching a raw 32768-byte .sav file)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local NAME_LENGTH = 11
|
||||
local PARTY_LENGTH = 6
|
||||
local MONS_PER_BOX = 20
|
||||
local NUM_BADGES = 8
|
||||
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
|
||||
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
|
||||
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
|
||||
local PARTY_STRUCT_SIZE = 44 -- box_struct + Level + Stats x5 (party_struct)
|
||||
local BOX_REGION_SIZE = 1 + (MONS_PER_BOX + 1) + MONS_PER_BOX * BOX_STRUCT_SIZE
|
||||
+ MONS_PER_BOX * NAME_LENGTH + MONS_PER_BOX * NAME_LENGTH -- 1122
|
||||
|
||||
local O = {}
|
||||
O.playerName = 9624 -- sPlayerName (11B) = 0x2598
|
||||
O.mainData = O.playerName + NAME_LENGTH -- sMainData (wMainDataStart mirror)
|
||||
O.pokedexOwned = O.mainData + 0 -- 19B (flag_array 151)
|
||||
O.pokedexSeen = O.mainData + 19 -- 19B
|
||||
O.numBagItems = O.mainData + 38 -- 1B
|
||||
O.bagItems = O.mainData + 39 -- 41B (20 x (id,qty) + $FF term)
|
||||
O.money = O.mainData + 80 -- 3B BCD = 0x25F3
|
||||
O.rivalName = O.mainData + 83 -- 11B
|
||||
O.options = O.mainData + 94 -- 1B
|
||||
O.badges = O.mainData + 95 -- 1B = 0x2602
|
||||
O.playerId = O.mainData + 98 -- 2B (big-endian)
|
||||
O.curMap = O.mainData + 103 -- 1B
|
||||
O.yCoord = O.mainData + 106 -- 1B
|
||||
O.xCoord = O.mainData + 107 -- 1B
|
||||
O.lastMap = O.mainData + 110 -- 1B
|
||||
O.numPcItems = O.mainData + 579 -- 1B
|
||||
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
|
||||
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
|
||||
O.coins = O.mainData + 685 -- 2B BCD
|
||||
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
-- wEventFlags anchor: 320 (event flag_array) + 293 (the wGrassRate/enemy-party
|
||||
-- battle UNION) + 66 + 66 (wEnemyMonOT/Nicks, 6 x NAME_LENGTH each; the
|
||||
-- rgbds FOR n,1,PARTY_LENGTH+1 loop is end-exclusive => 6 mons, not 7) + 2
|
||||
-- (wTrainerHeaderPtr) + 6 (ds) + 1 (wOpponentAfterWrongAnswer) + 1
|
||||
-- (wCurMapScript) + 7 (ds) = 762. Confirmed on the real fixture: those five
|
||||
-- bytes read 201h 30m 07s, a sane completed-save clock.
|
||||
O.playTimeHours = O.mainData + 1866 -- 1B
|
||||
O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 255h)
|
||||
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
|
||||
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
|
||||
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
|
||||
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
|
||||
|
||||
O.spriteData = O.mainData + O.mainDataSize
|
||||
O.spriteDataSize = 512 -- 2 x 16 sprites x 16B
|
||||
|
||||
O.partyData = O.spriteData + O.spriteDataSize -- = 0x2F2C
|
||||
O.partyCount = O.partyData
|
||||
O.partySpecies = O.partyData + 1 -- 7B (PARTY_LENGTH+1)
|
||||
O.partyMons = O.partyData + 8 -- 6 x 44B
|
||||
O.partyMonOT = O.partyData + 8 + PARTY_LENGTH * PARTY_STRUCT_SIZE
|
||||
O.partyMonNicks = O.partyMonOT + PARTY_LENGTH * NAME_LENGTH
|
||||
O.partyDataSize = 1 + (PARTY_LENGTH + 1) + PARTY_LENGTH * PARTY_STRUCT_SIZE
|
||||
+ PARTY_LENGTH * NAME_LENGTH + PARTY_LENGTH * NAME_LENGTH -- 404
|
||||
|
||||
O.curBoxData = O.partyData + O.partyDataSize
|
||||
O.boxCount = O.curBoxData
|
||||
O.boxSpecies = O.curBoxData + 1 -- 21B
|
||||
O.boxMons = O.curBoxData + 22 -- 20 x 33B
|
||||
O.boxMonOT = O.curBoxData + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE
|
||||
O.boxMonNicks = O.boxMonOT + MONS_PER_BOX * NAME_LENGTH
|
||||
|
||||
O.checksumStart = O.playerName
|
||||
O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B)
|
||||
O.mainChecksum = O.checksumEnd -- 1B
|
||||
|
||||
O.box1 = 16384 -- bank 2 start
|
||||
O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE
|
||||
O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B
|
||||
O.box7 = 24576 -- bank 3 start
|
||||
O.boxBank3Checksum = O.box7 + 6 * BOX_REGION_SIZE
|
||||
O.boxBank3IndividualChecksums = O.boxBank3Checksum + 1 -- 6B
|
||||
|
||||
GenSave.OFFSETS = O
|
||||
GenSave.BOX_REGION_SIZE = BOX_REGION_SIZE
|
||||
GenSave.SAVE_SIZE = 32768
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Byte-level helpers. `bytes` is a 32768-byte Lua string (1-based
|
||||
-- indexing, so byte offset N is string position N+1); `buf` for writing
|
||||
-- is a 32768-entry array of 1-char strings, joined at the end.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function u8(bytes, off) return bytes:byte(off + 1) end
|
||||
local function u16be(bytes, off) return u8(bytes, off) * 256 + u8(bytes, off + 1) end
|
||||
local function u24be(bytes, off)
|
||||
return u8(bytes, off) * 65536 + u8(bytes, off + 1) * 256 + u8(bytes, off + 2)
|
||||
end
|
||||
|
||||
local function setByte(buf, off, v)
|
||||
buf[off + 1] = string.char(bit.band(v, 0xFF))
|
||||
end
|
||||
local function setU16be(buf, off, v)
|
||||
setByte(buf, off, bit.band(bit.rshift(v, 8), 0xFF))
|
||||
setByte(buf, off + 1, bit.band(v, 0xFF))
|
||||
end
|
||||
local function setU24be(buf, off, v)
|
||||
setByte(buf, off, bit.band(bit.rshift(v, 16), 0xFF))
|
||||
setByte(buf, off + 1, bit.band(bit.rshift(v, 8), 0xFF))
|
||||
setByte(buf, off + 2, bit.band(v, 0xFF))
|
||||
end
|
||||
local function setBcd(buf, off, nbytes, v)
|
||||
for i = nbytes - 1, 0, -1 do
|
||||
local d = v % 100
|
||||
v = math.floor(v / 100)
|
||||
setByte(buf, off + i, math.floor(d / 10) * 16 + (d % 10))
|
||||
end
|
||||
end
|
||||
local function readBcd(bytes, off, nbytes)
|
||||
local n = 0
|
||||
for i = 0, nbytes - 1 do
|
||||
local b = u8(bytes, off + i)
|
||||
n = n * 100 + math.floor(b / 16) * 10 + (b % 16)
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- CalcCheckSum (engine/menus/save.asm): complement of the additive sum
|
||||
local function checksum(bytes, from, to)
|
||||
local sum = 0
|
||||
for i = from, to - 1 do sum = bit.band(sum + u8(bytes, i), 0xFF) end
|
||||
return bit.band(bit.bnot(sum), 0xFF)
|
||||
end
|
||||
|
||||
-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0).
|
||||
-- This is pokered's runtime FlagAction convention (home/predef macros): it
|
||||
-- takes flag number N, addresses byte N/8, and builds the mask by rotating
|
||||
-- a 1 left N%8 times starting from bit 0 -- i.e. flag N%8==0 is the LSB.
|
||||
-- Same convention PKHeX uses for Gen1 dex/event flags (FlagUtil.GetFlag:
|
||||
-- data[ofs + bit/8] >> (bit%8) & 1). Cross-validated against the real save:
|
||||
-- ZAPDOS (dex 145) is physically boxed there, so its owned/seen flag must be
|
||||
-- set; only the LSB reading returns it set (MSB-first spuriously drops
|
||||
-- exactly that one bit at the byte-18 boundary), yielding a complete 151/151
|
||||
-- dex. The prior MSB-first code round-tripped self-consistently but decoded
|
||||
-- every flag_array (pokedex AND event flags) to the wrong bit.
|
||||
local function bitGet(bytes, base, index)
|
||||
local byteOff = base + math.floor(index / 8)
|
||||
local b = u8(bytes, byteOff)
|
||||
return bit.band(bit.rshift(b, index % 8), 1) == 1
|
||||
end
|
||||
|
||||
-- Set one bit directly in `buf` (0-based flag index into a flag_array
|
||||
-- starting at `base`), preserving every other bit already in that byte --
|
||||
-- template bytes (see encode()'s header note) survive for bits this pass
|
||||
-- never explicitly touches, e.g. event-flag bits with no known name
|
||||
-- sharing a byte with ones that do.
|
||||
local function bitSet(buf, base, index, value)
|
||||
local byteOff = base + math.floor(index / 8)
|
||||
local bitIdx = index % 8
|
||||
local cur = buf[byteOff + 1] and buf[byteOff + 1]:byte() or 0
|
||||
local mask = bit.lshift(1, bitIdx)
|
||||
setByte(buf, byteOff, value and bit.bor(cur, mask) or bit.band(cur, bit.bnot(mask)))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Text (fixed-length name fields: charmap-encoded, "@" ($50) terminated,
|
||||
-- $50-padded after the terminator). setCharmap(cm) must be called once
|
||||
-- before decode/encode (src/save_convert/data/charmap.lua's shape).
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local charmap
|
||||
|
||||
function GenSave.setCharmap(cm) charmap = cm end
|
||||
|
||||
local function decodeName(bytes, off, len)
|
||||
local out = {}
|
||||
for i = 0, len - 1 do
|
||||
local b = u8(bytes, off + i)
|
||||
if b == 0x50 then break end
|
||||
out[#out + 1] = charmap.byByte[b] or "?"
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function encodeName(buf, off, len, text)
|
||||
local i, pos = 0, 1
|
||||
while i < len - 1 and pos <= #text do
|
||||
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
|
||||
-- byte with no plain-glyph mapping) is ONE game character despite
|
||||
-- being several text bytes here; match it as a whole unit first, or
|
||||
-- it would fall through to per-byte matching and turn into "?" x5
|
||||
local bracket = text:match("^(<[^<>]*>)", pos)
|
||||
local ch, clen
|
||||
if bracket and charmap.byToken[bracket] then
|
||||
ch, clen = bracket, #bracket
|
||||
else
|
||||
local b0 = text:byte(pos)
|
||||
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
|
||||
ch = text:sub(pos, pos + clen - 1)
|
||||
end
|
||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||
i, pos = i + 1, pos + clen
|
||||
end
|
||||
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
|
||||
-- left untouched: when encoding over a template they stay as the original
|
||||
-- save's post-terminator padding (so an unchanged name round-trips
|
||||
-- byte-identical), and on a templateless export they stay zero-filled. The
|
||||
-- game reads a name only up to the first $50, so whatever follows is inert.
|
||||
if i < len then setByte(buf, off + i, 0x50) end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- DV / PP packing (box_struct DVs, PP)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- DVs:: dw, packed as byte0=(Attack<<4)|Defense, byte1=(Speed<<4)|Special;
|
||||
-- HP DV is derived, not stored, from each stat DV's low bit.
|
||||
local function decodeDVs(bytes, off)
|
||||
local b0, b1 = u8(bytes, off), u8(bytes, off + 1)
|
||||
local atk, def = bit.rshift(b0, 4), bit.band(b0, 0xF)
|
||||
local spe, spc = bit.rshift(b1, 4), bit.band(b1, 0xF)
|
||||
local hp = bit.bor(bit.lshift(bit.band(atk, 1), 3), bit.lshift(bit.band(def, 1), 2),
|
||||
bit.lshift(bit.band(spe, 1), 1), bit.band(spc, 1))
|
||||
return { hp = hp, attack = atk, defense = def, speed = spe, special = spc }
|
||||
end
|
||||
|
||||
local function encodeDVs(buf, off, dvs)
|
||||
setByte(buf, off, bit.bor(bit.lshift(bit.band(dvs.attack or 0, 0xF), 4), bit.band(dvs.defense or 0, 0xF)))
|
||||
setByte(buf, off + 1, bit.bor(bit.lshift(bit.band(dvs.speed or 0, 0xF), 4), bit.band(dvs.special or 0, 0xF)))
|
||||
end
|
||||
|
||||
-- PP byte: top 2 bits = PP Up count (0-3), bottom 6 bits = current PP
|
||||
local function decodePPByte(b) return bit.band(b, 0x3F), bit.rshift(b, 6) end
|
||||
local function encodePPByte(pp, ppUps) return bit.bor(bit.lshift(bit.band(ppUps or 0, 3), 6), bit.band(pp or 0, 0x3F)) end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Crosswalks (built once from `data` = {pokemon=,moves=,items=,maps=})
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- pokered constants/type_constants.asm PHYSICAL/SPECIAL block; stable,
|
||||
-- not worth a dedicated extractor for 15 names.
|
||||
local TYPE_BY_INDEX = {
|
||||
[0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON",
|
||||
[4] = "GROUND", [5] = "ROCK", [6] = "BIRD", [7] = "BUG", [8] = "GHOST",
|
||||
[20] = "FIRE", [21] = "WATER", [22] = "GRASS", [23] = "ELECTRIC",
|
||||
[24] = "PSYCHIC_TYPE", [25] = "ICE", [26] = "DRAGON",
|
||||
}
|
||||
local TYPE_INDEX = {}
|
||||
for i, name in pairs(TYPE_BY_INDEX) do TYPE_INDEX[name] = i end
|
||||
|
||||
-- Badge bit order (constants/ram_constants.asm BIT_BOULDERBADGE=0 ..
|
||||
-- BIT_EARTHBADGE=7); this project stores badges as truthy
|
||||
-- save.inventory[id] entries, not a flag or a separate bitmask
|
||||
-- (src/inventory/Badges.lua Badges.list's VANILLA order matches exactly).
|
||||
local BADGE_BY_BIT = {
|
||||
[0] = "BOULDERBADGE", [1] = "CASCADEBADGE", [2] = "THUNDERBADGE",
|
||||
[3] = "RAINBOWBADGE", [4] = "SOULBADGE", [5] = "MARSHBADGE",
|
||||
[6] = "VOLCANOBADGE", [7] = "EARTHBADGE",
|
||||
}
|
||||
local BADGE_BY_BIT_SET = {}
|
||||
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
|
||||
|
||||
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
|
||||
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
|
||||
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
||||
local function decodeStatus(b)
|
||||
if bit.band(b, 7) > 0 then return "SLP" end
|
||||
for name, bitIdx in pairs(STATUS_BIT) do
|
||||
if bit.band(b, bit.lshift(1, bitIdx)) ~= 0 then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function encodeStatus(status)
|
||||
if status == "SLP" then return 7 end
|
||||
if status and STATUS_BIT[status] then return bit.lshift(1, STATUS_BIT[status]) end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function buildIndexCrosswalk(defs)
|
||||
local byIndex, byId = {}, {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if def.index ~= nil then
|
||||
byIndex[def.index] = id
|
||||
byId[id] = def.index
|
||||
end
|
||||
end
|
||||
return byIndex, byId
|
||||
end
|
||||
|
||||
-- Pokedex bit position: NATIONAL DEX NUMBER (1-151), NOT the internal ROM
|
||||
-- species byte (`def.index`, used for party/box mon structs) -- these are
|
||||
-- two completely different Gen1 numbering schemes (the whole "MissingNo"
|
||||
-- phenomenon is dex-number vs internal-index mismatches). This project's
|
||||
-- generated data has no dedicated dex-number field, but every pokemon.lua
|
||||
-- entry's `source` documents its extraction origin as "ROM:BaseStats[N]",
|
||||
-- and BaseStats is declared in dex order in the disassembly -- verified
|
||||
-- directly against 5 species (BULBASAUR->[1], CHARMANDER->[4],
|
||||
-- SQUIRTLE->[7], PIKACHU->[25], MEWTWO->[150], all exactly their real
|
||||
-- national dex numbers) before relying on it here.
|
||||
local function buildDexCrosswalk(defs)
|
||||
local byDex, dexOf = {}, {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]"))
|
||||
if n then
|
||||
byDex[n] = id
|
||||
dexOf[id] = n
|
||||
end
|
||||
end
|
||||
return byDex, dexOf
|
||||
end
|
||||
|
||||
-- TM/HM item entries carry no `index` (data/generated/items.lua extracts
|
||||
-- them by move/slot, not by their place in the raw item-constant table),
|
||||
-- so buildIndexCrosswalk alone would silently drop every TM/HM from the
|
||||
-- bag/PC on encode. Their real item ids ARE derivable: pokered's
|
||||
-- constants/item_constants.asm declares "HM_\1: the item id, starting at
|
||||
-- $C4" and "TM_\1: the item id, starting at $C9" for slot 1, incrementing
|
||||
-- per slot -- i.e. HM01=196+.. , TM01=201+(number-1).
|
||||
local function addMachineIndices(defs, byIndex, byId)
|
||||
for id, def in pairs(defs or {}) do
|
||||
if byId[id] == nil and def.machine and def.machine.number then
|
||||
local base = def.machine.kind == "HM" and 195 or 200
|
||||
local idx = base + def.machine.number
|
||||
byIndex[idx] = id
|
||||
byId[id] = idx
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function GenSave.crosswalks(data)
|
||||
local pokemonByIndex, pokemonIndex = buildIndexCrosswalk(data.pokemon)
|
||||
local movesByIndex, movesIndex = buildIndexCrosswalk(data.moves)
|
||||
local itemsByIndex, itemsIndex = buildIndexCrosswalk(data.items)
|
||||
addMachineIndices(data.items, itemsByIndex, itemsIndex)
|
||||
local mapsByIndex, mapsIndex = buildIndexCrosswalk(data.maps)
|
||||
local pokemonByDex, pokemonDex = buildDexCrosswalk(data.pokemon)
|
||||
return {
|
||||
pokemonByIndex = pokemonByIndex, pokemonIndex = pokemonIndex,
|
||||
pokemonByDex = pokemonByDex, pokemonDex = pokemonDex,
|
||||
movesByIndex = movesByIndex, movesIndex = movesIndex,
|
||||
itemsByIndex = itemsByIndex, itemsIndex = itemsIndex,
|
||||
mapsByIndex = mapsByIndex, mapsIndex = mapsIndex,
|
||||
speciesDefs = data.pokemon or {},
|
||||
}
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
|
||||
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
|
||||
-- Type1/Type2 are read for nothing (this project derives type from
|
||||
-- species) but re-derived from data.pokemon[species].types on encode.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function decodeMon(bytes, off, isParty, cw)
|
||||
local speciesIdx = u8(bytes, off)
|
||||
if speciesIdx == 0 then return nil end -- empty slot
|
||||
local species = cw.pokemonByIndex[speciesIdx]
|
||||
local hp = u16be(bytes, off + 1)
|
||||
local boxLevel = u8(bytes, off + 3)
|
||||
local status = decodeStatus(u8(bytes, off + 4))
|
||||
local catchRate = u8(bytes, off + 7)
|
||||
local moves = {}
|
||||
for i = 0, 3 do
|
||||
local moveIdx = u8(bytes, off + 8 + i)
|
||||
if moveIdx > 0 then
|
||||
local pp, ppUps = decodePPByte(u8(bytes, off + 29 + i))
|
||||
moves[#moves + 1] = { id = cw.movesByIndex[moveIdx], pp = pp, ppUps = ppUps }
|
||||
end
|
||||
end
|
||||
local otId = u16be(bytes, off + 12)
|
||||
local exp = u24be(bytes, off + 14)
|
||||
local statExp = {
|
||||
hp = u16be(bytes, off + 17), attack = u16be(bytes, off + 19),
|
||||
defense = u16be(bytes, off + 21), speed = u16be(bytes, off + 23),
|
||||
special = u16be(bytes, off + 25),
|
||||
}
|
||||
local dvs = decodeDVs(bytes, off + 27)
|
||||
local mon = {
|
||||
species = species, exp = exp, dvs = dvs, statExp = statExp,
|
||||
hp = hp, status = status, moves = moves, otId = otId,
|
||||
catchRate = catchRate, level = boxLevel,
|
||||
-- Type1/Type2 as physically stored. This project derives type from species
|
||||
-- for gameplay, but the raw bytes are captured so encode() can reproduce
|
||||
-- them verbatim: some real saves (traded/tampered mons) carry type values
|
||||
-- that do not match the ROM base stats, and re-deriving would corrupt them.
|
||||
typeBytes = { u8(bytes, off + 5), u8(bytes, off + 6) },
|
||||
}
|
||||
if isParty then
|
||||
mon.level = u8(bytes, off + 33)
|
||||
mon.stats = {
|
||||
hp = u16be(bytes, off + 34), attack = u16be(bytes, off + 36),
|
||||
defense = u16be(bytes, off + 38), speed = u16be(bytes, off + 40),
|
||||
special = u16be(bytes, off + 42),
|
||||
}
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
local function encodeMon(buf, off, mon, isParty, cw)
|
||||
if not mon then
|
||||
setByte(buf, off, 0)
|
||||
return
|
||||
end
|
||||
setByte(buf, off, cw.pokemonIndex[mon.species] or 0)
|
||||
setU16be(buf, off + 1, mon.hp or 0)
|
||||
setByte(buf, off + 3, mon.level or 1)
|
||||
setByte(buf, off + 4, encodeStatus(mon.status))
|
||||
local def = cw.speciesDefs[mon.species]
|
||||
if mon.typeBytes then
|
||||
-- reproduce the exact stored type bytes captured on decode (faithful
|
||||
-- byte round-trip); fresh, engine-built mons have none and derive below.
|
||||
setByte(buf, off + 5, mon.typeBytes[1] or 0)
|
||||
setByte(buf, off + 6, mon.typeBytes[2] or 0)
|
||||
else
|
||||
local t = (def and def.types) or {}
|
||||
setByte(buf, off + 5, TYPE_INDEX[t[1]] or 0)
|
||||
setByte(buf, off + 6, TYPE_INDEX[t[2] or t[1]] or 0)
|
||||
end
|
||||
setByte(buf, off + 7, mon.catchRate or (def and def.catchRate) or 0)
|
||||
for i = 0, 3 do
|
||||
local mv = mon.moves and mon.moves[i + 1]
|
||||
setByte(buf, off + 8 + i, mv and (cw.movesIndex[mv.id] or 0) or 0)
|
||||
setByte(buf, off + 29 + i, mv and encodePPByte(mv.pp, mv.ppUps) or 0)
|
||||
end
|
||||
setU16be(buf, off + 12, mon.otId or 0)
|
||||
setU24be(buf, off + 14, mon.exp or 0)
|
||||
local se = mon.statExp or {}
|
||||
setU16be(buf, off + 17, se.hp or 0)
|
||||
setU16be(buf, off + 19, se.attack or 0)
|
||||
setU16be(buf, off + 21, se.defense or 0)
|
||||
setU16be(buf, off + 23, se.speed or 0)
|
||||
setU16be(buf, off + 25, se.special or 0)
|
||||
encodeDVs(buf, off + 27, mon.dvs or {})
|
||||
if isParty then
|
||||
setByte(buf, off + 33, mon.level or 1)
|
||||
local st = mon.stats or {}
|
||||
setU16be(buf, off + 34, st.hp or 0)
|
||||
setU16be(buf, off + 36, st.attack or 0)
|
||||
setU16be(buf, off + 38, st.defense or 0)
|
||||
setU16be(buf, off + 40, st.speed or 0)
|
||||
setU16be(buf, off + 42, st.special or 0)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Bag / PC items: (id, qty) byte pairs, $FF-terminated
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function decodeItemList(bytes, off, capacity, cw)
|
||||
local inventory, order = {}, {}
|
||||
for i = 0, capacity - 1 do
|
||||
local idByte = u8(bytes, off + i * 2)
|
||||
if idByte == 0xFF then break end
|
||||
local qty = u8(bytes, off + i * 2 + 1)
|
||||
local id = cw.itemsByIndex[idByte]
|
||||
if id then
|
||||
inventory[id] = qty
|
||||
order[#order + 1] = id
|
||||
end
|
||||
end
|
||||
return inventory, order
|
||||
end
|
||||
|
||||
local function encodeItemList(buf, off, capacity, inventory, order, cw)
|
||||
local i = 0
|
||||
local seen = {}
|
||||
local function put(id, qty)
|
||||
if i >= capacity or not qty or qty <= 0 then return end
|
||||
local idByte = cw.itemsIndex[id]
|
||||
if not idByte then return end
|
||||
setByte(buf, off + i * 2, idByte)
|
||||
setByte(buf, off + i * 2 + 1, math.min(qty, 99))
|
||||
i = i + 1
|
||||
seen[id] = true
|
||||
end
|
||||
for _, id in ipairs(order or {}) do
|
||||
if inventory[id] and not seen[id] then put(id, inventory[id]) end
|
||||
end
|
||||
for id, qty in pairs(inventory or {}) do
|
||||
if not seen[id] then put(id, qty) end
|
||||
end
|
||||
setByte(buf, off + i * 2, 0xFF)
|
||||
return i -- count actually written (badges etc. in `inventory` that
|
||||
-- aren't real items are silently skipped by put(), so this
|
||||
-- can be less than #inventory -- see the wNumBagItems caller)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- decode: raw 32768-byte SRAM string -> save.lua-shaped table
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
function GenSave.decode(bytes, data, opts)
|
||||
assert(#bytes == GenSave.SAVE_SIZE, "expected a 32768-byte save")
|
||||
local cw = GenSave.crosswalks(data)
|
||||
local warnings = {}
|
||||
local function warn(msg) warnings[#warnings + 1] = msg end
|
||||
|
||||
if checksum(bytes, O.checksumStart, O.checksumEnd) ~= u8(bytes, O.mainChecksum) then
|
||||
warn("main data checksum mismatch (importing anyway)")
|
||||
end
|
||||
|
||||
local save = {
|
||||
meta = { format = "gen1_import" },
|
||||
player = {
|
||||
name = decodeName(bytes, O.playerName, NAME_LENGTH),
|
||||
rival = decodeName(bytes, O.rivalName, NAME_LENGTH),
|
||||
id = u16be(bytes, O.playerId),
|
||||
},
|
||||
money = readBcd(bytes, O.money, 3),
|
||||
coins = readBcd(bytes, O.coins, 2),
|
||||
inventory = {},
|
||||
pcItems = {},
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
flags = {},
|
||||
party = {},
|
||||
boxes = {},
|
||||
currentBox = 1,
|
||||
}
|
||||
|
||||
-- pokedex (own/seen, flag_array NUM_POKEMON: bit 0 = dex #1)
|
||||
for dex, species in pairs(cw.pokemonByDex) do
|
||||
local bitIdx = dex - 1
|
||||
if bitGet(bytes, O.pokedexOwned, bitIdx) then save.pokedex.owned[species] = true end
|
||||
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
|
||||
end
|
||||
|
||||
save.inventory, save.bagOrder = decodeItemList(bytes, O.bagItems, 20, cw)
|
||||
save.pcItems, save.pcOrder = decodeItemList(bytes, O.pcItems, 50, cw)
|
||||
|
||||
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
|
||||
-- set AFTER decodeItemList since that call replaces save.inventory
|
||||
local badgesByte = u8(bytes, O.badges)
|
||||
for i = 0, NUM_BADGES - 1 do
|
||||
if bit.band(badgesByte, bit.lshift(1, i)) ~= 0 then
|
||||
save.inventory[BADGE_BY_BIT[i]] = 1
|
||||
end
|
||||
end
|
||||
|
||||
-- party
|
||||
local partyCount = u8(bytes, O.partyCount)
|
||||
for i = 0, math.min(partyCount, PARTY_LENGTH) - 1 do
|
||||
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
|
||||
save.party[#save.party + 1] = mon
|
||||
end
|
||||
end
|
||||
|
||||
-- current box (bank 1) + the 11 stored boxes (banks 2/3)
|
||||
for i = 1, 12 do save.boxes[i] = {} end
|
||||
local function decodeBoxRegion(base, boxNum)
|
||||
local count = u8(bytes, base)
|
||||
for i = 0, math.min(count, MONS_PER_BOX) - 1 do
|
||||
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
|
||||
table.insert(save.boxes[boxNum], mon)
|
||||
end
|
||||
end
|
||||
end
|
||||
local curBoxNum = bit.band(u8(bytes, O.currentBoxNum), 0x7F) -- 0-based box index
|
||||
curBoxNum = math.max(1, math.min(12, curBoxNum + 1))
|
||||
decodeBoxRegion(O.curBoxData, curBoxNum)
|
||||
for b = 1, 6 do
|
||||
if b + 0 ~= curBoxNum then decodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, b) end
|
||||
end
|
||||
for b = 7, 12 do
|
||||
if b ~= curBoxNum then decodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, b) end
|
||||
end
|
||||
save.currentBox = curBoxNum
|
||||
|
||||
-- event flags (only bits with a known name are decoded)
|
||||
local events = data.eventFlags
|
||||
if events then
|
||||
for bitIdx, name in pairs(events.byBit) do
|
||||
if bitGet(bytes, O.eventFlags, bitIdx) then save.flags[name] = true end
|
||||
end
|
||||
end
|
||||
|
||||
-- map + position
|
||||
local mapIdx = u8(bytes, O.curMap)
|
||||
local mapId = cw.mapsByIndex[mapIdx]
|
||||
local y, x = u8(bytes, O.yCoord), u8(bytes, O.xCoord)
|
||||
if mapId then
|
||||
save.player.map, save.player.x, save.player.y = mapId, x, y
|
||||
else
|
||||
warn(("unknown map index %d, defaulting spawn"):format(mapIdx))
|
||||
end
|
||||
local lastMapIdx = u8(bytes, O.lastMap)
|
||||
local lastMapId = cw.mapsByIndex[lastMapIdx]
|
||||
if lastMapId then save.lastOutdoor = { id = lastMapId } end
|
||||
|
||||
-- play time: this project stores save.playTime as a single float of
|
||||
-- SECONDS (src/core/Game.lua accumulates dt each frame; StartMenu /
|
||||
-- TrainerCard / TitleState render it H:MM via t/3600 and (t/60)%60).
|
||||
-- Fold the Gen1 H/M/S/F fields into that one number; frames are 1/60s
|
||||
-- sub-second ticks, kept as a fraction so an export recovers them exactly.
|
||||
save.playTime = u8(bytes, O.playTimeHours) * 3600
|
||||
+ u8(bytes, O.playTimeMinutes) * 60
|
||||
+ u8(bytes, O.playTimeSeconds)
|
||||
+ u8(bytes, O.playTimeFrames) / 60
|
||||
|
||||
save.warnings = warnings
|
||||
save.rawImport = bytes -- template for a later encode(); see file header
|
||||
return save
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- encode: save.lua-shaped table -> raw 32768-byte SRAM string
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
function GenSave.encode(save, data, template)
|
||||
local cw = GenSave.crosswalks(data)
|
||||
local src = template or save.rawImport
|
||||
local buf = {}
|
||||
if src then
|
||||
for i = 1, GenSave.SAVE_SIZE do buf[i] = src:sub(i, i) end
|
||||
else
|
||||
local zero = string.char(0)
|
||||
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
|
||||
end
|
||||
|
||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
||||
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
|
||||
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
|
||||
|
||||
local badgesByte = 0
|
||||
for bitIdx, name in pairs(BADGE_BY_BIT) do
|
||||
if save.inventory and save.inventory[name] then
|
||||
badgesByte = bit.bor(badgesByte, bit.lshift(1, bitIdx))
|
||||
end
|
||||
end
|
||||
setByte(buf, O.badges, badgesByte)
|
||||
|
||||
for dex, species in pairs(cw.pokemonByDex) do
|
||||
local bitIdx = dex - 1
|
||||
bitSet(buf, O.pokedexOwned, bitIdx,
|
||||
(save.pokedex and save.pokedex.owned and save.pokedex.owned[species]) and true or false)
|
||||
bitSet(buf, O.pokedexSeen, bitIdx,
|
||||
(save.pokedex and save.pokedex.seen and save.pokedex.seen[species]) and true or false)
|
||||
end
|
||||
|
||||
-- Badges occupy real item IDs in data/generated/items.lua (Gen1's item
|
||||
-- ID space includes them, $01-$08, for the "got the BOULDERBADGE!"
|
||||
-- text display), but this project's save.lua stores them as truthy
|
||||
-- save.inventory[id] entries alongside actual bag items (see the badge
|
||||
-- block above and src/inventory/Badges.lua) -- a real save NEVER
|
||||
-- writes them into wBagItems (they only ever live in wObtainedBadges,
|
||||
-- already encoded above), so they must be filtered out here or they'd
|
||||
-- corrupt the bag with bogus "badge items".
|
||||
local bagInventory = {}
|
||||
for id, qty in pairs(save.inventory or {}) do
|
||||
if not BADGE_BY_BIT_SET[id] then bagInventory[id] = qty end
|
||||
end
|
||||
local bagN = encodeItemList(buf, O.bagItems, 20, bagInventory, save.bagOrder, cw)
|
||||
setByte(buf, O.numBagItems, bagN)
|
||||
local pcN = encodeItemList(buf, O.pcItems, 50, save.pcItems or {}, save.pcOrder, cw)
|
||||
setByte(buf, O.numPcItems, pcN)
|
||||
|
||||
local events = data.eventFlags
|
||||
if events and save.flags then
|
||||
for name in pairs(save.flags) do
|
||||
local bitIdx = events.byName[name]
|
||||
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||
end
|
||||
end
|
||||
|
||||
-- party
|
||||
local party = save.party or {}
|
||||
local partyN = math.min(#party, PARTY_LENGTH)
|
||||
setByte(buf, O.partyCount, partyN)
|
||||
for i = 0, partyN - 1 do
|
||||
local mon = party[i + 1]
|
||||
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
|
||||
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or mon.species or "")
|
||||
end
|
||||
-- $FF-terminate the species index list right after the last real mon. The
|
||||
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
||||
-- exactly as the template holds them (original stale data -> byte-identical
|
||||
-- round-trip) or zero on a fresh export -- the game never reads past the
|
||||
-- count, so this matches how it leaves those bytes itself.
|
||||
setByte(buf, O.partySpecies + partyN, 0xFF)
|
||||
|
||||
-- boxes: current box mirrors save.currentBox into sCurBoxData; all 12
|
||||
-- also get written into their bank-2/3 slot (sCurBoxData is a working
|
||||
-- copy the real game keeps in sync on every PC visit, so keeping both
|
||||
-- copies consistent here matches that invariant)
|
||||
local function encodeBoxRegion(base, mons)
|
||||
local n = math.min(#mons, MONS_PER_BOX)
|
||||
setByte(buf, base, n)
|
||||
for i = 0, n - 1 do
|
||||
local mon = mons[i + 1]
|
||||
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
|
||||
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or mon.species or "")
|
||||
end
|
||||
-- $FF-terminate the species list after the last real mon; empty slots past
|
||||
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||
-- export), just as the game leaves stale box data untouched past the count.
|
||||
setByte(buf, base + 1 + n, 0xFF)
|
||||
end
|
||||
local boxes = save.boxes or {}
|
||||
local curBoxNum = math.max(1, math.min(12, save.currentBox or 1))
|
||||
encodeBoxRegion(O.curBoxData, boxes[curBoxNum] or {})
|
||||
-- bit 7 of wCurBoxNum is the "box system initialized" flag, not part of the
|
||||
-- 0-11 index; preserve it from the template, or set it on a templateless
|
||||
-- export (any save we emit has an initialized box system).
|
||||
local prevBoxByte = buf[O.currentBoxNum + 1]
|
||||
local boxHiBit = (src and prevBoxByte) and bit.band(prevBoxByte:byte(), 0x80) or 0x80
|
||||
setByte(buf, O.currentBoxNum, bit.bor(bit.band(curBoxNum - 1, 0x7F), boxHiBit))
|
||||
for b = 1, 6 do encodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, boxes[b] or {}) end
|
||||
for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end
|
||||
|
||||
-- map + position
|
||||
if save.player and save.player.map then
|
||||
setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0)
|
||||
setByte(buf, O.yCoord, save.player.y or 0)
|
||||
setByte(buf, O.xCoord, save.player.x or 0)
|
||||
end
|
||||
if save.lastOutdoor and save.lastOutdoor.id then
|
||||
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
|
||||
end
|
||||
|
||||
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
|
||||
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
|
||||
-- mirror that cap rather than letting hours overflow a single byte.
|
||||
local totalFrames = math.floor((save.playTime or 0) * 60 + 0.5)
|
||||
local hours = math.floor(totalFrames / 216000) -- 3600s * 60 frames
|
||||
if hours > 255 then
|
||||
setByte(buf, O.playTimeHours, 255)
|
||||
setByte(buf, O.playTimeMaxed, 1)
|
||||
setByte(buf, O.playTimeMinutes, 59)
|
||||
setByte(buf, O.playTimeSeconds, 59)
|
||||
setByte(buf, O.playTimeFrames, 59)
|
||||
else
|
||||
local rem = totalFrames - hours * 216000
|
||||
local mins = math.floor(rem / 3600); rem = rem - mins * 3600
|
||||
local secs = math.floor(rem / 60)
|
||||
setByte(buf, O.playTimeHours, hours)
|
||||
setByte(buf, O.playTimeMaxed, 0)
|
||||
setByte(buf, O.playTimeMinutes, mins)
|
||||
setByte(buf, O.playTimeSeconds, secs)
|
||||
setByte(buf, O.playTimeFrames, rem - secs * 60)
|
||||
end
|
||||
|
||||
local out = table.concat(buf)
|
||||
-- checksums, computed last over the now-final bytes
|
||||
local outBuf = {}
|
||||
for i = 1, #out do outBuf[i] = out:sub(i, i) end
|
||||
setByte(outBuf, O.mainChecksum, checksum(out, O.checksumStart, O.checksumEnd))
|
||||
-- Per pokered (engine/menus/save.asm SaveSAVtoSRAM / CalcCheckSum): each box
|
||||
-- gets its own checksum, and the bank aggregate is CalcCheckSum over the
|
||||
-- ENTIRE six-box region (6 x 1122 bytes), not a sum of the six box sums.
|
||||
local function boxChecksum(base) return checksum(out, base, base + BOX_REGION_SIZE) end
|
||||
for b = 0, 5 do
|
||||
setByte(outBuf, O.boxBank2IndividualChecksums + b,
|
||||
boxChecksum(O.box1 + b * BOX_REGION_SIZE))
|
||||
end
|
||||
setByte(outBuf, O.boxBank2Checksum,
|
||||
checksum(out, O.box1, O.box1 + 6 * BOX_REGION_SIZE))
|
||||
for b = 0, 5 do
|
||||
setByte(outBuf, O.boxBank3IndividualChecksums + b,
|
||||
boxChecksum(O.box7 + b * BOX_REGION_SIZE))
|
||||
end
|
||||
setByte(outBuf, O.boxBank3Checksum,
|
||||
checksum(out, O.box7, O.box7 + 6 * BOX_REGION_SIZE))
|
||||
|
||||
return table.concat(outBuf)
|
||||
end
|
||||
|
||||
return GenSave
|
||||
@@ -0,0 +1,189 @@
|
||||
-- SaveConvert -- the runtime-facing entry point the launcher UI calls to
|
||||
-- turn a vanilla Gen1 (Red/Blue, international) battery save into this
|
||||
-- project's in-memory save table, and back out to a raw .sav image.
|
||||
--
|
||||
-- This is the ONE place the engine, the tests and the CLI
|
||||
-- (tools/save_convert/convert.lua) share: the GenSave codec, the crosswalk
|
||||
-- data loading, the merge over new-game defaults, and the version tag all
|
||||
-- live here so every consumer behaves identically.
|
||||
--
|
||||
-- Pure Lua, no love.* dependency at require time: GenSave and the crosswalk
|
||||
-- tables load through `require`, exactly how src/core/Data.lua pulls the
|
||||
-- generated modules -- which resolves under both plain luajit (package.path
|
||||
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
|
||||
-- build, with an OS-path fallback for odd working directories. The only
|
||||
-- place `love` is referenced is inside a guarded fallback, so running under
|
||||
-- stock Lua never touches it.
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
|
||||
local SaveConvert = {}
|
||||
|
||||
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
|
||||
-- `require` (works headless via package.path and fused via love's package
|
||||
-- searcher); fall back to love.filesystem.load, then a plain dofile, for
|
||||
-- the rare case a host has an unusual cwd or module path.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- { require-module-path, os-relative-file-path } for each table the codec
|
||||
-- needs. pokemon/moves/items/maps come from the shared generated data;
|
||||
-- charmap/event_flags are the save-convert-specific crosswalks.
|
||||
local DATA_MODULES = {
|
||||
pokemon = { "data.generated.pokemon", "data/generated/pokemon.lua" },
|
||||
moves = { "data.generated.moves", "data/generated/moves.lua" },
|
||||
items = { "data.generated.items", "data/generated/items.lua" },
|
||||
maps = { "data.generated.maps", "data/generated/maps.lua" },
|
||||
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||
}
|
||||
|
||||
local function loadTable(requirePath, filePath)
|
||||
local ok, mod = pcall(require, requirePath)
|
||||
if ok and type(mod) == "table" then return mod end
|
||||
-- fused build with an unexpected module path: read straight off the
|
||||
-- mounted filesystem (love is a global here, only ever touched when it
|
||||
-- actually exists -- stock Lua never reaches this branch)
|
||||
if love and love.filesystem and love.filesystem.getInfo
|
||||
and love.filesystem.getInfo(filePath) then
|
||||
local chunk = love.filesystem.load(filePath)
|
||||
if chunk then
|
||||
local m = chunk()
|
||||
if type(m) == "table" then return m end
|
||||
end
|
||||
end
|
||||
local chunk = loadfile(filePath)
|
||||
if chunk then
|
||||
local m = chunk()
|
||||
if type(m) == "table" then return m end
|
||||
end
|
||||
return nil, ("cannot load save-convert data module %q (tried require %q and file %q)")
|
||||
:format(requirePath, requirePath, filePath)
|
||||
end
|
||||
|
||||
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||
local charmapReady
|
||||
|
||||
local function ensureData()
|
||||
if not crosswalk then
|
||||
local data = {}
|
||||
for key, spec in pairs(DATA_MODULES) do
|
||||
if key ~= "charmap" then
|
||||
local mod, e = loadTable(spec[1], spec[2])
|
||||
if not mod then return nil, e end
|
||||
data[key] = mod
|
||||
end
|
||||
end
|
||||
crosswalk = data
|
||||
end
|
||||
if not charmapReady then
|
||||
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
|
||||
if not cm then return nil, err end
|
||||
GenSave.setCharmap(cm)
|
||||
charmapReady = true
|
||||
end
|
||||
return crosswalk
|
||||
end
|
||||
|
||||
-- Exposed for the CLI/tests so they can share the exact data set the codec
|
||||
-- uses (and so a caller can pre-warm the cache). Returns data, err.
|
||||
function SaveConvert.loadData()
|
||||
return ensureData()
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- new-game default skeleton the decoded fields merge on top of. Carried
|
||||
-- verbatim from tools/save_convert/convert.lua so the CLI and the runtime
|
||||
-- produce a byte-identical save table for the same input.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function defaultsSave()
|
||||
return {
|
||||
meta = { format = "gen1_import", mods = {} },
|
||||
defeatedTrainers = {},
|
||||
repelSteps = 0,
|
||||
modData = {},
|
||||
options = {
|
||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
|
||||
-- way convert.lua did, then stamp the requested version. The 32768-byte
|
||||
-- import template GenSave stashes as `rawImport` and the decode `warnings`
|
||||
-- are dropped here: neither belongs in a serialized slot file (a fresh
|
||||
-- export always starts zero-filled -- see GenSave.lua's header).
|
||||
local function mergeDefaults(decoded, version)
|
||||
decoded.warnings = nil
|
||||
decoded.rawImport = nil
|
||||
local save = defaultsSave()
|
||||
for k, v in pairs(decoded) do save[k] = v end
|
||||
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
|
||||
save.lastOutdoor = save.lastOutdoor or { id = save.player.map }
|
||||
if version ~= nil then
|
||||
save.meta = save.meta or {}
|
||||
save.meta.version = version
|
||||
end
|
||||
return save
|
||||
end
|
||||
SaveConvert.mergeDefaults = mergeDefaults
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Public API
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- importSav(bytes, version) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
-- checksum, decodes through GenSave, and returns a save table fully merged
|
||||
-- over the new-game defaults and tagged with `version`, ready to hand to
|
||||
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
|
||||
-- message (never raises).
|
||||
function SaveConvert.importSav(bytes, version)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
if not data then return nil, derr end
|
||||
|
||||
local ok, decoded = pcall(GenSave.decode, bytes, data)
|
||||
if not ok then return nil, "decode failed: " .. tostring(decoded) end
|
||||
|
||||
-- checksum validation: GenSave.decode records a warning rather than
|
||||
-- throwing (so it can still read a foreign/corrupt save), but for the
|
||||
-- runtime import path a bad main-data checksum means the file is not a
|
||||
-- trustworthy save, so reject it.
|
||||
for _, w in ipairs(decoded.warnings or {}) do
|
||||
if tostring(w):find("checksum") then
|
||||
return nil, "save data checksum invalid (" .. tostring(w) .. ")"
|
||||
end
|
||||
end
|
||||
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
if not data then return nil, derr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||
return bytes
|
||||
end
|
||||
|
||||
return SaveConvert
|
||||
@@ -0,0 +1,579 @@
|
||||
-- Generated by tools/build_data.py. DO NOT EDIT.
|
||||
-- Gen1 text byte <-> glyph/token charmap (fixed-length name
|
||||
-- fields: player/rival/OT names, nicknames -- box/party mon
|
||||
-- names are NUL-free, '@' ($50) terminated, space-padded).
|
||||
-- byToken's key is the literal glyph for ordinary characters
|
||||
-- ("A", "é", ...) or a bracketed control token
|
||||
-- ("<PLAYER>", "@") for terminators/substitutions -- only
|
||||
-- the plain single-glyph entries are meaningful inside a name.
|
||||
return {
|
||||
byByte = {
|
||||
[0] = "<NULL>",
|
||||
[5] = "ガ",
|
||||
[6] = "ギ",
|
||||
[7] = "グ",
|
||||
[8] = "ゲ",
|
||||
[9] = "ゴ",
|
||||
[10] = "ザ",
|
||||
[11] = "ジ",
|
||||
[12] = "ズ",
|
||||
[13] = "ゼ",
|
||||
[14] = "ゾ",
|
||||
[15] = "ダ",
|
||||
[16] = "ヂ",
|
||||
[17] = "ヅ",
|
||||
[18] = "デ",
|
||||
[19] = "ド",
|
||||
[25] = "バ",
|
||||
[26] = "ビ",
|
||||
[27] = "ブ",
|
||||
[28] = "ボ",
|
||||
[38] = "が",
|
||||
[39] = "ぎ",
|
||||
[40] = "ぐ",
|
||||
[41] = "げ",
|
||||
[42] = "ご",
|
||||
[43] = "ざ",
|
||||
[44] = "じ",
|
||||
[45] = "ず",
|
||||
[46] = "ぜ",
|
||||
[47] = "ぞ",
|
||||
[48] = "だ",
|
||||
[49] = "ぢ",
|
||||
[50] = "づ",
|
||||
[51] = "で",
|
||||
[52] = "ど",
|
||||
[58] = "ば",
|
||||
[59] = "び",
|
||||
[60] = "ぶ",
|
||||
[61] = "べ",
|
||||
[62] = "ぼ",
|
||||
[64] = "パ",
|
||||
[65] = "ピ",
|
||||
[66] = "プ",
|
||||
[67] = "ポ",
|
||||
[68] = "ぱ",
|
||||
[69] = "ぴ",
|
||||
[70] = "ぷ",
|
||||
[71] = "ぺ",
|
||||
[72] = "ぽ",
|
||||
[73] = "<PAGE>",
|
||||
[74] = "<PKMN>",
|
||||
[75] = "<_CONT>",
|
||||
[76] = "<SCROLL>",
|
||||
[78] = "<NEXT>",
|
||||
[79] = "<LINE>",
|
||||
[80] = "@",
|
||||
[81] = "<PARA>",
|
||||
[82] = "<PLAYER>",
|
||||
[83] = "<RIVAL>",
|
||||
[84] = "#",
|
||||
[85] = "<CONT>",
|
||||
[86] = "<……>",
|
||||
[87] = "<DONE>",
|
||||
[88] = "<PROMPT>",
|
||||
[89] = "<TARGET>",
|
||||
[90] = "<USER>",
|
||||
[91] = "<PC>",
|
||||
[92] = "<TM>",
|
||||
[93] = "<TRAINER>",
|
||||
[94] = "<ROCKET>",
|
||||
[95] = "<DEXEND>",
|
||||
[96] = "<BOLD_A>",
|
||||
[97] = "<BOLD_B>",
|
||||
[98] = "<BOLD_C>",
|
||||
[99] = "<BOLD_D>",
|
||||
[100] = "<BOLD_E>",
|
||||
[101] = "<BOLD_F>",
|
||||
[102] = "<BOLD_G>",
|
||||
[103] = "<BOLD_H>",
|
||||
[104] = "<BOLD_I>",
|
||||
[105] = "<BOLD_V>",
|
||||
[106] = "<BOLD_S>",
|
||||
[107] = "<BOLD_L>",
|
||||
[108] = "<BOLD_M>",
|
||||
[109] = "<COLON>",
|
||||
[110] = "ぃ",
|
||||
[111] = "ぅ",
|
||||
[112] = "‘",
|
||||
[113] = "’",
|
||||
[114] = "“",
|
||||
[115] = "”",
|
||||
[116] = "·",
|
||||
[117] = "…",
|
||||
[118] = "ぁ",
|
||||
[119] = "ぇ",
|
||||
[120] = "ぉ",
|
||||
[121] = "┌",
|
||||
[122] = "─",
|
||||
[123] = "┐",
|
||||
[124] = "│",
|
||||
[125] = "└",
|
||||
[126] = "┘",
|
||||
[127] = " ",
|
||||
[128] = "A",
|
||||
[129] = "B",
|
||||
[130] = "C",
|
||||
[131] = "D",
|
||||
[132] = "E",
|
||||
[133] = "F",
|
||||
[134] = "G",
|
||||
[135] = "H",
|
||||
[136] = "I",
|
||||
[137] = "J",
|
||||
[138] = "K",
|
||||
[139] = "L",
|
||||
[140] = "M",
|
||||
[141] = "N",
|
||||
[142] = "O",
|
||||
[143] = "P",
|
||||
[144] = "Q",
|
||||
[145] = "R",
|
||||
[146] = "S",
|
||||
[147] = "T",
|
||||
[148] = "U",
|
||||
[149] = "V",
|
||||
[150] = "W",
|
||||
[151] = "X",
|
||||
[152] = "Y",
|
||||
[153] = "Z",
|
||||
[154] = "(",
|
||||
[155] = ")",
|
||||
[156] = ":",
|
||||
[157] = ";",
|
||||
[158] = "[",
|
||||
[159] = "]",
|
||||
[160] = "a",
|
||||
[161] = "b",
|
||||
[162] = "c",
|
||||
[163] = "d",
|
||||
[164] = "e",
|
||||
[165] = "f",
|
||||
[166] = "g",
|
||||
[167] = "h",
|
||||
[168] = "i",
|
||||
[169] = "j",
|
||||
[170] = "k",
|
||||
[171] = "l",
|
||||
[172] = "m",
|
||||
[173] = "n",
|
||||
[174] = "o",
|
||||
[175] = "p",
|
||||
[176] = "q",
|
||||
[177] = "r",
|
||||
[178] = "s",
|
||||
[179] = "t",
|
||||
[180] = "u",
|
||||
[181] = "v",
|
||||
[182] = "w",
|
||||
[183] = "x",
|
||||
[184] = "y",
|
||||
[185] = "z",
|
||||
[186] = "é",
|
||||
[187] = "'d",
|
||||
[188] = "'l",
|
||||
[189] = "'s",
|
||||
[190] = "'t",
|
||||
[191] = "'v",
|
||||
[192] = "た",
|
||||
[193] = "ち",
|
||||
[194] = "つ",
|
||||
[195] = "て",
|
||||
[196] = "と",
|
||||
[197] = "な",
|
||||
[198] = "に",
|
||||
[199] = "ぬ",
|
||||
[200] = "ね",
|
||||
[201] = "の",
|
||||
[202] = "は",
|
||||
[203] = "ひ",
|
||||
[204] = "ふ",
|
||||
[205] = "へ",
|
||||
[206] = "ほ",
|
||||
[207] = "ま",
|
||||
[208] = "み",
|
||||
[209] = "む",
|
||||
[210] = "め",
|
||||
[211] = "も",
|
||||
[212] = "や",
|
||||
[213] = "ゆ",
|
||||
[214] = "よ",
|
||||
[215] = "ら",
|
||||
[216] = "り",
|
||||
[217] = "る",
|
||||
[218] = "れ",
|
||||
[219] = "ろ",
|
||||
[220] = "わ",
|
||||
[221] = "を",
|
||||
[222] = "ん",
|
||||
[223] = "っ",
|
||||
[224] = "'",
|
||||
[225] = "<PK>",
|
||||
[226] = "<MN>",
|
||||
[227] = "-",
|
||||
[228] = "'r",
|
||||
[229] = "'m",
|
||||
[230] = "?",
|
||||
[231] = "!",
|
||||
[232] = ".",
|
||||
[233] = "ァ",
|
||||
[234] = "ゥ",
|
||||
[235] = "ェ",
|
||||
[236] = "▷",
|
||||
[237] = "▲",
|
||||
[238] = "▼",
|
||||
[239] = "♂",
|
||||
[240] = "<ED>",
|
||||
[241] = "×",
|
||||
[242] = "<DOT>",
|
||||
[243] = "/",
|
||||
[244] = ",",
|
||||
[245] = "♀",
|
||||
[246] = "0",
|
||||
[247] = "1",
|
||||
[248] = "2",
|
||||
[249] = "3",
|
||||
[250] = "4",
|
||||
[251] = "5",
|
||||
[252] = "6",
|
||||
[253] = "7",
|
||||
[254] = "8",
|
||||
[255] = "9",
|
||||
},
|
||||
byToken = {
|
||||
[" "] = 127,
|
||||
["!"] = 231,
|
||||
["#"] = 84,
|
||||
["'"] = 224,
|
||||
["'d"] = 187,
|
||||
["'l"] = 188,
|
||||
["'m"] = 229,
|
||||
["'r"] = 228,
|
||||
["'s"] = 189,
|
||||
["'t"] = 190,
|
||||
["'v"] = 191,
|
||||
["("] = 154,
|
||||
[")"] = 155,
|
||||
[","] = 244,
|
||||
["-"] = 227,
|
||||
["."] = 232,
|
||||
["/"] = 243,
|
||||
["0"] = 246,
|
||||
["1"] = 247,
|
||||
["2"] = 248,
|
||||
["3"] = 249,
|
||||
["4"] = 250,
|
||||
["5"] = 251,
|
||||
["6"] = 252,
|
||||
["7"] = 253,
|
||||
["8"] = 254,
|
||||
["9"] = 255,
|
||||
[":"] = 156,
|
||||
[";"] = 157,
|
||||
["<BOLD_A>"] = 96,
|
||||
["<BOLD_B>"] = 97,
|
||||
["<BOLD_C>"] = 98,
|
||||
["<BOLD_D>"] = 99,
|
||||
["<BOLD_E>"] = 100,
|
||||
["<BOLD_F>"] = 101,
|
||||
["<BOLD_G>"] = 102,
|
||||
["<BOLD_H>"] = 103,
|
||||
["<BOLD_I>"] = 104,
|
||||
["<BOLD_L>"] = 107,
|
||||
["<BOLD_M>"] = 108,
|
||||
["<BOLD_P>"] = 114,
|
||||
["<BOLD_S>"] = 106,
|
||||
["<BOLD_V>"] = 105,
|
||||
["<COLON>"] = 109,
|
||||
["<CONT>"] = 85,
|
||||
["<DEXEND>"] = 95,
|
||||
["<DONE>"] = 87,
|
||||
["<DOT>"] = 242,
|
||||
["<ED>"] = 240,
|
||||
["<ID>"] = 115,
|
||||
["<LINE>"] = 79,
|
||||
["<LV>"] = 110,
|
||||
["<MN>"] = 226,
|
||||
["<NEXT>"] = 78,
|
||||
["<NULL>"] = 0,
|
||||
["<PAGE>"] = 73,
|
||||
["<PARA>"] = 81,
|
||||
["<PC>"] = 91,
|
||||
["<PK>"] = 225,
|
||||
["<PKMN>"] = 74,
|
||||
["<PLAYER>"] = 82,
|
||||
["<PROMPT>"] = 88,
|
||||
["<RIVAL>"] = 83,
|
||||
["<ROCKET>"] = 94,
|
||||
["<SCROLL>"] = 76,
|
||||
["<TARGET>"] = 89,
|
||||
["<TM>"] = 92,
|
||||
["<TRAINER>"] = 93,
|
||||
["<USER>"] = 90,
|
||||
["<_CONT>"] = 75,
|
||||
["<to>"] = 112,
|
||||
["<……>"] = 86,
|
||||
["?"] = 230,
|
||||
["@"] = 80,
|
||||
A = 128,
|
||||
B = 129,
|
||||
C = 130,
|
||||
D = 131,
|
||||
E = 132,
|
||||
F = 133,
|
||||
G = 134,
|
||||
H = 135,
|
||||
I = 136,
|
||||
J = 137,
|
||||
K = 138,
|
||||
L = 139,
|
||||
M = 140,
|
||||
N = 141,
|
||||
O = 142,
|
||||
P = 143,
|
||||
Q = 144,
|
||||
R = 145,
|
||||
S = 146,
|
||||
T = 147,
|
||||
U = 148,
|
||||
V = 149,
|
||||
W = 150,
|
||||
X = 151,
|
||||
Y = 152,
|
||||
Z = 153,
|
||||
["["] = 158,
|
||||
["]"] = 159,
|
||||
a = 160,
|
||||
b = 161,
|
||||
c = 162,
|
||||
d = 163,
|
||||
e = 164,
|
||||
f = 165,
|
||||
g = 166,
|
||||
h = 167,
|
||||
i = 168,
|
||||
j = 169,
|
||||
k = 170,
|
||||
l = 171,
|
||||
m = 172,
|
||||
n = 173,
|
||||
o = 174,
|
||||
p = 175,
|
||||
q = 176,
|
||||
r = 177,
|
||||
s = 178,
|
||||
t = 179,
|
||||
u = 180,
|
||||
v = 181,
|
||||
w = 182,
|
||||
x = 183,
|
||||
y = 184,
|
||||
z = 185,
|
||||
["¥"] = 240,
|
||||
["·"] = 116,
|
||||
["×"] = 241,
|
||||
["é"] = 186,
|
||||
["‘"] = 112,
|
||||
["’"] = 113,
|
||||
["“"] = 114,
|
||||
["”"] = 115,
|
||||
["…"] = 117,
|
||||
["′"] = 96,
|
||||
["″"] = 97,
|
||||
["№"] = 116,
|
||||
["⋯"] = 117,
|
||||
["─"] = 122,
|
||||
["│"] = 124,
|
||||
["┌"] = 121,
|
||||
["┐"] = 123,
|
||||
["└"] = 125,
|
||||
["┘"] = 126,
|
||||
["▲"] = 237,
|
||||
["▶"] = 237,
|
||||
["▷"] = 236,
|
||||
["▼"] = 238,
|
||||
["♀"] = 245,
|
||||
["♂"] = 239,
|
||||
[" "] = 127,
|
||||
["。"] = 232,
|
||||
["「"] = 112,
|
||||
["」"] = 113,
|
||||
["『"] = 114,
|
||||
["』"] = 115,
|
||||
["ぁ"] = 118,
|
||||
["あ"] = 177,
|
||||
["ぃ"] = 110,
|
||||
["い"] = 178,
|
||||
["ぅ"] = 111,
|
||||
["う"] = 179,
|
||||
["ぇ"] = 119,
|
||||
["え"] = 180,
|
||||
["ぉ"] = 120,
|
||||
["お"] = 181,
|
||||
["か"] = 182,
|
||||
["が"] = 38,
|
||||
["き"] = 183,
|
||||
["ぎ"] = 39,
|
||||
["く"] = 184,
|
||||
["ぐ"] = 40,
|
||||
["け"] = 185,
|
||||
["げ"] = 41,
|
||||
["こ"] = 186,
|
||||
["ご"] = 42,
|
||||
["さ"] = 187,
|
||||
["ざ"] = 43,
|
||||
["し"] = 188,
|
||||
["じ"] = 44,
|
||||
["す"] = 189,
|
||||
["ず"] = 45,
|
||||
["せ"] = 190,
|
||||
["ぜ"] = 46,
|
||||
["そ"] = 191,
|
||||
["ぞ"] = 47,
|
||||
["た"] = 192,
|
||||
["だ"] = 48,
|
||||
["ち"] = 193,
|
||||
["ぢ"] = 49,
|
||||
["っ"] = 223,
|
||||
["つ"] = 194,
|
||||
["づ"] = 50,
|
||||
["て"] = 195,
|
||||
["で"] = 51,
|
||||
["と"] = 196,
|
||||
["ど"] = 52,
|
||||
["な"] = 197,
|
||||
["に"] = 198,
|
||||
["ぬ"] = 199,
|
||||
["ね"] = 200,
|
||||
["の"] = 201,
|
||||
["は"] = 202,
|
||||
["ば"] = 58,
|
||||
["ぱ"] = 68,
|
||||
["ひ"] = 203,
|
||||
["び"] = 59,
|
||||
["ぴ"] = 69,
|
||||
["ふ"] = 204,
|
||||
["ぶ"] = 60,
|
||||
["ぷ"] = 70,
|
||||
["へ"] = 205,
|
||||
["べ"] = 61,
|
||||
["ぺ"] = 71,
|
||||
["ほ"] = 206,
|
||||
["ぼ"] = 62,
|
||||
["ぽ"] = 72,
|
||||
["ま"] = 207,
|
||||
["み"] = 208,
|
||||
["む"] = 209,
|
||||
["め"] = 210,
|
||||
["も"] = 211,
|
||||
["ゃ"] = 224,
|
||||
["や"] = 212,
|
||||
["ゅ"] = 225,
|
||||
["ゆ"] = 213,
|
||||
["ょ"] = 226,
|
||||
["よ"] = 214,
|
||||
["ら"] = 215,
|
||||
["り"] = 216,
|
||||
["る"] = 217,
|
||||
["れ"] = 218,
|
||||
["ろ"] = 219,
|
||||
["わ"] = 220,
|
||||
["を"] = 221,
|
||||
["ん"] = 222,
|
||||
["ァ"] = 233,
|
||||
["ア"] = 128,
|
||||
["ィ"] = 176,
|
||||
["イ"] = 129,
|
||||
["ゥ"] = 234,
|
||||
["ウ"] = 130,
|
||||
["ェ"] = 235,
|
||||
["エ"] = 131,
|
||||
["ォ"] = 244,
|
||||
["オ"] = 132,
|
||||
["カ"] = 133,
|
||||
["ガ"] = 5,
|
||||
["キ"] = 134,
|
||||
["ギ"] = 6,
|
||||
["ク"] = 135,
|
||||
["グ"] = 7,
|
||||
["ケ"] = 136,
|
||||
["ゲ"] = 8,
|
||||
["コ"] = 137,
|
||||
["ゴ"] = 9,
|
||||
["サ"] = 138,
|
||||
["ザ"] = 10,
|
||||
["シ"] = 139,
|
||||
["ジ"] = 11,
|
||||
["ス"] = 140,
|
||||
["ズ"] = 12,
|
||||
["セ"] = 141,
|
||||
["ゼ"] = 13,
|
||||
["ソ"] = 142,
|
||||
["ゾ"] = 14,
|
||||
["タ"] = 143,
|
||||
["ダ"] = 15,
|
||||
["チ"] = 144,
|
||||
["ヂ"] = 16,
|
||||
["ッ"] = 172,
|
||||
["ツ"] = 145,
|
||||
["ヅ"] = 17,
|
||||
["テ"] = 146,
|
||||
["デ"] = 18,
|
||||
["ト"] = 147,
|
||||
["ド"] = 19,
|
||||
["ナ"] = 148,
|
||||
["ニ"] = 149,
|
||||
["ヌ"] = 150,
|
||||
["ネ"] = 151,
|
||||
["ノ"] = 152,
|
||||
["ハ"] = 153,
|
||||
["バ"] = 25,
|
||||
["パ"] = 64,
|
||||
["ヒ"] = 154,
|
||||
["ビ"] = 26,
|
||||
["ピ"] = 65,
|
||||
["フ"] = 155,
|
||||
["ブ"] = 27,
|
||||
["プ"] = 66,
|
||||
["ホ"] = 156,
|
||||
["ボ"] = 28,
|
||||
["ポ"] = 67,
|
||||
["マ"] = 157,
|
||||
["ミ"] = 158,
|
||||
["ム"] = 159,
|
||||
["メ"] = 160,
|
||||
["モ"] = 161,
|
||||
["ャ"] = 173,
|
||||
["ヤ"] = 162,
|
||||
["ュ"] = 174,
|
||||
["ユ"] = 163,
|
||||
["ョ"] = 175,
|
||||
["ヨ"] = 164,
|
||||
["ラ"] = 165,
|
||||
["ル"] = 166,
|
||||
["レ"] = 167,
|
||||
["ロ"] = 168,
|
||||
["ワ"] = 169,
|
||||
["ヲ"] = 170,
|
||||
["ン"] = 171,
|
||||
["ー"] = 227,
|
||||
["円"] = 240,
|
||||
["!"] = 231,
|
||||
["."] = 242,
|
||||
["/"] = 243,
|
||||
["0"] = 246,
|
||||
["1"] = 247,
|
||||
["2"] = 248,
|
||||
["3"] = 249,
|
||||
["4"] = 250,
|
||||
["5"] = 251,
|
||||
["6"] = 252,
|
||||
["7"] = 253,
|
||||
["8"] = 254,
|
||||
["9"] = 255,
|
||||
["?"] = 230,
|
||||
["゙"] = 229,
|
||||
["゚"] = 228,
|
||||
},
|
||||
source = "pokered constants/charmap.asm",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+55
-9
@@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles)
|
||||
end
|
||||
|
||||
function Commands.move_npc(ctx, objIndex, dir, tiles)
|
||||
local npc = ctx.overworld:npcByIndex(objIndex)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
local npc = ow:npcByIndex(objIndex)
|
||||
if npc then walkEntity(ctx, npc, dir, tiles) end
|
||||
end
|
||||
|
||||
@@ -308,6 +310,7 @@ end
|
||||
|
||||
function Commands.move_npc_to(ctx, objIndex, tx, ty)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
local npc = ow:npcByIndex(objIndex)
|
||||
if not npc then return end
|
||||
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
|
||||
@@ -339,6 +342,20 @@ function Commands.face_object(ctx, objIndex, dir)
|
||||
if npc then npc.facing = dir end
|
||||
end
|
||||
|
||||
-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1).
|
||||
-- facing is optional. Headless-safe no-op without an overworld.
|
||||
function Commands.place_npc(ctx, objIndex, x, y, facing)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
local npc = ow:npcByIndex(objIndex)
|
||||
if not npc then return end
|
||||
npc.cellX, npc.cellY = x, y
|
||||
npc.px, npc.py = x * 16, y * 16
|
||||
npc.moving = false
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
if facing then npc.facing = facing end
|
||||
end
|
||||
|
||||
function Commands.face_npc(ctx)
|
||||
-- make the player face the talking NPC
|
||||
if ctx.npc and ctx.overworld then
|
||||
@@ -485,7 +502,7 @@ function Commands.heal_party(ctx)
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
|
||||
-- AddPartyMon only offers this for party mons (box deposits skip it).
|
||||
-- AddPartyMon and SendNewMonToBox both offer this.
|
||||
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
|
||||
-- yes/no result (scripts jump_if_false on give failure afterwards).
|
||||
local function askNickname(ctx, mon)
|
||||
@@ -527,8 +544,9 @@ end
|
||||
-- the asm's carry: true when the mon was given, false when both the
|
||||
-- party and every box are full (that .boxFull path leaves the giver's
|
||||
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
||||
-- Party adds run AskName (AddPartyMon) when a script runner is present;
|
||||
-- mods that pre-set gift.nickname skip the prompt.
|
||||
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
|
||||
-- script runner is present; mods that pre-set gift.nickname skip it.
|
||||
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
||||
function Commands.give_pokemon(ctx, species, level)
|
||||
-- Native mods can transform a gift before the Pokémon object is created.
|
||||
-- This is intentionally an event rather than a special-case starter hook:
|
||||
@@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
end
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local mon = Pokemon.new(ctx.game.data, species, level)
|
||||
if gift.nickname then mon.nickname = gift.nickname end
|
||||
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
||||
ctx.pendingPokemonName = species
|
||||
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
||||
local addedToParty = Party.add(ctx.save.party, mon)
|
||||
local boxNum = nil
|
||||
if not addedToParty then
|
||||
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
|
||||
boxNum = Boxes.deposit(ctx.save, mon)
|
||||
if not boxNum then
|
||||
ctx.lastCheck = false
|
||||
return
|
||||
end
|
||||
@@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
dex.owned[species] = true
|
||||
end
|
||||
ctx.lastCheck = true
|
||||
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
|
||||
-- and callback-style callers that have no script runner to yield on.
|
||||
if addedToParty and not gift.nickname and ctx.runner then
|
||||
ctx.addedToParty = addedToParty
|
||||
ctx.boxNum = boxNum
|
||||
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
|
||||
-- and callback-style callers with no script runner to yield on.
|
||||
if not gift.nickname and ctx.runner then
|
||||
askNickname(ctx, mon)
|
||||
end
|
||||
if boxNum then
|
||||
local name = mon.nickname
|
||||
or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name)
|
||||
or species
|
||||
ctx.game.boxMonNicks = name
|
||||
ctx.game.stringBuffer = tostring(boxNum)
|
||||
if ctx.runner then
|
||||
Commands.show_text(ctx, "_SentToBoxText")
|
||||
else
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
||||
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
||||
"There's no more\nroom for POKéMON!\v" .. name
|
||||
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
|
||||
function() end))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Commands.give_money(ctx, amount)
|
||||
@@ -783,6 +823,8 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
||||
local newMon = Pokemon.new(data, trade.get, sent.level)
|
||||
newMon.nickname = trade.nickname
|
||||
newMon.traded = true -- boosted exp + Name Rater refusal (different OT)
|
||||
newMon.ot = "TRAINER" -- InGameTrade_TrainerString
|
||||
newMon.otId = (love.math and love.math.random or math.random)(0, 65535)
|
||||
table.remove(party, slot)
|
||||
table.insert(party, newMon)
|
||||
local dex = ctx.save.pokedex
|
||||
@@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
||||
dex.seen[trade.get] = true
|
||||
dex.owned[trade.get] = true
|
||||
end
|
||||
-- the trade machine animation (engine/movie/trade.asm)
|
||||
-- InternalClockTradeAnim (engine/movie/trade.asm)
|
||||
Screens.push(ctx.game, "TradeAnim", {
|
||||
sent = sent, received = newMon,
|
||||
enemyName = "TRAINER",
|
||||
playerOt = ctx.save.player.name,
|
||||
playerOtId = sent.otId or ctx.save.player.id,
|
||||
enemyOtId = newMon.otId,
|
||||
onDone = function() runner:resume() end,
|
||||
})
|
||||
runner:yield()
|
||||
|
||||
@@ -331,6 +331,12 @@ end
|
||||
|
||||
local function useItem(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
-- ItemUseTMHM checks wIsInBattle before BootedUpTMText
|
||||
if battle and def and def.machine then
|
||||
local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle)
|
||||
showMessages(game, payload)
|
||||
return
|
||||
end
|
||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
||||
-- TMs/HMs boot up and announce their move before the target picker
|
||||
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||
|
||||
+98
-21
@@ -3,9 +3,11 @@
|
||||
-- plus CHANGE BOX.
|
||||
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local BoxMenu = {}
|
||||
|
||||
@@ -14,6 +16,11 @@ local function monLabel(game, mon)
|
||||
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
|
||||
end
|
||||
|
||||
local function monName(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return mon.nickname or def.name
|
||||
end
|
||||
|
||||
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
|
||||
-- action + STATS + CANCEL. STATS shows the status screen and returns
|
||||
-- here; CANCEL/B goes back to the list.
|
||||
@@ -31,8 +38,26 @@ local function monSubmenu(game, action, mon, onAction)
|
||||
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
||||
end
|
||||
|
||||
-- After a successful transfer: close the mon list (BoxMenu stays beneath
|
||||
-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu).
|
||||
local function afterTransfer(game, list, text)
|
||||
list:close()
|
||||
game.stack:push(TextBox.new(game, text))
|
||||
end
|
||||
|
||||
local function withdraw(game)
|
||||
local box = Boxes.active(game.save)
|
||||
local t = game.data.text
|
||||
if #box == 0 then
|
||||
game.stack:push(TextBox.new(game, t._NoMonText
|
||||
or "What? There are\nno POKéMON here!"))
|
||||
return
|
||||
end
|
||||
if #game.save.party >= Party.MAX then
|
||||
game.stack:push(TextBox.new(game, t._CantTakeMonText
|
||||
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
@@ -49,13 +74,29 @@ local function withdraw(game)
|
||||
end
|
||||
table.remove(box, item.value)
|
||||
table.insert(game.save.party, mon)
|
||||
list:close()
|
||||
local name = monName(game, mon)
|
||||
game.stringBuffer = name
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
afterTransfer(game, list, t._MonIsTakenOutText
|
||||
or (name .. " is\ntaken out.\vGot " .. name .. "."))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
end
|
||||
|
||||
local function deposit(game)
|
||||
local t = game.data.text
|
||||
if #game.save.party <= 1 then
|
||||
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
|
||||
or "You can't deposit\nthe last POKéMON!"))
|
||||
return
|
||||
end
|
||||
local box = Boxes.active(game.save)
|
||||
if #box >= Boxes.CAPACITY then
|
||||
game.stack:push(TextBox.new(game, t._BoxFullText
|
||||
or "Oops! This Box is\nfull of POKéMON."))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(game.save.party) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
@@ -69,40 +110,50 @@ local function deposit(game)
|
||||
list.footer = "You need at least\none POKéMON!"
|
||||
return
|
||||
end
|
||||
local box = Boxes.active(game.save)
|
||||
if #box >= Boxes.CAPACITY then
|
||||
local active = Boxes.active(game.save)
|
||||
if #active >= Boxes.CAPACITY then
|
||||
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
|
||||
return
|
||||
end
|
||||
table.remove(game.save.party, item.value)
|
||||
table.insert(box, mon)
|
||||
list:close()
|
||||
table.insert(active, mon)
|
||||
local name = monName(game, mon)
|
||||
game.stringBuffer = name
|
||||
game.boxNumString = tostring(game.save.currentBox)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
afterTransfer(game, list, t._MonWasStoredText
|
||||
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
end
|
||||
|
||||
-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
|
||||
-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!".
|
||||
-- Index by list.index (not stale item.value) after removeCurrent (#171).
|
||||
local function release(game)
|
||||
local box = Boxes.active(game.save)
|
||||
local t = game.data.text
|
||||
if #box == 0 then
|
||||
game.stack:push(TextBox.new(game, t._NoMonText
|
||||
or "What? There are\nno POKéMON here!"))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
|
||||
onChoose = function(item, list)
|
||||
local mon = box[item.value]
|
||||
onChoose = function(_, list)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local def = game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
local name = monName(game, mon)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then return end
|
||||
table.remove(box, item.value)
|
||||
table.remove(box, list.index)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
|
||||
@@ -129,7 +180,6 @@ local function changeBox(game)
|
||||
-- the original asks BEFORE switching ("When you change a #MON
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
@@ -143,21 +193,48 @@ local function changeBox(game)
|
||||
}))
|
||||
end
|
||||
|
||||
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
|
||||
local function drawChrome(game)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("What?", 8, 112)
|
||||
Font.drawBox(9, 14, 11, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("BOX No.", 80, 128)
|
||||
local n = game.save.currentBox or 1
|
||||
if n >= 10 then
|
||||
Font.draw(tostring(n), 136, 128)
|
||||
else
|
||||
Font.draw(tostring(n), 144, 128)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function BoxMenu.new(game)
|
||||
Boxes.ensure(game.save)
|
||||
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
|
||||
-- full interior (cursor col + label); the old tw=12 right-side box
|
||||
-- drew the final glyph on the border.
|
||||
return Menu.new(game, {
|
||||
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
||||
{ label = "RELEASE", onSelect = function() release(game) end },
|
||||
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end },
|
||||
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
|
||||
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||
local menu = Menu.new(game, {
|
||||
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
|
||||
onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
|
||||
onSelect = function() deposit(game) end },
|
||||
{ label = "RELEASE <PK><MN>", keepOpen = true,
|
||||
onSelect = function() release(game) end },
|
||||
{ label = "CHANGE BOX", keepOpen = true,
|
||||
onSelect = function() changeBox(game) end },
|
||||
{ label = "SEE YA!" },
|
||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||
-- engine/menus/pokemon_pc.asm)
|
||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||
local baseDraw = menu.draw
|
||||
function menu:draw()
|
||||
baseDraw(self)
|
||||
drawChrome(game)
|
||||
end
|
||||
return menu
|
||||
end
|
||||
|
||||
return BoxMenu
|
||||
|
||||
+12
-7
@@ -1,4 +1,4 @@
|
||||
-- YES/NO choice box (top-left of the text box area, like the original).
|
||||
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
@@ -14,6 +14,11 @@ function ChoiceBox.new(game, onChoose, opts)
|
||||
self.index = (opts and opts.defaultNo) and 2 or 1
|
||||
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
|
||||
self.noSound = (opts and opts.noSound) or false
|
||||
local box = Theme.choiceBox
|
||||
self.tx = (opts and opts.tx) or box.tx
|
||||
self.ty = (opts and opts.ty) or box.ty
|
||||
self.tw = (opts and opts.tw) or box.tw
|
||||
self.th = (opts and opts.th) or box.th
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -38,13 +43,13 @@ function ChoiceBox:update(dt)
|
||||
end
|
||||
|
||||
function ChoiceBox:draw()
|
||||
local box = Theme.choiceBox
|
||||
Font.drawBox(box.tx, box.ty, box.tw, box.th)
|
||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
|
||||
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
|
||||
Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
|
||||
(box.ty + (self.index == 1 and 1 or 3)) * 8)
|
||||
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
|
||||
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||
(ty + (self.index == 1 and 1 or 3)) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+10
-5
@@ -37,8 +37,12 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- text box, a money box sits top-right, and the list shortens to
|
||||
-- clear them (DisplayPokemartDialogue_'s screen)
|
||||
self.dialogue = opts.dialogue
|
||||
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
|
||||
-- and PrintText footers ("How many?", stored/withdrew) use the standard
|
||||
-- bottom text box — same row budget as the mart, without the money box.
|
||||
self.messageBox = opts.messageBox
|
||||
self.money = opts.money -- () -> current money for the box
|
||||
self.rows = opts.dialogue and 4 or ROWS
|
||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -137,8 +141,10 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
-- the clerk's line in the standard bottom text box; long prompts
|
||||
-- wrap and keep their last two lines, like the GB's scrolled box
|
||||
end
|
||||
if self.dialogue or (self.messageBox and self.footer) then
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.footer then
|
||||
@@ -153,8 +159,7 @@ function ListMenu:draw()
|
||||
end
|
||||
end
|
||||
elseif self.footer then
|
||||
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
|
||||
-- long item names are not clipped at the screen edge (#115).
|
||||
-- bare footer (bag money line, etc.)
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
|
||||
+24
-12
@@ -22,6 +22,8 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone)
|
||||
self.newMoveId = newMoveId
|
||||
self.onDone = onDone
|
||||
self.index = 1
|
||||
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
|
||||
self.selecting = false
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -31,24 +33,32 @@ end
|
||||
|
||||
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
|
||||
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning).
|
||||
-- Use TextBox opts.choice so YES/NO overlays the still-visible prompt;
|
||||
-- pushing ChoiceBox from onDone pops the text first and leaves YES/NO
|
||||
-- on "Which move should be forgotten?" (#173).
|
||||
function MoveLearnMenu:enter()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local game = self.game
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
local name = self:monName()
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
|
||||
:format(name, mdef.name, name) ..
|
||||
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
|
||||
function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then self:confirmAbandon() end
|
||||
end))
|
||||
end))
|
||||
nil, {
|
||||
choice = function(yes)
|
||||
if yes then
|
||||
self.selecting = true
|
||||
else
|
||||
self:confirmAbandon()
|
||||
end
|
||||
end,
|
||||
}))
|
||||
end
|
||||
|
||||
function MoveLearnMenu:update(dt)
|
||||
if not self.selecting then return end
|
||||
local input = self.game.input
|
||||
local n = #self.mon.moves + 1 -- moves + CANCEL
|
||||
if input:wasPressed("up") then
|
||||
@@ -82,15 +92,15 @@ end
|
||||
-- (DontAbandonLearning)
|
||||
function MoveLearnMenu:confirmAbandon()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local game = self.game
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Abandon learning\n%s?"):format(mdef.name), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes then self:finish(false) else self:enter() end
|
||||
end))
|
||||
end))
|
||||
("Abandon learning\n%s?"):format(mdef.name), nil, {
|
||||
choice = function(yes)
|
||||
if yes then self:finish(false) else self:enter() end
|
||||
end,
|
||||
}))
|
||||
end
|
||||
|
||||
function MoveLearnMenu:finish(learned)
|
||||
@@ -98,6 +108,7 @@ function MoveLearnMenu:finish(learned)
|
||||
local game = self.game
|
||||
local name = self:monName()
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
self.selecting = false
|
||||
game.stack:pop()
|
||||
local msg
|
||||
if learned then
|
||||
@@ -114,6 +125,7 @@ function MoveLearnMenu:finish(learned)
|
||||
end
|
||||
|
||||
function MoveLearnMenu:draw()
|
||||
if not self.selecting then return end
|
||||
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
|
||||
-- plus the port's extra CANCEL row
|
||||
Font.drawBox(4, 5, 16, 7)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
-- bottom line like pokered's.
|
||||
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
@@ -193,6 +194,15 @@ local function buildRows(game)
|
||||
local o = g.save.options
|
||||
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
|
||||
Tilt.setLevel(o.tilt)
|
||||
-- tilt and a mod's world pipeline are two answers to the same
|
||||
-- question; turning this on switches that off (Pipelines does the
|
||||
-- same in the other direction)
|
||||
if o.tilt > 0 then
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.drawWorld then Pipelines.setLevel(entry.id, 0) end
|
||||
end
|
||||
Pipelines.syncOptions(o)
|
||||
end
|
||||
return true
|
||||
end },
|
||||
{ id = "gbcfx", label = "GBC FX",
|
||||
@@ -295,6 +305,26 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- A mod's render pipelines are display modes like TILT, so their rows sit
|
||||
-- with it rather than at the end of the list where a mod's own
|
||||
-- ui.options.rows additions land. Nothing registered means nothing
|
||||
-- spliced, so a vanilla install sees the list it always had.
|
||||
local pipelineRows = Pipelines.rows(game)
|
||||
if pipelineRows[1] then
|
||||
local merged = {}
|
||||
for _, row in ipairs(rows) do
|
||||
merged[#merged + 1] = row
|
||||
if row.id == "tilt" then
|
||||
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
|
||||
end
|
||||
end
|
||||
-- no TILT row to anchor to (a future build could drop it): append
|
||||
-- rather than silently lose the modes
|
||||
if #merged == #rows then
|
||||
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
|
||||
end
|
||||
rows = merged
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
|
||||
+71
-49
@@ -1,8 +1,11 @@
|
||||
-- Party menu: list the party, choose a member.
|
||||
-- Modes:
|
||||
-- default: A -> submenu (STATS / SWITCH order / CANCEL)
|
||||
-- opts.onSwitch: A -> hand the chosen mon to the callback (battle
|
||||
-- switch, item targeting via opts.pickOnly)
|
||||
-- default: A -> submenu (STATS / SWITCH / field moves)
|
||||
-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS /
|
||||
-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH
|
||||
-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately
|
||||
-- (ChooseNextMon / SHIFT free-switch)
|
||||
-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target)
|
||||
-- opts.onCancel: fired when the menu closes without a pick (B)
|
||||
-- Pops itself on B.
|
||||
|
||||
@@ -125,6 +128,7 @@ function PartyMenu.new(game, opts)
|
||||
self.onSwitch = opts.onSwitch
|
||||
self.onCancel = opts.onCancel
|
||||
self.pickOnly = opts.pickOnly
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = opts.party -- link battles pass their clamped copies
|
||||
self.swapFrom = nil
|
||||
@@ -156,7 +160,17 @@ function PartyMenu:update(dt)
|
||||
-- hook-injected entries carry a callback instead of an action id
|
||||
entry.onSelect(mon, self.game)
|
||||
elseif action == "stats" then
|
||||
-- battle and field alike return to the party list afterwards
|
||||
-- (core.asm .partyMenuWasSelected)
|
||||
Screens.push(self.game, "SummaryMenu", mon)
|
||||
elseif action == "battle_switch" then
|
||||
self.game.stack:pop()
|
||||
self.onSwitch(mon)
|
||||
return
|
||||
elseif action == "cancel" then
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
return
|
||||
elseif action == "switch" then
|
||||
self.swapFrom = self.index
|
||||
elseif action == "fly" then
|
||||
@@ -341,58 +355,66 @@ function PartyMenu:update(dt)
|
||||
require("src.core.Sound").play(self.game.data, "Swap")
|
||||
end
|
||||
self.swapFrom = nil
|
||||
elseif self.onSwitch then
|
||||
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
|
||||
self.game.stack:pop()
|
||||
self.onSwitch(mon)
|
||||
else
|
||||
self.submenu = true
|
||||
self.subIndex = 1
|
||||
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
|
||||
-- builds the same dynamic list)
|
||||
local items = { { label = "STATS", action = "stats" },
|
||||
{ label = "SWITCH", action = "switch" } }
|
||||
local items
|
||||
local ow = self.game.overworld
|
||||
-- Field moves (HMs/TMs) are usable out of battle even when the mon
|
||||
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
table.insert(items, { label = "FLY", action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
table.insert(items, { label = "FLASH", action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||
-- with the same list-time badge filter this file already uses
|
||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
||||
table.insert(items, { label = "CUT", action = "cut" })
|
||||
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
|
||||
table.insert(items, { label = "SURF", action = "surf" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
table.insert(items, { label = "STRENGTH", action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = "TELEPORT", action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||
-- the dark (Rock Tunnel)
|
||||
table.insert(items, { label = "DIG", action = "escape" })
|
||||
if self.battle and self.onSwitch then
|
||||
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
|
||||
items = { { label = "SWITCH", action = "battle_switch" },
|
||||
{ label = "STATS", action = "stats" },
|
||||
{ label = "CANCEL", action = "cancel" } }
|
||||
else
|
||||
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
|
||||
-- builds the same dynamic list)
|
||||
items = { { label = "STATS", action = "stats" },
|
||||
{ label = "SWITCH", action = "switch" } }
|
||||
-- Field moves (HMs/TMs) are usable out of battle even when the mon
|
||||
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
table.insert(items, { label = "FLY", action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
table.insert(items, { label = "FLASH", action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||
-- with the same list-time badge filter this file already uses
|
||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
||||
table.insert(items, { label = "CUT", action = "cut" })
|
||||
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
|
||||
table.insert(items, { label = "SURF", action = "surf" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
table.insert(items, { label = "STRENGTH", action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = "TELEPORT", action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||
-- the dark (Rock Tunnel)
|
||||
table.insert(items, { label = "DIG", action = "escape" })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+6
-3
@@ -71,6 +71,7 @@ end
|
||||
local function withdraw(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
@@ -107,6 +108,7 @@ local function deposit(game)
|
||||
if not Bag.isBadge(id) then depositable[id] = count end
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
||||
messageBox = true,
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
@@ -126,6 +128,7 @@ end
|
||||
local function toss(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
onChoose = function(item, list)
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
@@ -161,9 +164,9 @@ function PlayerPC.new(game)
|
||||
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
|
||||
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
|
||||
{ label = "LOG OFF" },
|
||||
-- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND,
|
||||
-- engine/menus/players_pc.asm PlayersPCMenu)
|
||||
}, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true })
|
||||
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
|
||||
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
|
||||
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
|
||||
end
|
||||
|
||||
return PlayerPC
|
||||
|
||||
@@ -40,15 +40,19 @@ function QuantityBox:update(dt)
|
||||
end
|
||||
|
||||
function QuantityBox:draw()
|
||||
local w = self.unitPrice and 11 or 7
|
||||
local tx = 20 - w - 1
|
||||
Font.drawBox(tx, 13, w, 3)
|
||||
-- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at
|
||||
-- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1).
|
||||
-- TextBoxBorder adds the frame, so outer size is +2 on each axis.
|
||||
local tw = self.unitPrice and 13 or 5
|
||||
local tx = self.unitPrice and 7 or 15
|
||||
local ty = 9
|
||||
Font.drawBox(tx, ty, tw, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local s = ("×%02d"):format(self.qty) -- the multiply glyph tile
|
||||
if self.unitPrice then
|
||||
s = s .. (" ¥%d"):format(self.qty * self.unitPrice)
|
||||
end
|
||||
Font.draw(s, (tx + 1) * 8, 14 * 8)
|
||||
Font.draw(s, (tx + 1) * 8, (ty + 1) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ local Theme = {
|
||||
cols = Renderer.WIDTH / 8,
|
||||
rows = Renderer.HEIGHT / 8,
|
||||
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
|
||||
choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
|
||||
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
|
||||
}
|
||||
|
||||
function Theme.load(data)
|
||||
|
||||
+427
-45
@@ -1,8 +1,7 @@
|
||||
-- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm):
|
||||
-- the traded POKéMON rises away with its cry and a goodbye, then the
|
||||
-- received one descends with its cry and "take good care" text.
|
||||
-- A skips the slide animations ahead. Calls onDone() after popping.
|
||||
-- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic
|
||||
-- used by in-game NPC trades and internally-clocked link trades.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
@@ -10,13 +9,22 @@ local TradeAnim = {}
|
||||
TradeAnim.__index = TradeAnim
|
||||
TradeAnim.isOpaque = true
|
||||
|
||||
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
|
||||
function TradeAnim:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local SLIDE_FRAMES = 90
|
||||
local REST_Y = 44 -- resting top of the sprite, roughly screen centre
|
||||
local DEFAULT_ART = {
|
||||
gameBoy = "assets/generated/trade/game_boy.png",
|
||||
openCable = "assets/generated/trade/open_cable.png",
|
||||
cableHoriz = "assets/generated/trade/cable_horiz.png",
|
||||
cableConn = "assets/generated/trade/cable_conn.png",
|
||||
cableVert = "assets/generated/trade/cable_vert.png",
|
||||
cableCorner = "assets/generated/trade/cable_corner.png",
|
||||
cableEnd = "assets/generated/trade/cable_end.png",
|
||||
cableBall = "assets/generated/trade/cable_ball.png",
|
||||
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
|
||||
bubble = "assets/generated/trade/bubble.png",
|
||||
}
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
@@ -29,11 +37,46 @@ local function nameOf(game, mon)
|
||||
return mon.nickname or (def and def.name) or mon.species
|
||||
end
|
||||
|
||||
local function speciesName(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return (def and def.name) or mon.species
|
||||
end
|
||||
|
||||
local function dexOf(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return def and def.dex or 0
|
||||
end
|
||||
|
||||
local function spriteOf(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return tryImage(def and def.spriteFront)
|
||||
end
|
||||
|
||||
local function expand(game, key, subs)
|
||||
local raw = game.data.text and game.data.text[key]
|
||||
if not raw then return key end
|
||||
for token, value in pairs(subs or {}) do
|
||||
raw = raw:gsub("{" .. token .. "}", value)
|
||||
end
|
||||
return TextBox.substitute(game, raw)
|
||||
end
|
||||
|
||||
-- InternalClockTradeFuncSequence
|
||||
local SEQ = {
|
||||
"show_player",
|
||||
"open_cable",
|
||||
"ball_enter",
|
||||
"transfer_lr",
|
||||
"delay",
|
||||
"went_to",
|
||||
"for_sends",
|
||||
"farewell",
|
||||
"transfer_rl",
|
||||
"open_cable2",
|
||||
"show_enemy",
|
||||
"done",
|
||||
}
|
||||
|
||||
function TradeAnim.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, TradeAnim)
|
||||
@@ -41,62 +84,401 @@ function TradeAnim.new(game, opts)
|
||||
self.sent = opts.sent
|
||||
self.received = opts.received
|
||||
self.onDone = opts.onDone
|
||||
self.enemyName = opts.enemyName or (self.received and self.received.ot) or "TRAINER"
|
||||
self.playerName = (game.save.player and game.save.player.name) or "RED"
|
||||
self.playerOt = opts.playerOt or self.playerName
|
||||
self.playerOtId = opts.playerOtId
|
||||
or (self.sent and self.sent.otId)
|
||||
or (game.save.player and game.save.player.id)
|
||||
or 0
|
||||
self.enemyOtId = opts.enemyOtId
|
||||
or (self.received and self.received.otId)
|
||||
or love.math.random(0, 65535)
|
||||
|
||||
local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART
|
||||
self.img = {
|
||||
gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy),
|
||||
openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
|
||||
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
|
||||
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
|
||||
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
|
||||
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
|
||||
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
|
||||
cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall),
|
||||
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
|
||||
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
|
||||
}
|
||||
self.sentSprite = spriteOf(game, self.sent)
|
||||
self.receivedSprite = spriteOf(game, self.received)
|
||||
self.phase = "out"
|
||||
self.recvSprite = spriteOf(game, self.received)
|
||||
|
||||
self.seq = 1
|
||||
self.phase = SEQ[1]
|
||||
self.t = 0
|
||||
self.scx = 0
|
||||
self.ballX = 0
|
||||
self.ballY = 0
|
||||
self.monX = 0
|
||||
self.monY = 0
|
||||
self.flash = false
|
||||
self.monVisible = true
|
||||
self.waitingText = false
|
||||
self.cableFlash = false
|
||||
return self
|
||||
end
|
||||
|
||||
function TradeAnim:enter()
|
||||
Sound.playCry(self.game.data, self.sent.species)
|
||||
Sound.play(self.game.data, "Trade_Machine")
|
||||
end
|
||||
|
||||
function TradeAnim:advance()
|
||||
self.seq = self.seq + 1
|
||||
self.phase = SEQ[self.seq] or "done"
|
||||
self.t = 0
|
||||
self.scx = 0
|
||||
self.sub = nil
|
||||
self.flash = false
|
||||
self.cableFlash = false
|
||||
self.poof = nil
|
||||
if self.phase == "done" then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
elseif self.phase == "show_enemy" then
|
||||
self.monVisible = false
|
||||
elseif self.phase == "transfer_lr" then
|
||||
-- wBaseCoord $54, $1c OAM -> screen (76, 12)
|
||||
self.monX, self.monY = 76, 12
|
||||
elseif self.phase == "transfer_rl" then
|
||||
-- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen
|
||||
self.monX, self.monY = 92, 52
|
||||
self.scx = 160
|
||||
elseif self.phase == "ball_enter" then
|
||||
-- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable)
|
||||
self.ballX, self.ballY = 0x60, 0x20
|
||||
elseif self.phase == "open_cable" or self.phase == "open_cable2" then
|
||||
-- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64
|
||||
self.scx = 0x50
|
||||
Sound.play(self.game.data, "Heal_HP")
|
||||
end
|
||||
end
|
||||
|
||||
function TradeAnim:say(text, delay, thenFn)
|
||||
self.waitingText = true
|
||||
self.game.stack:push(TextBox.new(self.game, text, function()
|
||||
self.waitingText = false
|
||||
if thenFn then thenFn() else self:advance() end
|
||||
end, { auto = { delay = delay or 80 } }))
|
||||
end
|
||||
|
||||
function TradeAnim:skipHeld()
|
||||
return self.game.input:wasPressed("a") or self.game.input:isDown("a")
|
||||
end
|
||||
|
||||
function TradeAnim:update(dt)
|
||||
local input = self.game.input
|
||||
if self.phase == "out" or self.phase == "in" then
|
||||
self.t = self.t + 1
|
||||
if input:wasPressed("a") then self.t = SLIDE_FRAMES end
|
||||
if self.t < SLIDE_FRAMES then return end
|
||||
if self.phase == "out" then
|
||||
self.phase = "goodbye"
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
("Goodbye %s!"):format(nameOf(self.game, self.sent)),
|
||||
function()
|
||||
self.phase = "in"
|
||||
self.t = 0
|
||||
Sound.playCry(self.game.data, self.received.species)
|
||||
end))
|
||||
if self.waitingText or self.phase == "done" then return end
|
||||
local skip = self.game.input:wasPressed("a")
|
||||
self.t = self.t + 1
|
||||
local p = self.phase
|
||||
|
||||
if p == "show_player" then
|
||||
-- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon)
|
||||
if not self.sub then self.sub = "slide" end
|
||||
if self.sub == "slide" then
|
||||
self.scx = math.max(0, 0x86 - self.t * 2)
|
||||
if skip or self.scx <= 0 then
|
||||
self.scx = 0
|
||||
self.sub = "hold"
|
||||
self.t = 0
|
||||
end
|
||||
elseif self.sub == "hold" then
|
||||
if skip or self.t >= 80 then
|
||||
self.sub = "poof"
|
||||
self.t = 0
|
||||
self.monVisible = false
|
||||
Sound.play(self.game.data, "Ball_Poof")
|
||||
end
|
||||
elseif self.sub == "poof" then
|
||||
self.poof = math.max(0, 16 - self.t)
|
||||
if skip or self.t >= 16 then
|
||||
Sound.playCry(self.game.data, self.sent.species)
|
||||
self.sub = nil
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
|
||||
elseif p == "open_cable" or p == "open_cable2" then
|
||||
-- 20 steps of 4px, matching the SCX loop
|
||||
self.scx = math.max(0, 0x50 - self.t * 4)
|
||||
if skip or self.scx <= 0 then
|
||||
self.scx = 0
|
||||
self:advance()
|
||||
end
|
||||
|
||||
elseif p == "ball_enter" then
|
||||
-- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0
|
||||
if self.t < 20 then
|
||||
if skip then self.t = 20 end
|
||||
return
|
||||
end
|
||||
local step = self.t - 20
|
||||
if step % 3 == 0 then
|
||||
self.ballX = 0x60 + math.floor(step / 3) * 4
|
||||
self.flash = not self.flash
|
||||
if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end
|
||||
end
|
||||
if skip then self.ballX = 0xA0 end
|
||||
if self.ballX >= 0xA0 then self:advance() end
|
||||
|
||||
elseif p == "transfer_lr" then
|
||||
-- scroll left GB off while the mon rides the cable, then the sprite
|
||||
-- itself moves right and down into the right GB (Trade_AnimMonMoveVertical)
|
||||
if self.t <= 80 then
|
||||
self.scx = math.min(160, self.t * 2)
|
||||
self.monX, self.monY = 76, 12
|
||||
elseif self.t <= 80 + 32 then
|
||||
self.monX = 76 + math.floor((self.t - 80) / 8) * 4
|
||||
elseif self.t <= 80 + 64 then
|
||||
self.monX = 92
|
||||
self.monY = 12 + math.floor((self.t - 112) / 8) * 10
|
||||
else
|
||||
self.phase = "takecare"
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
("Take good care\nof %s!"):format(nameOf(self.game, self.received)),
|
||||
function()
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end))
|
||||
self:advance()
|
||||
return
|
||||
end
|
||||
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
|
||||
if skip then self:advance() end
|
||||
|
||||
elseif p == "transfer_rl" then
|
||||
-- mirror: sprite climbs out of the right GB, then the screen scrolls
|
||||
-- back to the left GB
|
||||
if self.t <= 32 then
|
||||
self.scx = 160
|
||||
self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10
|
||||
elseif self.t <= 64 then
|
||||
self.monX = 92 - math.floor((self.t - 32) / 8) * 4
|
||||
self.monY = 12
|
||||
elseif self.t <= 64 + 80 then
|
||||
self.scx = math.max(0, 160 - (self.t - 64) * 2)
|
||||
self.monX, self.monY = 76, 12
|
||||
else
|
||||
self:advance()
|
||||
return
|
||||
end
|
||||
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
|
||||
if skip then self:advance() end
|
||||
|
||||
elseif p == "delay" then
|
||||
if skip or self.t >= 100 then self:advance() end
|
||||
|
||||
elseif p == "went_to" then
|
||||
local text = expand(self.game, "_TradeWentToText", {
|
||||
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
})
|
||||
self:say(text, 200)
|
||||
|
||||
elseif p == "for_sends" then
|
||||
local a = expand(self.game, "_TradeForText", {
|
||||
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
|
||||
})
|
||||
local b = expand(self.game, "_TradeSendsText", {
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
self.waitingText = true
|
||||
self.game.stack:push(TextBox.new(self.game, a, function()
|
||||
self.game.stack:push(TextBox.new(self.game, b, function()
|
||||
self.waitingText = false
|
||||
self:advance()
|
||||
end, { auto = { delay = 80 } }))
|
||||
end, { auto = { delay = 80 } }))
|
||||
|
||||
elseif p == "farewell" then
|
||||
local a = expand(self.game, "_TradeWavesFarewellText", {
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
})
|
||||
local b = expand(self.game, "_TradeTransferredText", {
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
self.waitingText = true
|
||||
self.game.stack:push(TextBox.new(self.game, a, function()
|
||||
self.game.stack:push(TextBox.new(self.game, b, function()
|
||||
self.waitingText = false
|
||||
self:advance()
|
||||
end, { auto = { delay = 80 } }))
|
||||
end, { auto = { delay = 80 } }))
|
||||
|
||||
elseif p == "show_enemy" then
|
||||
if self.t == 1 then
|
||||
Sound.play(self.game.data, "Ball_Poof")
|
||||
self.monVisible = true
|
||||
elseif self.t == 20 then
|
||||
Sound.playCry(self.game.data, self.received.species)
|
||||
elseif self.t >= 120 or skip then
|
||||
local text = expand(self.game, "_TradeTakeCareText", {
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
self:say(text, 80)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function drawCableHoriz(self, y, x0, x1)
|
||||
if self.img.cableHoriz then
|
||||
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y)
|
||||
else
|
||||
love.graphics.setColor(0.2, 0.2, 0.2, 1)
|
||||
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
||||
-- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the
|
||||
-- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm)
|
||||
Font.drawBox(4, boxTy, 12, 8)
|
||||
local y0 = boxTy * 8
|
||||
local no = ("No.%03d"):format(dexOf(self.game, mon))
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 56, y0, Font.width(no), 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(no, 56, y0)
|
||||
Font.draw(speciesName(self.game, mon), 40, y0 + 16)
|
||||
Font.draw("OT/" .. (ot or "????"), 40, y0 + 32)
|
||||
Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TradeAnim:drawIconInBubble(mon, x, y)
|
||||
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
|
||||
if spr then
|
||||
local sw, sh = spr:getDimensions()
|
||||
local s = 16 / math.max(sw, sh)
|
||||
love.graphics.draw(spr, x, y, 0, s, s)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.img.bubble then
|
||||
if not self.bubbleQuad then
|
||||
local iw, ih = self.img.bubble:getDimensions()
|
||||
self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih)
|
||||
self.bubbleQuadAlt = ih >= 32
|
||||
and love.graphics.newQuad(0, 16, 16, 16, iw, ih)
|
||||
or self.bubbleQuad
|
||||
end
|
||||
local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad
|
||||
love.graphics.draw(self.img.bubble, q, x - 8, y - 8)
|
||||
end
|
||||
end
|
||||
|
||||
function TradeAnim:drawGameBoy(x, y)
|
||||
if self.img.gameBoy then
|
||||
love.graphics.draw(self.img.gameBoy, x, y)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", x, y, 48, 64)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
function TradeAnim:drawLeftGB()
|
||||
-- cable from GB to the right edge
|
||||
if self.img.cableConn then
|
||||
love.graphics.draw(self.img.cableConn, 88, 32)
|
||||
end
|
||||
drawCableHoriz(self, 32, 96, 160)
|
||||
self:drawGameBoy(40, 24)
|
||||
Font.drawBox(4, 12, 9, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.playerName, 40, 112)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TradeAnim:drawRightGB()
|
||||
drawCableHoriz(self, 32, 0, 112)
|
||||
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
|
||||
if self.img.cableVert then
|
||||
for i = 1, 4 do
|
||||
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8)
|
||||
end
|
||||
end
|
||||
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
|
||||
if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end
|
||||
self:drawGameBoy(56, 64)
|
||||
Font.drawBox(6, 0, 9, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.enemyName, 56, 16)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TradeAnim:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local sprite, y
|
||||
if self.phase == "out" then
|
||||
-- the sent mon rises up and off the screen
|
||||
sprite = self.sentSprite
|
||||
y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60))
|
||||
elseif self.phase == "in" or self.phase == "takecare" then
|
||||
-- the received mon descends into place
|
||||
sprite = self.receivedSprite
|
||||
local t = self.phase == "in" and self.t or SLIDE_FRAMES
|
||||
y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60))
|
||||
end
|
||||
if sprite and y then
|
||||
local w = sprite:getWidth()
|
||||
love.graphics.draw(sprite, math.floor((160 - w) / 2), y)
|
||||
local p = self.phase
|
||||
|
||||
if p == "show_player" then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(-self.scx, 0)
|
||||
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
|
||||
-- hWY $50, so it sits in the bottom half of the screen
|
||||
if self.monVisible and self.sentSprite then
|
||||
love.graphics.draw(self.sentSprite, 56, 16)
|
||||
end
|
||||
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
|
||||
love.graphics.pop()
|
||||
if self.poof and self.poof > 0 then
|
||||
love.graphics.setColor(0, 0, 0, self.poof / 16)
|
||||
love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0))
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
elseif p == "open_cable" or p == "open_cable2" then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(self.scx, 0)
|
||||
if self.img.openCable then
|
||||
love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0
|
||||
end
|
||||
love.graphics.pop()
|
||||
|
||||
elseif p == "ball_enter" then
|
||||
if self.img.openCable then
|
||||
love.graphics.draw(self.img.openCable, 64, 16)
|
||||
end
|
||||
-- OAM coords carry a (+8, +16) hardware offset
|
||||
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
|
||||
or self.img.cableBall
|
||||
if ball then
|
||||
love.graphics.draw(ball, self.ballX - 8, self.ballY - 16)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
elseif p == "transfer_lr" or p == "transfer_rl" then
|
||||
-- two-screen world: left GB scene at x0, right GB scene at x160;
|
||||
-- the mon icon stays in screen space like the OAM sprite it ports
|
||||
love.graphics.push()
|
||||
love.graphics.translate(-self.scx, 0)
|
||||
self:drawLeftGB()
|
||||
love.graphics.translate(160, 0)
|
||||
self:drawRightGB()
|
||||
love.graphics.pop()
|
||||
local mon = (p == "transfer_lr") and self.sent or self.received
|
||||
self:drawIconInBubble(mon, self.monX, self.monY)
|
||||
if self.cableFlash then
|
||||
love.graphics.setColor(1, 1, 1, 0.15)
|
||||
love.graphics.rectangle("fill", 0, 32, 160, 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
elseif p == "show_enemy" then
|
||||
if self.monVisible and self.recvSprite then
|
||||
love.graphics.draw(self.recvSprite, 56, 16)
|
||||
end
|
||||
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
|
||||
end
|
||||
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
|
||||
end
|
||||
|
||||
return TradeAnim
|
||||
|
||||
@@ -34,7 +34,8 @@ function TrainerCard.new(game)
|
||||
local self = setmetatable({ game = game }, TrainerCard)
|
||||
local img = tryImage("assets/generated/trainer_card/badges.png")
|
||||
if img then
|
||||
-- 8 pairs of [gym leader face, badge]
|
||||
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles)
|
||||
self.faces = { img = img, quads = quads16(img, 8, 32, 0, 0) }
|
||||
self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) }
|
||||
end
|
||||
local nums = tryImage("assets/generated/trainer_card/badge_numbers.png")
|
||||
@@ -124,7 +125,7 @@ function TrainerCard:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
-- numbered badge grid (rows 11-17): earned solid, unearned dimmed
|
||||
-- numbered badge grid (rows 11-17): face by default, badge when owned
|
||||
self:frameBox(0, 11, 20, 7)
|
||||
local badges = Badges.list(self.game.data)
|
||||
for i = 1, #badges do
|
||||
@@ -136,12 +137,11 @@ function TrainerCard:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty)
|
||||
end
|
||||
if self.badges and self.badges.quads[i - 1]
|
||||
and save.inventory[Badges.itemFor(badges[i])] then
|
||||
-- unearned badge slots stay blank (DrawBadges)
|
||||
if self.faces and self.faces.quads[i - 1] then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.badges.img, self.badges.quads[i - 1],
|
||||
tx + 4, ty + 6)
|
||||
local owned = save.inventory[Badges.itemFor(badges[i])]
|
||||
local sheet = owned and self.badges or self.faces
|
||||
love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
-- The boot shell: the heart of the self-updater. A fused build, before it
|
||||
-- runs the game bundled inside it, looks in its save directory for a newer
|
||||
-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and
|
||||
-- runnable, mounts it over the bundled source and chainloads it -- so the
|
||||
-- binary shipped once can keep updating the Lua it runs without a reinstall.
|
||||
--
|
||||
-- Only a fused build self-updates. A dev / source checkout IS the game, so
|
||||
-- Boot.run is a no-op there.
|
||||
--
|
||||
-- Three pieces, deliberately layered so the risky part is small and the
|
||||
-- decision part is testable:
|
||||
-- * Boot.select -- pure: given probed candidates + the bundled version,
|
||||
-- decide what to run and what to delete. No love.*.
|
||||
-- * Boot.probePayload-- read one archive's advertised version, isolated.
|
||||
-- * Boot.run -- orchestrates: crash-guard, enumerate, select, and
|
||||
-- (if a payload wins) mount + chainload with full
|
||||
-- rollback on any failure so the bundled game always
|
||||
-- boots.
|
||||
--
|
||||
-- Known limitation: the bundled love.run keeps driving the frame loop after a
|
||||
-- handoff (it has already returned its stepper to LÖVE; redefining the global
|
||||
-- love.run does nothing to the running one). A payload that must change
|
||||
-- love.run itself therefore requires a minShell bump so an older shell refuses
|
||||
-- to chainload it.
|
||||
|
||||
local Semver = require("src.update.Semver")
|
||||
|
||||
local Boot = {}
|
||||
|
||||
-- Save-directory layout (identity "pokemon-love2d"), per the shared contract.
|
||||
local PAYLOAD_DIR = "updates"
|
||||
local PENDING = "updates/pending.txt"
|
||||
|
||||
-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its
|
||||
-- copy never collides with the running source's copy at "/".
|
||||
local PROBE_MOUNT = "__pokeport_probe"
|
||||
|
||||
-- Downloaded payloads are named gen1recomp-<X.Y.Z>.love.
|
||||
local function isPayloadName(name)
|
||||
return name:match("^gen1recomp%-.+%.love$") ~= nil
|
||||
end
|
||||
|
||||
-- The love callbacks the payload's main.lua chunk may redefine when it runs.
|
||||
-- We snapshot these before a handoff and restore them if the handoff fails, so
|
||||
-- the exact bundled closures (with their intact upvalues) drive the game
|
||||
-- again. love.run is included: harmless to restore, and it is one of the
|
||||
-- globals a payload main.lua reassigns.
|
||||
local CALLBACK_NAMES = {
|
||||
"load", "update", "draw", "quit", "run",
|
||||
"keypressed", "keyreleased", "textinput",
|
||||
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
|
||||
"touchpressed", "touchmoved", "touchreleased",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
|
||||
"focus", "visible", "resize", "filedropped", "directorydropped",
|
||||
"errorhandler", "threaderror", "lowmemory",
|
||||
}
|
||||
|
||||
local function snapshotCallbacks()
|
||||
local snap = {}
|
||||
for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end
|
||||
return snap
|
||||
end
|
||||
|
||||
local function restoreCallbacks(snap)
|
||||
for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end
|
||||
end
|
||||
|
||||
-- Drop every bundled Lua module the payload must be allowed to re-resolve: all
|
||||
-- src.* modules plus the main/conf chunks. conf.lua cached the bundled
|
||||
-- Version into package.loaded["src.core.Version"]; without this purge the next
|
||||
-- require would hand back the bundled copy instead of the payload's. Setting
|
||||
-- existing fields to nil during a pairs traversal is explicitly permitted.
|
||||
local function purgeBundledModules()
|
||||
for key in pairs(package.loaded) do
|
||||
if key:match("^src%.") or key == "main" or key == "conf" then
|
||||
package.loaded[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
|
||||
--
|
||||
-- Mount the archive at rel (a save-directory-relative path) on an isolated
|
||||
-- mountpoint, read its src/core/Version.lua by executing the source with
|
||||
-- loadstring (NEVER require -- we must not cache or run it as a module), then
|
||||
-- unmount. Version.lua is zero-require, so running its chunk is safe.
|
||||
function Boot.probePayload(rel)
|
||||
if not love.filesystem.mount(rel, PROBE_MOUNT) then
|
||||
return nil, "could not mount " .. tostring(rel)
|
||||
end
|
||||
local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua"
|
||||
local ok, result = pcall(function()
|
||||
local src = love.filesystem.read(chunkPath)
|
||||
if not src then error("Version.lua missing", 0) end
|
||||
local chunk = loadstring(src, "@" .. chunkPath)
|
||||
if not chunk then error("Version.lua would not compile", 0) end
|
||||
return chunk()
|
||||
end)
|
||||
love.filesystem.unmount(rel)
|
||||
if not ok then return nil, tostring(result) end
|
||||
local v = result
|
||||
if type(v) ~= "table" or type(v.engine) ~= "string" then
|
||||
return nil, "payload has no usable Version table"
|
||||
end
|
||||
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
|
||||
end
|
||||
|
||||
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
|
||||
--
|
||||
-- Pure (no love.*): decide which payload to run and which to delete.
|
||||
-- candidates is a list of { name = , engine = , minShell = }.
|
||||
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
|
||||
-- whose minShell <= bundledShell (a payload the running shell can host).
|
||||
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
|
||||
-- already ship), or superseded by the chosen one (not newer than chosen).
|
||||
-- A payload newer than the chosen one but unrunnable here (minShell too
|
||||
-- high) is kept: a future shell upgrade may be able to run it.
|
||||
function Boot.select(candidates, bundledEngine, bundledShell)
|
||||
local chosen
|
||||
for _, c in ipairs(candidates) do
|
||||
local newer = Semver.compare(c.engine, bundledEngine) > 0
|
||||
local runnable = (c.minShell or 1) <= bundledShell
|
||||
if newer and runnable then
|
||||
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
|
||||
chosen = c
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local toDelete = {}
|
||||
for _, c in ipairs(candidates) do
|
||||
if not (chosen and c.name == chosen.name) then
|
||||
local stale = Semver.compare(c.engine, bundledEngine) <= 0
|
||||
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
|
||||
stale = true
|
||||
end
|
||||
if stale then toDelete[#toDelete + 1] = c.name end
|
||||
end
|
||||
end
|
||||
|
||||
return chosen and chosen.name or nil, toDelete
|
||||
end
|
||||
|
||||
-- Mount the chosen payload and hand control to it. Returns true when the
|
||||
-- payload is live and has completed its own love.load; false (with full
|
||||
-- rollback) on any failure, so the caller runs the bundled game instead.
|
||||
local function chainload(name, args)
|
||||
local rel = PAYLOAD_DIR .. "/" .. name
|
||||
|
||||
-- Crash marker: if we die between here and clearing it, the next boot's
|
||||
-- crash guard distrusts this payload and deletes it.
|
||||
love.filesystem.write(PENDING, name)
|
||||
|
||||
-- Prepend-mount the payload at "/" (appendToPath = false) so its files win
|
||||
-- over the fused source for every subsequent require / love.filesystem read.
|
||||
if not love.filesystem.mount(rel, "/", false) then
|
||||
love.filesystem.remove(PENDING)
|
||||
return false
|
||||
end
|
||||
|
||||
local snapshot = snapshotCallbacks()
|
||||
purgeBundledModules()
|
||||
_G.POKEPORT_PAYLOAD_MOUNTED = true
|
||||
|
||||
-- Chainload: run the payload's main.lua (redefines the love callbacks from
|
||||
-- the NEW code), then call its love.load. The new love.load calls Boot.run
|
||||
-- again, which no-ops via the flag set above.
|
||||
local ok, err = pcall(function()
|
||||
local chunk = assert(love.filesystem.load("main.lua"))
|
||||
chunk()
|
||||
love.load(args)
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
-- Handoff failed after mounting. Unwind everything so the bundled game
|
||||
-- boots cleanly: clear the flag, unmount the payload, purge any payload
|
||||
-- modules it cached (so bundled requires reload from source), restore the
|
||||
-- bundled love callbacks with their intact upvalues, and drop the marker.
|
||||
-- Delete the payload too: it failed deterministically once, so leaving it
|
||||
-- would re-select and re-fail it on every boot forever.
|
||||
print("update: payload handoff failed, reverting to bundled: " .. tostring(err))
|
||||
_G.POKEPORT_PAYLOAD_MOUNTED = nil
|
||||
pcall(love.filesystem.unmount, rel)
|
||||
purgeBundledModules()
|
||||
restoreCallbacks(snapshot)
|
||||
love.filesystem.remove(rel)
|
||||
love.filesystem.remove(PENDING)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Success: the payload owns the game now. Drop the marker and tell the
|
||||
-- caller to stop so the bundled love.load does not run on top of it.
|
||||
love.filesystem.remove(PENDING)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Everything after the fused / flag guards, wrapped so an unexpected error in
|
||||
-- enumeration or selection can never crash the boot.
|
||||
local function runInner(args)
|
||||
-- Crash guard first: a pending.txt naming a payload means a previous boot
|
||||
-- crashed mid-handoff. Distrust that payload -- delete it and the marker --
|
||||
-- then continue (we may still pick an older valid payload, or fall through
|
||||
-- to the bundled game).
|
||||
local pending = love.filesystem.read(PENDING)
|
||||
if pending then
|
||||
pending = pending:gsub("%s+$", "")
|
||||
if pending ~= "" then
|
||||
love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending)
|
||||
end
|
||||
love.filesystem.remove(PENDING)
|
||||
end
|
||||
|
||||
-- Enumerate and probe every payload in updates/.
|
||||
local candidates = {}
|
||||
if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then
|
||||
for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do
|
||||
if isPayloadName(entry) then
|
||||
local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry)
|
||||
if info then
|
||||
candidates[#candidates + 1] = {
|
||||
name = entry,
|
||||
engine = info.engine,
|
||||
minShell = info.minShell,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local Version = require("src.core.Version")
|
||||
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
|
||||
|
||||
for _, victim in ipairs(toDelete) do
|
||||
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
|
||||
end
|
||||
|
||||
if not chosen then return false end
|
||||
return chainload(chosen, args)
|
||||
end
|
||||
|
||||
-- Boot.run(args) -> boolean
|
||||
--
|
||||
-- The first line of love.load. True means a payload was mounted and
|
||||
-- chainloaded and the caller must return immediately; false means boot the
|
||||
-- bundled game as normal.
|
||||
function Boot.run(args)
|
||||
-- Dev / source checkouts never self-update.
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) then
|
||||
return false
|
||||
end
|
||||
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
|
||||
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
|
||||
|
||||
local ok, result = pcall(runInner, args)
|
||||
if not ok then
|
||||
-- An error escaped before any handoff mount (chainload cleans up after
|
||||
-- itself), so state is still clean. Never crash the boot.
|
||||
return false
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
return Boot
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Async release-check and payload-download for the self-update flow.
|
||||
--
|
||||
-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens
|
||||
-- on a background love.thread worker (src/update/check_worker.lua); this module
|
||||
-- is only the thin main-thread state machine the UI polls. Two channels carry
|
||||
-- the conversation:
|
||||
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" }
|
||||
-- "update_check_state" worker -> main: { status, latest, progress, error }
|
||||
--
|
||||
-- Nothing here ever blocks or throws into the game loop: when love.thread is
|
||||
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
|
||||
-- state() simply reports "error" and the UI hides itself. See the shared
|
||||
-- contract in the task brief for the status vocabulary and the file layout.
|
||||
--
|
||||
-- The release-JSON extraction and the sums parsing are exported as pure
|
||||
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
|
||||
-- worker can reuse the exact same code path via love.filesystem.load.
|
||||
|
||||
local Check = {}
|
||||
|
||||
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
|
||||
|
||||
local CMD = "update_check_cmd"
|
||||
local STATE = "update_check_state"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- pure helpers (no love.*) -- also used inside the worker
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Find the release asset named exactly `name`, returning its download URL and
|
||||
-- byte size (or nil when the release has no such asset).
|
||||
function Check.pickAsset(assets, name)
|
||||
if type(assets) ~= "table" then return nil end
|
||||
for _, a in ipairs(assets) do
|
||||
if type(a) == "table" and a.name == name then
|
||||
return { url = a.browser_download_url, size = tonumber(a.size) }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function stripV(tag)
|
||||
return (tostring(tag):gsub("^[vV]", ""))
|
||||
end
|
||||
|
||||
-- Decode a GitHub "releases/latest" response into just the fields the updater
|
||||
-- needs. Returns { version, payloadName, payload, sums } where payload/sums are
|
||||
-- { url, size } tables (or nil when that asset is missing), or nil, err when the
|
||||
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
|
||||
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
|
||||
-- falls back to require.
|
||||
function Check.parseRelease(jsonText, Json)
|
||||
Json = Json or require("src.link.Json")
|
||||
local doc = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" or not doc.tag_name then
|
||||
return nil, "no tag_name in release json"
|
||||
end
|
||||
local version = stripV(doc.tag_name)
|
||||
if not version:match("^%d+%.%d+%.%d+$") then
|
||||
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
|
||||
end
|
||||
local payloadName = "gen1recomp-" .. version .. ".love"
|
||||
return {
|
||||
version = version,
|
||||
payloadName = payloadName,
|
||||
payload = Check.pickAsset(doc.assets, payloadName),
|
||||
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
||||
}
|
||||
end
|
||||
|
||||
-- Parse a shasum -a 256 file ("<hex> <filename>", bare filenames). With a
|
||||
-- `target` argument returns just that file's hash (or nil); otherwise returns
|
||||
-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix.
|
||||
function Check.parseSums(text, target)
|
||||
local map = {}
|
||||
for line in tostring(text):gmatch("[^\r\n]+") do
|
||||
local hash, file = line:match("^(%x+)%s+%*?(%S+)")
|
||||
if hash and file then
|
||||
map[(file:gsub("^%./", ""))] = hash:lower()
|
||||
end
|
||||
end
|
||||
if target ~= nil then return map[target] end
|
||||
return map
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- main-thread state machine
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function Check.releaseUrl()
|
||||
return "https://github.com/" .. Check.REPO .. "/releases/latest"
|
||||
end
|
||||
|
||||
local worker -- the love.thread, once started
|
||||
local cmdCh, stateCh -- the two channels
|
||||
local workerReady -- nil = untried, true = running, false = unavailable
|
||||
local requested -- a check has been asked for this session
|
||||
local cache = { status = "idle" } -- newest snapshot from the worker
|
||||
|
||||
local function ensureWorker()
|
||||
if workerReady ~= nil then return workerReady end
|
||||
if not (love and love.thread and love.thread.newThread) then
|
||||
workerReady = false
|
||||
return false
|
||||
end
|
||||
local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua")
|
||||
if not ok or not th then
|
||||
workerReady = false
|
||||
return false
|
||||
end
|
||||
cmdCh = love.thread.getChannel(CMD)
|
||||
stateCh = love.thread.getChannel(STATE)
|
||||
if not pcall(function() th:start() end) then
|
||||
workerReady = false
|
||||
return false
|
||||
end
|
||||
worker = th
|
||||
workerReady = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- Pull every pending snapshot off the state channel (keeping the newest) and
|
||||
-- surface a worker crash as a soft error the UI can hide on.
|
||||
local function drain()
|
||||
if stateCh then
|
||||
local msg = stateCh:pop()
|
||||
while msg do
|
||||
cache = msg
|
||||
msg = stateCh:pop()
|
||||
end
|
||||
end
|
||||
if worker then
|
||||
local err = worker:getError()
|
||||
if err then
|
||||
cache = { status = "error", error = tostring(err) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
|
||||
-- once a check is in flight or has reached a terminal state it is a no-op.
|
||||
function Check.start()
|
||||
drain()
|
||||
if cache.status == "checking" or cache.status == "downloading" then return end
|
||||
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
|
||||
if not ensureWorker() then
|
||||
cache = { status = "error", error = "background threads unavailable" }
|
||||
return
|
||||
end
|
||||
requested = true
|
||||
cache = { status = "checking" }
|
||||
cmdCh:push({ cmd = "check" })
|
||||
end
|
||||
|
||||
-- Current snapshot: { status, latest, progress, error }. status is one of
|
||||
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
||||
function Check.state()
|
||||
drain()
|
||||
return {
|
||||
status = cache.status or "idle",
|
||||
latest = cache.latest,
|
||||
progress = cache.progress,
|
||||
error = cache.error,
|
||||
}
|
||||
end
|
||||
|
||||
-- Start downloading the payload announced by an "available" check. A no-op in
|
||||
-- any other state (the worker still holds the release info from the check).
|
||||
function Check.download()
|
||||
drain()
|
||||
if not cmdCh then return end
|
||||
if cache.status ~= "available" then return end
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0 }
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
return Check
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Strict X.Y.Z semantic-version parsing and comparison for the self-updater.
|
||||
-- Every part of the updater (Boot, Check, the release picker) agrees on this
|
||||
-- one notion of "newer". Zero requires and no love.* calls, so plain-Lua
|
||||
-- tests can exercise it and Boot can use it during the earliest boot step.
|
||||
--
|
||||
-- We only need the numeric core (major.minor.patch): the engine field is a
|
||||
-- bare X.Y.Z in shipped builds and the "0.0.0-dev" placeholder in the working
|
||||
-- tree. Pre-release / build metadata is intentionally not supported -- a
|
||||
-- "-dev" or any other suffix makes parse fail, which is the safe answer for
|
||||
-- the updater (a dev checkout never counts as a real release to chainload).
|
||||
|
||||
local Semver = {}
|
||||
|
||||
-- Parse a strict "X.Y.Z" string (an optional leading "v" is allowed) into
|
||||
-- { major = n, minor = n, patch = n }. Returns nil for anything else --
|
||||
-- extra components, non-numeric parts, or a trailing suffix like "-dev".
|
||||
function Semver.parse(s)
|
||||
if type(s) ~= "string" then return nil end
|
||||
local body = s:match("^v?(.+)$")
|
||||
if not body then return nil end
|
||||
local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$")
|
||||
if not maj then return nil end
|
||||
return {
|
||||
major = tonumber(maj),
|
||||
minor = tonumber(min),
|
||||
patch = tonumber(pat),
|
||||
}
|
||||
end
|
||||
|
||||
-- Coerce an argument that is either an already-parsed table or a version
|
||||
-- string into a parsed table (or nil).
|
||||
local function coerce(v)
|
||||
if type(v) == "table" then return v end
|
||||
return Semver.parse(v)
|
||||
end
|
||||
|
||||
-- Compare two versions, each a parsed table or an X.Y.Z string.
|
||||
-- Returns -1 when a < b, 0 when equal, 1 when a > b. An unparseable side
|
||||
-- sorts as the lowest possible version so a bogus value never wins a "newer"
|
||||
-- test; two unparseable sides compare equal.
|
||||
function Semver.compare(a, b)
|
||||
local pa, pb = coerce(a), coerce(b)
|
||||
if not pa and not pb then return 0 end
|
||||
if not pa then return -1 end
|
||||
if not pb then return 1 end
|
||||
for _, field in ipairs({ "major", "minor", "patch" }) do
|
||||
if pa[field] < pb[field] then return -1 end
|
||||
if pa[field] > pb[field] then return 1 end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
return Semver
|
||||
@@ -0,0 +1,343 @@
|
||||
-- Background worker for the self-update flow (driven by src/update/Check.lua).
|
||||
--
|
||||
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
|
||||
-- touches the render thread. Talks over two channels:
|
||||
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
|
||||
-- "update_check_state" out: { status, latest, progress, error }
|
||||
--
|
||||
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
|
||||
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
|
||||
-- or a hung download degrades to a "error"/"needs_full" state rather than
|
||||
-- blocking or crashing the game. On Android curl is absent and the check
|
||||
-- soft-fails to "error", which the UI hides.
|
||||
--
|
||||
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
|
||||
-- modules are pulled in with love.filesystem.load exactly like
|
||||
-- src/core/chip_worker.lua does. Semver and Boot are authored in parallel; we
|
||||
-- load them defensively and degrade (a local semver fallback, a permissive
|
||||
-- gate) if they are not present yet.
|
||||
|
||||
require("love.thread")
|
||||
require("love.filesystem")
|
||||
require("love.data")
|
||||
require("love.timer")
|
||||
require("love.system")
|
||||
|
||||
local function loadModule(path)
|
||||
local ok, chunk = pcall(love.filesystem.load, path)
|
||||
if not ok or type(chunk) ~= "function" then return nil end
|
||||
local ok2, mod = pcall(chunk)
|
||||
if not ok2 then return nil end
|
||||
return mod
|
||||
end
|
||||
|
||||
local Json = loadModule("src/link/Json.lua")
|
||||
local Check = loadModule("src/update/Check.lua")
|
||||
local Version = loadModule("src/core/Version.lua")
|
||||
local Semver = loadModule("src/update/Semver.lua")
|
||||
-- Boot's top-level require("src.update.Semver") cannot resolve in this thread
|
||||
-- (no src.* searcher), which would leave Boot nil and the minShell gate
|
||||
-- permanently permissive. Seed the loaded table first so it resolves.
|
||||
if Semver then package.loaded["src.update.Semver"] = Semver end
|
||||
local Boot = loadModule("src/update/Boot.lua")
|
||||
|
||||
local cmdCh = love.thread.getChannel("update_check_cmd")
|
||||
local stateCh = love.thread.getChannel("update_check_state")
|
||||
|
||||
local function post(t) stateCh:push(t) end
|
||||
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
local isWindows = osName == "Windows"
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
|
||||
local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/releases/latest"
|
||||
|
||||
-- the release picked by the last "check"; kept between commands so "download"
|
||||
-- knows the payload url/size/name without re-fetching
|
||||
local pending = nil
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- shell / curl
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if isWindows then
|
||||
return '"' .. s:gsub('"', '') .. '"'
|
||||
end
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- run curl and return its response body (text), or nil on any failure. Used
|
||||
-- for the small text resources (release JSON, sums file); -f makes curl exit
|
||||
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
|
||||
local function curlCapture(url)
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
|
||||
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
|
||||
.. shq(url)
|
||||
local ok, pipe = pcall(io.popen, cmd)
|
||||
if not ok or not pipe then return nil end
|
||||
local out = pipe:read("*a")
|
||||
pipe:close()
|
||||
if not out or out == "" then return nil end
|
||||
return out
|
||||
end
|
||||
|
||||
local function haveCurl()
|
||||
local ok, pipe = pcall(io.popen, "curl --version")
|
||||
if not ok or not pipe then return false end
|
||||
local out = pipe:read("*a")
|
||||
pipe:close()
|
||||
return out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- version compare (Semver per contract item 5, with a local fallback)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function parseTriple(s)
|
||||
s = (tostring(s):gsub("^[vV]", ""))
|
||||
local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)")
|
||||
if not a then return nil end
|
||||
return { tonumber(a), tonumber(b), tonumber(c) }
|
||||
end
|
||||
|
||||
-- -1 | 0 | 1 for a<b | a==b | a>b
|
||||
local function compareVersions(a, b)
|
||||
if Semver and Semver.compare then
|
||||
local ok, r = pcall(Semver.compare, a, b)
|
||||
if ok and r ~= nil then return r end
|
||||
end
|
||||
local pa, pb = parseTriple(a), parseTriple(b)
|
||||
if not pa or not pb then return 0 end
|
||||
for i = 1, 3 do
|
||||
if pa[i] ~= pb[i] then return pa[i] < pb[i] and -1 or 1 end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- verification and the shell gate
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function sha256hex(data)
|
||||
local digest = love.data.hash("sha256", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
digest = digest:getString()
|
||||
end
|
||||
return love.data.encode("string", "hex", digest)
|
||||
end
|
||||
|
||||
-- Confirm the save-dir file `rel` hashes to the sum listed for `payloadName`.
|
||||
local function verifyPayload(rel, payloadName, sumsText)
|
||||
local want = Check.parseSums(sumsText, payloadName)
|
||||
if not want then return false, "no checksum for " .. payloadName end
|
||||
local data = love.filesystem.read(rel)
|
||||
if not data then return false, "cannot read downloaded payload" end
|
||||
if sha256hex(data):lower() ~= want:lower() then
|
||||
return false, "checksum mismatch"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot
|
||||
-- cannot probe (module missing during parallel dev, or a probe failure) we allow
|
||||
-- it: Boot.run's crash-guard handles a payload that turns out unrunnable.
|
||||
local function gatePasses(rel)
|
||||
if not (Boot and Boot.probePayload) then return true end
|
||||
local info = Boot.probePayload(rel)
|
||||
if not info then return true end
|
||||
local shell = (Version and Version.shell) or 1
|
||||
if info.minShell and info.minShell > shell then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- check
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function doCheck()
|
||||
post({ status = "checking" })
|
||||
|
||||
if not haveCurl() then
|
||||
post({ status = "error", error = "curl not available" })
|
||||
return
|
||||
end
|
||||
|
||||
local body = curlCapture(API_URL)
|
||||
if not body then
|
||||
post({ status = "error", error = "release check failed" })
|
||||
return
|
||||
end
|
||||
|
||||
local rel, perr = Check.parseRelease(body, Json)
|
||||
if not rel then
|
||||
post({ status = "error", error = perr or "bad release json" })
|
||||
return
|
||||
end
|
||||
pending = rel
|
||||
|
||||
-- Unstamped dev build: the working tree always looks "newer", so never
|
||||
-- pester the developer with an update (contract item, Check design).
|
||||
local currentEngine = (Version and Version.engine) or "0.0.0-dev"
|
||||
if currentEngine == "0.0.0-dev" then
|
||||
post({ status = "uptodate", latest = rel.version })
|
||||
return
|
||||
end
|
||||
|
||||
if compareVersions(rel.version, currentEngine) <= 0 then
|
||||
post({ status = "uptodate", latest = rel.version })
|
||||
return
|
||||
end
|
||||
|
||||
-- A newer release, but without the .love payload or its sums we cannot do an
|
||||
-- in-place update: send the user to the full installers.
|
||||
if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then
|
||||
post({ status = "needs_full", latest = rel.version })
|
||||
return
|
||||
end
|
||||
|
||||
-- Already downloaded on a previous run? Verify and gate it rather than
|
||||
-- pulling the bytes again.
|
||||
local finalRel = "updates/" .. rel.payloadName
|
||||
if love.filesystem.getInfo(finalRel) then
|
||||
local sums = curlCapture(rel.sums.url)
|
||||
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
|
||||
if gatePasses(finalRel) == false then
|
||||
love.filesystem.remove(finalRel)
|
||||
post({ status = "needs_full", latest = rel.version })
|
||||
return
|
||||
end
|
||||
post({ status = "ready", latest = rel.version })
|
||||
return
|
||||
end
|
||||
-- stale / corrupt: drop it and offer a fresh download
|
||||
love.filesystem.remove(finalRel)
|
||||
end
|
||||
|
||||
post({ status = "available", latest = rel.version })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- download
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Launch curl in the background writing `partAbs`, touching `doneAbs` when it
|
||||
-- exits. Returns without waiting so the caller can poll the growing file for
|
||||
-- progress. We deliberately do not capture curl's exit code: an incomplete or
|
||||
-- failed transfer simply fails the checksum below, which is the real gate.
|
||||
local function launchDownload(url, partAbs, doneAbs)
|
||||
if isWindows then
|
||||
-- a tiny batch file sidesteps cmd.exe's nested-quote madness
|
||||
local batRel = "updates/dl.bat"
|
||||
love.filesystem.write(batRel,
|
||||
"@echo off\r\n"
|
||||
.. "curl -fsSL --connect-timeout 15 --max-time 900 -o \""
|
||||
.. partAbs .. "\" \"" .. url .. "\"\r\n"
|
||||
.. "type nul > \"" .. doneAbs .. "\"\r\n")
|
||||
os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel))
|
||||
else
|
||||
-- ( ... ) & backgrounds the whole group so os.execute returns at once
|
||||
os.execute("( curl -fsSL --connect-timeout 15 --max-time 900 -o "
|
||||
.. shq(partAbs) .. " " .. shq(url)
|
||||
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
|
||||
end
|
||||
end
|
||||
|
||||
local function doDownload()
|
||||
if not (pending and pending.payload and pending.payload.url) then
|
||||
post({ status = "error", error = "nothing to download" })
|
||||
return
|
||||
end
|
||||
local rel = pending
|
||||
post({ status = "downloading", latest = rel.version, progress = 0 })
|
||||
|
||||
love.filesystem.createDirectory("updates")
|
||||
local partRel = "updates/" .. rel.payloadName .. ".part"
|
||||
local doneRel = "updates/" .. rel.payloadName .. ".done"
|
||||
local finalRel = "updates/" .. rel.payloadName
|
||||
love.filesystem.remove(partRel)
|
||||
love.filesystem.remove(doneRel)
|
||||
|
||||
local partAbs = saveDir .. "/updates/" .. rel.payloadName .. ".part"
|
||||
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
|
||||
local size = rel.payload.size or 0
|
||||
|
||||
launchDownload(rel.payload.url, partAbs, doneAbs)
|
||||
|
||||
-- poll the .part size for progress until curl drops the done-marker; a
|
||||
-- stalled or run-away transfer breaks out and lets verification fail cleanly
|
||||
local waited, lastSize, lastChange = 0, -1, 0
|
||||
while true do
|
||||
if love.filesystem.getInfo(doneRel) then break end
|
||||
local pinfo = love.filesystem.getInfo(partRel)
|
||||
local cur = (pinfo and pinfo.size) or 0
|
||||
if size > 0 then
|
||||
local p = cur / size
|
||||
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
|
||||
post({ status = "downloading", latest = rel.version, progress = p })
|
||||
else
|
||||
post({ status = "downloading", latest = rel.version })
|
||||
end
|
||||
if cur ~= lastSize then lastSize, lastChange = cur, waited end
|
||||
if waited - lastChange > 60 then break end -- 60s with no growth: give up
|
||||
if waited > 960 then break end -- absolute ceiling
|
||||
love.timer.sleep(0.25)
|
||||
waited = waited + 0.25
|
||||
end
|
||||
love.filesystem.remove(doneRel)
|
||||
|
||||
local sums = curlCapture(rel.sums and rel.sums.url or "")
|
||||
if not sums then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = "checksum fetch failed" })
|
||||
return
|
||||
end
|
||||
|
||||
local ok, verr = verifyPayload(partRel, rel.payloadName, sums)
|
||||
if not ok then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = verr or "verification failed" })
|
||||
return
|
||||
end
|
||||
|
||||
if gatePasses(partRel) == false then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "needs_full", latest = rel.version })
|
||||
return
|
||||
end
|
||||
|
||||
-- finalize: rename the verified .part to its real name (fall back to a
|
||||
-- love.filesystem copy if os.rename is unavailable on this platform)
|
||||
if not os.rename(partAbs, saveDir .. "/updates/" .. rel.payloadName) then
|
||||
local data = love.filesystem.read(partRel)
|
||||
if not data then
|
||||
post({ status = "error", error = "finalize failed" })
|
||||
return
|
||||
end
|
||||
love.filesystem.write(finalRel, data)
|
||||
love.filesystem.remove(partRel)
|
||||
end
|
||||
|
||||
post({ status = "ready", latest = rel.version })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- command loop
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
while true do
|
||||
local cmd = cmdCh:demand() -- blocks until the main thread pushes work
|
||||
if type(cmd) == "table" then
|
||||
if cmd.cmd == "quit" then
|
||||
break
|
||||
elseif cmd.cmd == "check" then
|
||||
local ok, err = pcall(doCheck)
|
||||
if not ok then post({ status = "error", error = tostring(err) }) end
|
||||
elseif cmd.cmd == "download" then
|
||||
local ok, err = pcall(doDownload)
|
||||
if not ok then post({ status = "error", error = tostring(err) }) end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -116,6 +116,16 @@ FieldDefaults.FIELD = {
|
||||
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
|
||||
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
|
||||
hiddenExtras = {
|
||||
-- PrintTrashText bins (#188); seeds stale caches missing the key
|
||||
printTrash = {
|
||||
SS_ANNE_KITCHEN = {
|
||||
{ x = 13, y = 5, facing = "down" },
|
||||
{ x = 13, y = 7, facing = "down" },
|
||||
},
|
||||
VERMILION_GYM = {
|
||||
{ x = 6, y = 1, facing = "down" },
|
||||
},
|
||||
},
|
||||
trashCans = { map = "VERMILION_GYM",
|
||||
doorBlock = { bx = 2, by = 2, block = 5 } },
|
||||
},
|
||||
|
||||
@@ -223,6 +223,12 @@ function Map:setBlock(bx, by, block)
|
||||
self.def.blocks[by * self.def.width + bx + 1] = block
|
||||
end
|
||||
|
||||
-- true if the cell's collision tile is a door tile
|
||||
-- (pokered IsPlayerStandingOnDoorTile)
|
||||
function Map:isDoorTileCell(cx, cy)
|
||||
return self.doorTiles[self:cellTile(cx, cy)] or false
|
||||
end
|
||||
|
||||
-- true if the cell's collision tile is a door or warp-activating tile
|
||||
function Map:isWarpTileCell(cx, cy)
|
||||
local t = self:cellTile(cx, cy)
|
||||
|
||||
+11
-2
@@ -101,9 +101,18 @@ function NPC:walkPhase()
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
-- Same contract as Player:pose -- the sheet, position, facing and step
|
||||
-- phase this frame renders to -- so a render pipeline can pose an NPC
|
||||
-- without caring which kind of entity it is. An NPC never hops, so the
|
||||
-- trailing hop flag is always false.
|
||||
function NPC:pose()
|
||||
return self.sprite, self.px, self.py, self.facing,
|
||||
self:walkPhase(), self.stepFlip, false
|
||||
end
|
||||
|
||||
function NPC:draw(camX, camY)
|
||||
self.sprite:draw(self.px, self.py, camX, camY, self.facing,
|
||||
self:walkPhase(), self.stepFlip)
|
||||
local sprite, px, py, facing, phase, flip = self:pose()
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
end
|
||||
|
||||
return NPC
|
||||
|
||||
@@ -12,6 +12,7 @@ local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local NPC = require("src.world.NPC")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Player = require("src.world.Player")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -20,6 +21,7 @@ local Tilt = require("src.render.Tilt")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
local Warp = require("src.world.Warp")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
|
||||
-- isOverworld marks the live world state for WorldAPI's stack scan
|
||||
local OverworldState = { isOpaque = true, isOverworld = true }
|
||||
@@ -716,16 +718,13 @@ function OverworldState:update(dt)
|
||||
end
|
||||
if self.healAnim then
|
||||
local ha = self.healAnim
|
||||
local Music = require("src.core.Music")
|
||||
if ha.jinglePlaying and not ha.jingleDone then
|
||||
ha.jingleDone = not Music.oneShotPlaying()
|
||||
end
|
||||
local ev = OverworldState.stepHealAnim(ha)
|
||||
if ev == "ball" then
|
||||
require("src.core.Sound").play(Game.data, "Healing_Machine")
|
||||
elseif ev == "jingle" then
|
||||
ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed")
|
||||
ha.jingleDone = not ha.jinglePlaying
|
||||
-- playOnce restores the map theme when the jingle ends; we no longer
|
||||
-- block the fighting-fit text on that (#157)
|
||||
require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed")
|
||||
elseif ev == "done" then
|
||||
local done = ha.onDone
|
||||
self.healAnim = nil
|
||||
@@ -1509,6 +1508,15 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
end
|
||||
end
|
||||
|
||||
-- PrintTrashText: SS Anne kitchen + Vermilion Gym non-puzzle can
|
||||
for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy then
|
||||
Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText
|
||||
or "Nope, there's\nonly trash here."))
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- the Vermilion Gym trash can lock puzzle
|
||||
if self.map.id == "VERMILION_GYM" then
|
||||
for _, h in ipairs(extras.trashCans.cans or {}) do
|
||||
@@ -2188,13 +2196,10 @@ function OverworldState:dexRating()
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating))
|
||||
end
|
||||
|
||||
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): the
|
||||
-- monitor lights, then one ball per party mon appears every 30 frames
|
||||
-- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine
|
||||
-- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame
|
||||
-- beat once the jingle ends. Pure per-frame step over the ha table
|
||||
-- ({ balls, lit, timer, visible, jingleDone }); returns "ball"/"jingle"/
|
||||
-- "done" when the caller must fire the matching side effect.
|
||||
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
|
||||
-- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip
|
||||
-- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is
|
||||
-- immediate; jingle still plays and restoreMap runs when it ends.
|
||||
function OverworldState.stepHealAnim(ha)
|
||||
ha.timer = ha.timer + 1
|
||||
ha.phase = ha.phase or "balls"
|
||||
@@ -2217,17 +2222,11 @@ function OverworldState.stepHealAnim(ha)
|
||||
ha.visible = not ha.visible
|
||||
ha.flashes = ha.flashes + 1
|
||||
if ha.flashes >= 8 then
|
||||
ha.phase = "wait"
|
||||
ha.visible = true
|
||||
ha.phase = "done"
|
||||
return "done"
|
||||
end
|
||||
end
|
||||
elseif ha.phase == "wait" then
|
||||
-- .waitLoop2: hold until the jingle ends, then 32 more frames
|
||||
if not ha.jingleDone then
|
||||
ha.timer = 0
|
||||
elseif ha.timer >= 32 then
|
||||
return "done"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2403,19 +2402,34 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
Commands.hide_object(ctx, entry[1], entry[2])
|
||||
end
|
||||
end
|
||||
local lines = {}
|
||||
if reward.badge then
|
||||
Game.save.inventory[reward.badge] = 1
|
||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||
or reward.badge
|
||||
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
|
||||
end
|
||||
if reward.item then
|
||||
local inv = Game.save.inventory
|
||||
inv[reward.item] = (inv[reward.item] or 0) + 1
|
||||
local name = Game.data.items[reward.item] and Game.data.items[reward.item].name
|
||||
or reward.item
|
||||
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
|
||||
local idef = Game.data.items[reward.item]
|
||||
-- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
|
||||
Game.stringBuffer = idef and idef.name or reward.item
|
||||
end
|
||||
local lines = {}
|
||||
if reward.dialogue then
|
||||
local text = Game.data.text or {}
|
||||
for _, label in ipairs(reward.dialogue) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
elseif reward.badge or reward.item then
|
||||
if reward.badge then
|
||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||
or reward.badge
|
||||
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
|
||||
end
|
||||
if reward.item then
|
||||
local name = Game.stringBuffer or reward.item
|
||||
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
|
||||
end
|
||||
end
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
|
||||
@@ -2437,6 +2451,8 @@ end
|
||||
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
|
||||
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
|
||||
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
|
||||
-- Off-screen sprites (IMAGEINDEX=$ff) never engage: without that gate, the
|
||||
-- same 8-bit wrap makes far same-row trainers look in-range (#153/#183).
|
||||
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
|
||||
local function u8(n) return n % 256 end
|
||||
local function calcDiff(a, b)
|
||||
@@ -2452,6 +2468,14 @@ local function trainerSightPixelDist(npc, player, horizontal)
|
||||
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
|
||||
end
|
||||
|
||||
-- CheckSpriteAvailability (movement.asm): wXCoord/wYCoord = player - 4;
|
||||
-- visible when sprite is in [wCoord, wCoord + SCREEN_*/2 - 1] (GB 10x9).
|
||||
local function trainerSpriteOnScreen(npc, player)
|
||||
local dx = npc.cellX - player.cellX
|
||||
local dy = npc.cellY - player.cellY
|
||||
return dx >= -4 and dx <= 5 and dy >= -4 and dy <= 4
|
||||
end
|
||||
|
||||
-- STAY trainers with a facing spot the player crossing their line of
|
||||
-- sight (range from the extracted trainer headers), walk up and battle.
|
||||
function OverworldState:checkTrainerSight()
|
||||
@@ -2464,7 +2488,8 @@ function OverworldState:checkTrainerSight()
|
||||
-- walkers included (they sight between steps)
|
||||
if d.trainerClass and not npc.moving
|
||||
and not self:trainerDefeated(npc)
|
||||
and not mapScripts.talkScript(self.map.id, d.text) then
|
||||
and not mapScripts.talkScript(self.map.id, d.text)
|
||||
and trainerSpriteOnScreen(npc, p) then
|
||||
local header = Game.data:trainerHeader(self.map.def.label, d.index)
|
||||
local range = header and header.range or 0
|
||||
local vec = DIRVEC[npc.facing]
|
||||
@@ -3208,7 +3233,7 @@ function OverworldState:takeWarp(warpDef)
|
||||
self:startWarpTo(destMap, x, y, facing)
|
||||
return
|
||||
end
|
||||
self.doorWarp = true -- door SFX + outdoor walk-out step
|
||||
self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out
|
||||
self:startWarpTo(destMap, x, y, facing)
|
||||
end
|
||||
|
||||
@@ -3293,12 +3318,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
local outdoor = Map.isOutdoor(self.map.def)
|
||||
require("src.core.Sound").play(Game.data,
|
||||
outdoor and "Go_Outside" or "Go_Inside")
|
||||
-- stepping out of an outdoor door/cave entrance (the original's
|
||||
-- walk-out). Auto-walk leaves the mat, so the arrival disable
|
||||
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
||||
-- warp that lands on a door tile auto-steps south once, indoor or
|
||||
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
||||
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
|
||||
-- stand on the door without re-entering if you hold back into it.
|
||||
if outdoor and self.player.facing == "down"
|
||||
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
|
||||
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
||||
self.warpEntryCell = nil
|
||||
self.justWarped = false
|
||||
self:scriptMove(self.player, "down", 1)
|
||||
@@ -3539,11 +3564,22 @@ function OverworldState:drawWorld()
|
||||
-- tilt is active). So the ground draw calls below never change with tilt;
|
||||
-- only the sprite/FX draw path below them branches. The sorts below only
|
||||
-- reorder (no draws), so they run once for both paths.
|
||||
local tilt = Tilt.active()
|
||||
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
|
||||
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
||||
for _, nb in ipairs(self.neighbors) do
|
||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
||||
-- A render pipeline (src/render/Pipelines.lua) replaces the ground draw
|
||||
-- entirely with geometry of its own, so it is decided before tilt and
|
||||
-- wins over it. It falls back to the tilt/flat path whenever it cannot
|
||||
-- run this frame -- headless, a driver with no depth canvas, or a mod
|
||||
-- that threw -- so no caller ever sees a blank frame.
|
||||
local pipelineId = Pipelines.worldPipeline()
|
||||
local tilt = (not pipelineId) and Tilt.active()
|
||||
-- the pipeline's finished world image, once it has run; nil keeps every
|
||||
-- path below on the vanilla flat/tilt draw
|
||||
local override
|
||||
if not pipelineId then
|
||||
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
|
||||
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
||||
for _, nb in ipairs(self.neighbors) do
|
||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
||||
end
|
||||
end
|
||||
-- per-billboard SGB palette source; only needed (and only paid for) when
|
||||
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
||||
@@ -3774,7 +3810,114 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
|
||||
if not tilt then
|
||||
if pipelineId then
|
||||
-- === PIPELINE PATH: a mod owns the world pass. ======================
|
||||
-- It renders terrain and characters however it likes and hands back one
|
||||
-- window-resolution image; the field FX stay ordinary 2D draws
|
||||
-- composited on top by ctx.drawFx, each anchored to where its ground
|
||||
-- point projects under the pipeline's own camera. That is the direct
|
||||
-- analogue of what :billboard does for tilt, and it keeps exactly one
|
||||
-- copy of every effect: the closures above are the ones that run.
|
||||
local pw, ph = love.graphics.getDimensions()
|
||||
local pscale = Zoom.scale(Game.renderer:fitScale())
|
||||
local ctx = {
|
||||
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
||||
width = pw, height = ph, scale = pscale,
|
||||
level = Pipelines.level(pipelineId),
|
||||
-- the SGB world palette a map draws under; nil in the true-colour
|
||||
-- modes, whose art is already baked (and must not be re-mapped)
|
||||
paletteFor = function(map)
|
||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||
end,
|
||||
spriteColors = function(map)
|
||||
if PaletteFX.usesGbcPack() then return nil end
|
||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||
end,
|
||||
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
||||
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
|
||||
}
|
||||
-- Draw every active field FX into the finished scene. `project(wx, wy)`
|
||||
-- maps a world point to canvas pixels (nil when it is behind the
|
||||
-- camera) and `scale` is canvas pixels per world pixel; the pipeline
|
||||
-- owns the camera, this owns where each effect belongs and how the
|
||||
-- closures' flat coordinates are slid onto the projected anchor.
|
||||
-- Deliberately unscaled by depth, like :billboard: an effect keeps its
|
||||
-- crisp authored size and only its anchor moves.
|
||||
ctx.drawFx = function(project, scale)
|
||||
scale = scale or pscale
|
||||
local colors = ctx.spriteColors()
|
||||
local function at(drawFn, wx, wy)
|
||||
if not drawFn then return end
|
||||
local sx, sy = project(wx, wy)
|
||||
if not sx then return end -- behind the camera
|
||||
local shader = colors and PaletteFX.shader() or nil
|
||||
if shader then
|
||||
PaletteFX.sendColors(shader, colors)
|
||||
love.graphics.setShader(shader)
|
||||
end
|
||||
-- the closures draw relative to the flat foot; slide that onto the
|
||||
-- projected anchor, in world-pixel units inside the scaled transform
|
||||
local fx, fy = wx - cam.x, wy - cam.y
|
||||
love.graphics.push()
|
||||
love.graphics.scale(scale, scale)
|
||||
love.graphics.translate(sx / scale - fx, sy / scale - fy)
|
||||
drawFn()
|
||||
love.graphics.pop()
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
-- ground-hugging effects sit on the cell they belong to
|
||||
if self.dustAnim then
|
||||
at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8)
|
||||
end
|
||||
if self.cutAnim then
|
||||
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
|
||||
end
|
||||
if self.healAnim then
|
||||
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
|
||||
end
|
||||
-- standing effects anchor at the foot of whoever they belong to
|
||||
if self.emote and self.emote.npc then
|
||||
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
|
||||
end
|
||||
if self.flyAnim then
|
||||
at(fxBird, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
if self.fishing then
|
||||
at(fxRod, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
-- Rock Tunnel darkness is a screen-space light window, not a ground
|
||||
-- object: draw it flat over the finished scene like the tilt path.
|
||||
-- It fills the view in world-pixel units, so it only needs the scale.
|
||||
if self.dark then
|
||||
love.graphics.push()
|
||||
love.graphics.scale(scale, scale)
|
||||
fxDark()
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
override = Pipelines.drawWorld(pipelineId, ctx)
|
||||
-- world post-processes (a miniature-diorama blur, a colour grade) fold
|
||||
-- over the finished scene here, so they never touch the UI drawn on top
|
||||
if override then
|
||||
override = Pipelines.worldPresent(override, ctx)
|
||||
end
|
||||
Game.renderer:setWorldOverride(override)
|
||||
if not override then
|
||||
-- The pipeline declined this frame (nothing to draw, or it threw and
|
||||
-- was retired). The ground pass was skipped on its behalf above, so
|
||||
-- draw it now and fall through to the flat path below rather than
|
||||
-- compositing an empty canvas.
|
||||
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
|
||||
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
||||
for _, nb in ipairs(self.neighbors) do
|
||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if override then
|
||||
-- the pipeline owns the whole frame; nothing else draws into the world
|
||||
elseif not tilt then
|
||||
-- === FLAT PATH: everything into the one world canvas, as before =====
|
||||
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
|
||||
-- grass feet-overdraw must replay over them too, colorized with the
|
||||
|
||||
+34
-16
@@ -140,28 +140,29 @@ end
|
||||
|
||||
local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
-- What this frame renders to: the sheet, where it sits, which way it faces
|
||||
-- and how far through a step it is. Shared by the 2D draw below and by a
|
||||
-- render pipeline's own geometry (src/render/Pipelines.lua), so the two can
|
||||
-- never disagree about which sprite or facing is current.
|
||||
--
|
||||
-- The last return says the player is mid-ledge-hop, which is what the 2D
|
||||
-- path draws the ground shadow from and a 3D path turns into vertical lift.
|
||||
--
|
||||
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
|
||||
-- and draw() may run per frame -- and draw() is written in terms of pose()
|
||||
-- to keep that true by construction. (hopFrames counts down in
|
||||
-- Player:update, on the fixed step, so it is safe to read here.)
|
||||
function Player:pose()
|
||||
local py = self.py
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
|
||||
-- hopFrames counts down in Player:update (fixed step), never here.
|
||||
local hopping = false
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
local total = self.hopTotal or 32
|
||||
-- update runs before draw, so remaining N means N steps already
|
||||
-- consumed this hop → t matches the old draw-side post-decrement phase
|
||||
local t = 1 - self.hopFrames / total
|
||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
||||
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
|
||||
if self.shadowImg then
|
||||
local sx = math.floor(self.px - camX)
|
||||
local sy = math.floor(self.py - camY) - 4 + 8
|
||||
love.graphics.draw(self.shadowImg, sx, sy)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
end
|
||||
hopping = true
|
||||
elseif self.surfing then
|
||||
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
|
||||
py = py + (self.bobTimer < 16 and 0 or 1)
|
||||
@@ -186,7 +187,24 @@ function Player:draw(camX, camY)
|
||||
end
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
or (self.onBike and self.bikeSprite) or self.sprite
|
||||
sprite:draw(self.px, py, camX, camY, facing, phase, flip)
|
||||
return sprite, self.px, py, facing, phase, flip, hopping
|
||||
end
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
local sprite, px, py, facing, phase, flip, hopping = self:pose()
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
||||
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
|
||||
if hopping and self.shadowImg then
|
||||
local sx = math.floor(self.px - camX)
|
||||
local sy = math.floor(self.py - camY) - 4 + 8
|
||||
love.graphics.draw(self.shadowImg, sx, sy)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
end
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
end
|
||||
|
||||
return Player
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- S.S. Anne 1F cabins: ROM packs rooms so hallway doors map right→left
|
||||
-- onto the 3×2 rooms map (warp 1 = top-left = rightmost door). Survey
|
||||
-- zoom makes that look left/right flipped vs 2F/B1F. Reorder door→cell
|
||||
-- and slide objects so each door keeps its OG occupants while L→R doors
|
||||
-- land in reading order on the rooms map.
|
||||
|
||||
local SsAnneLayout = {}
|
||||
|
||||
local function leftmostRoomDoor(hall)
|
||||
local best
|
||||
for _, w in ipairs(hall.warps or {}) do
|
||||
if w.destMap == "SS_ANNE_1F_ROOMS" then
|
||||
if not best or w.x < best.x then best = w end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
function SsAnneLayout.apply(maps)
|
||||
if not maps then return false end
|
||||
local hall = maps.SS_ANNE_1F
|
||||
local rooms = maps.SS_ANNE_1F_ROOMS
|
||||
if not hall or not rooms then return false end
|
||||
local left = leftmostRoomDoor(hall)
|
||||
-- already hallway-ordered (leftmost door → rooms warp 1)
|
||||
if not left or left.destWarp == 1 then return false end
|
||||
|
||||
local doors = {}
|
||||
for _, w in ipairs(hall.warps) do
|
||||
if w.destMap == "SS_ANNE_1F_ROOMS" then doors[#doors + 1] = w end
|
||||
end
|
||||
table.sort(doors, function(a, b) return a.x < b.x end)
|
||||
for i, w in ipairs(doors) do w.destWarp = i end
|
||||
|
||||
-- rooms warps 1..6 return through hall warps 8..3 (L→R cabin doors)
|
||||
for i, w in ipairs(rooms.warps or {}) do
|
||||
if w.destMap == "SS_ANNE_1F" then w.destWarp = 9 - i end
|
||||
end
|
||||
|
||||
local warpPos = {}
|
||||
for i, w in ipairs(rooms.warps or {}) do
|
||||
warpPos[i] = { x = w.x, y = w.y }
|
||||
end
|
||||
for _, o in ipairs(rooms.objects or {}) do
|
||||
local col, row = math.floor(o.x / 10), math.floor(o.y / 10)
|
||||
local oldK
|
||||
for k, p in ipairs(warpPos) do
|
||||
if math.floor(p.x / 10) == col and math.floor(p.y / 10) == row then
|
||||
oldK = k
|
||||
break
|
||||
end
|
||||
end
|
||||
if oldK then
|
||||
local newK = 7 - oldK
|
||||
o.x = o.x + (warpPos[newK].x - warpPos[oldK].x)
|
||||
o.y = o.y + (warpPos[newK].y - warpPos[oldK].y)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return SsAnneLayout
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Driver: #187 S.S. Anne cabin door entry. Entering a room from the
|
||||
-- corridor should land one tile south of the door (pokered
|
||||
-- PlayerStepOutFromDoor), not leave the player on the door tile.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- SS_ANNE_1F warp at (31,8) -> SS_ANNE_1F_ROOMS (rightmost cabin)
|
||||
U.teleport(game, "SS_ANNE_1F", 31, 9, "up")
|
||||
U.shot(game, DIR .. "/anne_0_before_enter.png")
|
||||
local ow = game.overworld
|
||||
U.log("before map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
|
||||
"facing:", ow.player.facing)
|
||||
|
||||
U.hold(game, "up", 20)
|
||||
for _ = 1, 90 do
|
||||
U.wait(1)
|
||||
if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 and not ow.player.moving then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/anne_1_after_enter.png")
|
||||
U.log("after map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
|
||||
"facing:", ow.player.facing,
|
||||
"onDoor:", tostring(ow.map:isWarpTileCell(ow.player.cellX, ow.player.cellY)))
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Driver: mid-battle PKMN opens SWITCH / STATS / CANCEL (#180).
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/battle_party_submenu_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARMANDER", 12),
|
||||
Pokemon.new(game.data, "SQUIRTLE", 10),
|
||||
}
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 80 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
U.shot(game, DIR .. "/battle_party_0_menu.png")
|
||||
|
||||
-- FIGHT/PKMN/ITEM/RUN: right to PKMN, then A
|
||||
U.tap(game, "right"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(12)
|
||||
U.shot(game, DIR .. "/battle_party_1_list.png")
|
||||
|
||||
local pm = game.stack:top()
|
||||
U.log("party open:", pm and pm.onSwitch ~= nil)
|
||||
U.tap(game, "a"); U.wait(8)
|
||||
pm = game.stack:top()
|
||||
U.shot(game, DIR .. "/battle_party_2_switch_stats.png")
|
||||
|
||||
local labels = {}
|
||||
if pm and pm.submenu and pm.subItems then
|
||||
for _, e in ipairs(pm.subItems) do
|
||||
table.insert(labels, e.label)
|
||||
end
|
||||
end
|
||||
U.log("submenu:", table.concat(labels, "/"))
|
||||
U.log("order ok:", labels[1] == "SWITCH" and labels[2] == "STATS"
|
||||
and labels[3] == "CANCEL")
|
||||
|
||||
-- STATS
|
||||
U.tap(game, "down"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(14)
|
||||
U.shot(game, DIR .. "/battle_party_3_stats.png")
|
||||
U.log("stats open:", game.stack:top() ~= pm)
|
||||
end
|
||||
@@ -1,10 +1,12 @@
|
||||
-- Driver: the two reworked battle flows.
|
||||
-- Driver: reworked battle flows.
|
||||
-- A) Old man catch tutorial (DisplayBattleMenu's old-man script):
|
||||
-- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with
|
||||
-- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80):
|
||||
-- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw.
|
||||
-- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only
|
||||
-- after the hit test, at (0,7) like MoveSelectionMenu .mimicmenu.
|
||||
-- C) #162 trainer SHIFT offer: after KO'ing the first enemy mon,
|
||||
-- "about to use" + "Will … change POKéMON?" with YES/NO.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
@@ -83,5 +85,55 @@ return function(game)
|
||||
U.shot(game, DIR .. "/mimic_5_moves_after.png") -- slot now holds the copy
|
||||
while game.stack:top() ~= ow do game.stack:pop() end
|
||||
U.wait(5)
|
||||
|
||||
-- ------------------------------------------------ C) SHIFT about-to-use
|
||||
-- Inject the foe-faint path at the menu so screenshots don't depend on
|
||||
-- accuracy rolls; party has 2 slots so TrainerAboutToUseText fires.
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.battleStyle = "shift"
|
||||
game.save.options.animations = false
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "BLASTOISE", 50),
|
||||
}
|
||||
local trainer = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
|
||||
trainer.onFinish = function() end
|
||||
ow:pushBattle(trainer)
|
||||
mashUntil(function() return trainer.phase == "menu" end)
|
||||
U.shot(game, DIR .. "/shift_0_menu.png")
|
||||
trainer:markParticipant()
|
||||
trainer.enemy.mon.hp = 0
|
||||
trainer.enemyParty[1].hp = 0
|
||||
trainer.phase = "messages"
|
||||
trainer.afterQueue = "menu"
|
||||
trainer:onFaint(trainer.enemy)
|
||||
local function topIs(cls)
|
||||
return getmetatable(game.stack:top()) == cls
|
||||
end
|
||||
local function textDone(needle)
|
||||
local t = trainer.current and trainer.current.text
|
||||
return t and t:find(needle, 1, true)
|
||||
and (trainer.charIndex or 0) >= (trainer.total or 0)
|
||||
end
|
||||
mashUntil(function() return textDone("about to use") end, 200)
|
||||
U.shot(game, DIR .. "/shift_1_about_to_use.png")
|
||||
mashUntil(function() return textDone("change POKéMON") end, 80)
|
||||
U.wait(20) -- ChoiceBox auto-opens once the page has typed out
|
||||
mashUntil(function() return topIs(ChoiceBox) end, 40)
|
||||
U.shot(game, DIR .. "/shift_2_will_change.png")
|
||||
U.shot(game, DIR .. "/shift_3_yes_no.png")
|
||||
U.tap(game, "a"); U.wait(8) -- YES
|
||||
mashUntil(function() return topIs(PartyMenu) end, 40)
|
||||
U.shot(game, DIR .. "/shift_4_party.png")
|
||||
U.tap(game, "down"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(8) -- BLASTOISE
|
||||
mashUntil(function() return textDone("sent") end, 120)
|
||||
U.shot(game, DIR .. "/shift_5_enemy_sent.png")
|
||||
mashUntil(function() return textDone("BLASTOISE") end, 120)
|
||||
U.shot(game, DIR .. "/shift_6_player_sent.png")
|
||||
while game.stack:top() ~= ow do game.stack:pop() end
|
||||
U.wait(5)
|
||||
love.event.quit()
|
||||
end
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Driver: Bill's PC (#177) — chrome (What? / BOX No. / <PK><MN>) and
|
||||
-- withdraw/deposit returning to BillsPCMenu instead of closing the PC.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local pass = true
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
local function topMenu()
|
||||
local top = game.stack:top()
|
||||
return getmetatable(top) == Menu and top or nil
|
||||
end
|
||||
local function mash(btn, cond, frames)
|
||||
for _ = 1, frames or 120 do
|
||||
if cond() then return true end
|
||||
U.tap(game, btn)
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- party + box stocked so withdraw and deposit both work
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "PIKACHU", 12),
|
||||
Pokemon.new(game.data, "EEVEE", 10),
|
||||
}
|
||||
Boxes.ensure(game.save)
|
||||
game.save.currentBox = 1
|
||||
game.save.boxes[1] = {
|
||||
Pokemon.new(game.data, "SPEAROW", 8),
|
||||
Pokemon.new(game.data, "RATTATA", 6),
|
||||
}
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_MET_BILL = true
|
||||
|
||||
-- PC tile at (13,3); stand on (13,4) facing up
|
||||
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
|
||||
local ow = game.overworld
|
||||
|
||||
U.tap(game, "a") -- open PC
|
||||
U.wait(6)
|
||||
check(topIs(Menu), "PC main menu opened")
|
||||
U.tap(game, "a") -- BILL'S PC
|
||||
U.wait(8)
|
||||
local bills = topMenu()
|
||||
check(bills ~= nil, "Bill's PC menu opened")
|
||||
check(bills and bills.items[1] and bills.items[1].label:find("<PK>", 1, true),
|
||||
"WITHDRAW label includes <PK><MN> icon tokens")
|
||||
U.shot(game, DIR .. "/bills_pc_1_menu.png")
|
||||
|
||||
-- WITHDRAW first box mon
|
||||
U.tap(game, "a") -- WITHDRAW
|
||||
U.wait(8)
|
||||
check(topIs(ListMenu), "withdraw list opened")
|
||||
U.tap(game, "a") -- pick SPEAROW
|
||||
U.wait(6)
|
||||
check(topIs(Menu), "withdraw mon submenu opened")
|
||||
U.tap(game, "a") -- WITHDRAW action
|
||||
U.wait(6)
|
||||
check(mash("a", function() return topIs(TextBox) end, 60),
|
||||
"withdraw shows taken-out text")
|
||||
-- let the typewriter finish so the shot shows the taken-out line
|
||||
for _ = 1, 90 do
|
||||
local tb = game.stack:top()
|
||||
if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end
|
||||
U.wait(2)
|
||||
end
|
||||
U.shot(game, DIR .. "/bills_pc_2_withdraw_text.png")
|
||||
check(mash("a", function()
|
||||
local m = topMenu()
|
||||
return m and m.items and m.items[1]
|
||||
and tostring(m.items[1].label):find("WITHDRAW", 1, true)
|
||||
end, 120), "after withdraw, back on Bill's PC menu")
|
||||
check(#game.save.party == 3, "party gained the withdrawn mon")
|
||||
check(#game.save.boxes[1] == 1, "box lost the withdrawn mon")
|
||||
U.shot(game, DIR .. "/bills_pc_3_menu_after_withdraw.png")
|
||||
|
||||
-- DEPOSIT party lead (PIKACHU)
|
||||
U.tap(game, "down") -- DEPOSIT
|
||||
U.wait(2)
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
check(topIs(ListMenu), "deposit list opened")
|
||||
U.tap(game, "a") -- first party mon
|
||||
U.wait(6)
|
||||
check(topIs(Menu), "deposit mon submenu opened")
|
||||
U.tap(game, "a") -- DEPOSIT action
|
||||
U.wait(6)
|
||||
check(mash("a", function() return topIs(TextBox) end, 60),
|
||||
"deposit shows stored text")
|
||||
for _ = 1, 90 do
|
||||
local tb = game.stack:top()
|
||||
if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end
|
||||
U.wait(2)
|
||||
end
|
||||
U.shot(game, DIR .. "/bills_pc_4_deposit_text.png")
|
||||
check(mash("a", function()
|
||||
local m = topMenu()
|
||||
return m and m.items and m.items[1]
|
||||
and tostring(m.items[1].label):find("WITHDRAW", 1, true)
|
||||
end, 120), "after deposit, back on Bill's PC menu")
|
||||
check(#game.save.party == 2, "party lost the deposited mon")
|
||||
check(#game.save.boxes[1] == 2, "box gained the deposited mon")
|
||||
U.shot(game, DIR .. "/bills_pc_5_menu_after_deposit.png")
|
||||
|
||||
-- still inside the PC (not back on the overworld)
|
||||
check(game.stack:top() ~= ow, "PC still open after transfers")
|
||||
check(not topIs(ListMenu), "not stuck in a transfer list")
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
end
|
||||
@@ -1,15 +1,35 @@
|
||||
-- Driver: throw Poké Balls and capture the catch suspense sequence
|
||||
-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture).
|
||||
-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture),
|
||||
-- #159 nickname ask (text + white field, YES then NO), and #172
|
||||
-- full-party catch nickname prompt before box transfer.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12))
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function topIs(cls)
|
||||
return getmetatable(game.stack:top()) == cls
|
||||
end
|
||||
|
||||
local function mashUntil(cond, n)
|
||||
for _ = 1, (n or 400) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function onNicknameChoice()
|
||||
return topIs(ChoiceBox)
|
||||
end
|
||||
|
||||
-- one throw, screenshotting every 20 frames through the chain
|
||||
local function throwAndShoot(tag, rng)
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
@@ -36,4 +56,78 @@ return function(game)
|
||||
throwAndShoot("break", function(a, b) return b end)
|
||||
-- rng low: clean capture, ball stays shut
|
||||
throwAndShoot("caught", function(a, b) return a end)
|
||||
|
||||
-- #159: party catch -> nickname ask on white (YES cursor, then NO)
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 5) }
|
||||
game.save.pokedex = game.save.pokedex or { owned = {}, seen = {} }
|
||||
game.save.pokedex.owned.RATTATA = true
|
||||
game.save.pokedex.seen.RATTATA = true
|
||||
|
||||
local battle = BattleState.newWild(game, "RATTATA", 3)
|
||||
battle.onFinish = function() end
|
||||
battle.rng = function(a, b) return a end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end
|
||||
battle.phase = "messages"
|
||||
battle.afterQueue = "menu"
|
||||
battle:throwBall("POKE_BALL")
|
||||
|
||||
U.log("nick YES:", mashUntil(onNicknameChoice, 600))
|
||||
U.shot(game, DIR .. "/catch_nickname_yes.png")
|
||||
U.tap(game, "down")
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/catch_nickname_no.png")
|
||||
U.tap(game, "a") -- confirm NO
|
||||
U.wait(8)
|
||||
for _ = 1, 20 do U.tap(game, "a"); U.wait(4) end
|
||||
while game.stack:top() ~= ow do game.stack:pop() end
|
||||
U.wait(5)
|
||||
|
||||
-- #172: full party of 6 -> nickname ask, then "sent to BOX" / PC text
|
||||
game.save.party = {}
|
||||
for _ = 1, 6 do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "RATTATA", 5))
|
||||
end
|
||||
game.save.pokedex.owned.PIDGEY = true
|
||||
game.save.pokedex.seen.PIDGEY = true
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_MET_BILL = true
|
||||
|
||||
battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
battle.rng = function(a, b) return a end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end
|
||||
battle.phase = "messages"
|
||||
battle.afterQueue = "menu"
|
||||
battle:throwBall("POKE_BALL")
|
||||
|
||||
U.log("full-party nick ask:", mashUntil(onNicknameChoice, 600))
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/catch_fullparty_nickname.png")
|
||||
|
||||
-- decline nickname (NO), then wait (no A/B) for ItemUseBallText07
|
||||
if topIs(ChoiceBox) then
|
||||
U.tap(game, "b") -- NO
|
||||
end
|
||||
-- drain the B edge before the transfer text can see it
|
||||
for _ = 1, 8 do U.wait(1) end
|
||||
local sawBox = false
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and battle.current and battle.current.text
|
||||
and battle.current.text:find("transferred", 1, true) then
|
||||
sawBox = true
|
||||
-- wait until the typewriter has drawn most of the line
|
||||
if (battle.charIndex or 0) >= math.floor((battle.total or 0) * 0.7) then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("full-party box text:", sawBox,
|
||||
battle.current and battle.current.text or "nil")
|
||||
U.shot(game, DIR .. "/catch_fullparty_box.png")
|
||||
|
||||
for _ = 1, 30 do U.tap(game, "a"); U.wait(4) end
|
||||
while game.stack:top() ~= ow do game.stack:pop() end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Driver: Cerulean TM28 Rocket despawn fade (#170).
|
||||
-- Skips the fight (EVENT_BEAT set), talks through the TM return, and
|
||||
-- captures a mid-fade frame while CeruleanHideRocket runs.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "BLASTOISE", 50))
|
||||
game.save.flags.EVENT_BEAT_CERULEAN_ROCKET_THIEF = true
|
||||
-- Rocket at (30,8); stand south and face him
|
||||
U.teleport(game, "CERULEAN_CITY", 30, 9, "up")
|
||||
local ow = game.overworld
|
||||
|
||||
local function mashUntil(cond, label, cap)
|
||||
for _ = 1, cap or 600 do
|
||||
if cond() then return true end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(3)
|
||||
end
|
||||
U.log("TIMEOUT waiting for " .. label)
|
||||
return false
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/cerulean_rocket_00_before.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/cerulean_rocket_01_return_tm.png")
|
||||
|
||||
local function rocketAlive()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "CERULEANCITY_ROCKET" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Prefer mid fade-in (rocket already toggled off, screen still dark),
|
||||
-- matching pokered's post-HideObject GBFadeInFromBlack frames.
|
||||
local midShot = false
|
||||
for _ = 1, 900 do
|
||||
local overlay = ow.fadeOverlay
|
||||
local alpha = overlay and overlay.alpha or 0
|
||||
if not midShot and alpha > 0.35 and alpha < 0.95 and not rocketAlive() then
|
||||
U.shot(game, DIR .. "/cerulean_rocket_02_mid_fade.png")
|
||||
midShot = true
|
||||
end
|
||||
if midShot and not ow.fadeOverlay and game.stack:top() == ow
|
||||
and not ow.runner:isRunning() then
|
||||
break
|
||||
end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(2)
|
||||
end
|
||||
U.log("mid-fade shot:", tostring(midShot))
|
||||
mashUntil(function()
|
||||
return game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and not ow.fadeOverlay
|
||||
end, "idle after hide", 200)
|
||||
U.wait(10)
|
||||
U.shot(game, DIR .. "/cerulean_rocket_03_after.png")
|
||||
|
||||
local names = {}
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
names[#names + 1] = n.def and n.def.name or "?"
|
||||
end
|
||||
local toggles = game.save.objectToggles and game.save.objectToggles.CERULEAN_CITY
|
||||
U.log("rocket alive:", tostring(rocketAlive()))
|
||||
U.log("rocket toggle:", tostring(toggles and toggles.CERULEANCITY_ROCKET))
|
||||
U.log("got TM28:", tostring(game.save.flags.EVENT_GOT_TM28))
|
||||
U.log("npcs:", table.concat(names, ","))
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Driver: Copycat TM31 MIMIC trade (scripts/CopycatsHouse2F.asm).
|
||||
-- Teleport to 2F with a POKE DOLL, talk through the mimic dialogue + gift,
|
||||
-- screenshot the TM receive box, and assert flag/inventory.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/copycat_tm31 POKEPORT_DRIVER=tests/drivers/copycat_tm31_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
Bag.add(game.save, "POKE_DOLL", 1)
|
||||
U.teleport(game, "COPYCATS_HOUSE_2F", 4, 4, "up")
|
||||
local ow = game.overworld
|
||||
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.text == "TEXT_COPYCATSHOUSE2F_COPYCAT" then
|
||||
npc.wanders, npc.moving = false, false
|
||||
npc.cellX, npc.cellY = 4, 3
|
||||
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
||||
npc.facing = "down"
|
||||
end
|
||||
end
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/copycat_0_room.png")
|
||||
|
||||
local function topBox()
|
||||
local top = game.stack:top()
|
||||
return getmetatable(top) == TextBox and top or nil
|
||||
end
|
||||
|
||||
local function pageText(box)
|
||||
if not box then return "" end
|
||||
return table.concat(box.pages[box.pageIndex] or {}, "\n")
|
||||
end
|
||||
|
||||
-- Advance until a fully-typed page matches pred(pageText), then stop
|
||||
-- without consuming that page (so the shot catches it). Last pages
|
||||
-- set done (not waiting); mid-text page breaks set waiting.
|
||||
local function mashToPage(pred)
|
||||
for _ = 1, 500 do
|
||||
local box = topBox()
|
||||
if box and (box.waiting or box.done) and pred(pageText(box)) then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
U.log("mimic page:", mashToPage(function(s)
|
||||
return s:find("like POKéMON", 1, true) ~= nil
|
||||
or s:find("like POK", 1, true) ~= nil
|
||||
end))
|
||||
U.shot(game, DIR .. "/copycat_1_mimic.png")
|
||||
|
||||
U.log("pre-receive page:", mashToPage(function(s)
|
||||
return s:find("DOLL", 1, true) ~= nil
|
||||
end))
|
||||
U.shot(game, DIR .. "/copycat_2_prereceive.png")
|
||||
|
||||
U.log("TM receive page:", mashToPage(function(s)
|
||||
return s:find("received", 1, true) ~= nil
|
||||
and s:find("TM31", 1, true) ~= nil
|
||||
end))
|
||||
U.shot(game, DIR .. "/copycat_3_received_tm31.png")
|
||||
|
||||
local function mash(btn, cond)
|
||||
for _ = 1, 400 do
|
||||
if cond() then return true end
|
||||
U.tap(game, btn)
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.log("dialogue done:", mash("a", function()
|
||||
return game.stack:top() == ow
|
||||
end))
|
||||
U.shot(game, DIR .. "/copycat_4_done.png")
|
||||
|
||||
U.log("EVENT_GOT_TM31:", tostring(game.save.flags.EVENT_GOT_TM31))
|
||||
U.log("bag TM_MIMIC:", tostring(game.save.inventory.TM_MIMIC),
|
||||
"POKE_DOLL:", tostring(game.save.inventory.POKE_DOLL))
|
||||
|
||||
assert(game.save.flags.EVENT_GOT_TM31, "EVENT_GOT_TM31 not set")
|
||||
assert((game.save.inventory.TM_MIMIC or 0) >= 1, "TM_MIMIC missing from bag")
|
||||
assert((game.save.inventory.POKE_DOLL or 0) == 0, "POKE_DOLL not taken")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
U.log("thanks page:", mashToPage(function(s)
|
||||
return s:find("Thanks for TM31", 1, true) ~= nil
|
||||
end))
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/copycat_5_thanks.png")
|
||||
mash("a", function() return game.stack:top() == ow end)
|
||||
|
||||
U.log("DONE")
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Driver (#184): Magikarp Day Care 13→19 should learn Tackle at 15.
|
||||
-- Runs the DAYCARE retrieve script (same path as the gentleman NPC),
|
||||
-- logs the moveset, then screenshots status page 2 (moves + PP).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
local story2 = require("data.scripts.story2")
|
||||
|
||||
local def = game.data.pokemon.MAGIKARP
|
||||
local karp = Pokemon.new(game.data, "MAGIKARP", 13)
|
||||
karp.nickname = "FISHY"
|
||||
game.save.party = { Pokemon.new(game.data, "RATTATA", 5) }
|
||||
game.save.money = 10000
|
||||
game.save.player.name = "RED"
|
||||
|
||||
local steps = Growth.expForLevel(def.growthRate, 19)
|
||||
- Growth.expForLevel(def.growthRate, 13)
|
||||
game.save.daycare = {
|
||||
mon = karp, steps = steps, depositLevel = 13,
|
||||
}
|
||||
|
||||
local before = {}
|
||||
for _, mv in ipairs(karp.moves) do before[#before + 1] = mv.id end
|
||||
U.log("before: Lv", karp.level, "moves", table.concat(before, ","))
|
||||
|
||||
U.teleport(game, "DAYCARE", 2, 2, "up")
|
||||
|
||||
-- Resolve dialogue synchronously (parity_daycare style) without stacking
|
||||
-- fake TextBoxes over the overworld.
|
||||
local realTextBox = package.loaded["src.render.TextBox"]
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone, opts)
|
||||
if opts and opts.choice then
|
||||
opts.choice(true)
|
||||
elseif onDone then
|
||||
onDone()
|
||||
end
|
||||
return { text = text }
|
||||
end,
|
||||
}
|
||||
local realPush = game.stack.push
|
||||
game.stack.push = function() end
|
||||
local done = false
|
||||
story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN(
|
||||
game, game.overworld, nil, function() done = true end)
|
||||
game.stack.push = realPush
|
||||
package.loaded["src.render.TextBox"] = realTextBox
|
||||
|
||||
local mon = game.save.party[#game.save.party]
|
||||
local ids = {}
|
||||
for _, mv in ipairs(mon.moves or {}) do ids[#ids + 1] = mv.id end
|
||||
U.log("after: Lv", mon.level, "moves", table.concat(ids, ","),
|
||||
"done=", done, "daycare_cleared=", game.save.daycare == nil)
|
||||
|
||||
local hasTackle = false
|
||||
for _, id in ipairs(ids) do if id == "TACKLE" then hasTackle = true end end
|
||||
U.log("Magikarp 13→19 learned Tackle:", hasTackle)
|
||||
|
||||
local summary = SummaryMenu.new(game, mon)
|
||||
game.stack:push(summary)
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/daycare_00_summary_p1.png")
|
||||
summary.page = 2
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/daycare_01_moves.png")
|
||||
U.log("shots under", DIR)
|
||||
|
||||
if not (mon.species == "MAGIKARP" and mon.level == 19 and hasTackle) then
|
||||
error("daycare #184 failed: Lv" .. tostring(mon.level)
|
||||
.. " [" .. table.concat(ids, ",") .. "]")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Driver: #163 cont (\\v) must show ▼ and wait for A before scrolling.
|
||||
-- Talks to VIRIDIANFOREST_YOUNGSTER5 (cont mid-page) and the nearby
|
||||
-- TRAINER TIPS1 sign (para then conts). Screenshots the ▼ wait frames.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/dialogue_cont POKEPORT_DRIVER=tests/drivers/dialogue_cont_wait_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/dialogue_cont"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass = true
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
|
||||
local function waitForTextBox(frames)
|
||||
for _ = 1, frames or 60 do
|
||||
if topIs(TextBox) then return game.stack:top() end
|
||||
U.wait(1)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- type out until waiting/done without mashing A (held A would only
|
||||
-- speed the typewriter; wasPressed edges are what clear waits)
|
||||
local function waitUntilPrompt(box, frames)
|
||||
for _ = 1, frames or 240 do
|
||||
if not topIs(TextBox) then return false end
|
||||
if box.waiting or box.done then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return box.waiting or box.done
|
||||
end
|
||||
|
||||
-- ▼ blinks half the time (blink < 30); settle so the shot shows it.
|
||||
-- Keep POKEPORT_SPEED=1: scriptedIterations>1 can advance past the
|
||||
-- wait in the same rendered frame before love.draw captures.
|
||||
local function shotPrompt(box, path)
|
||||
for _ = 1, 60 do
|
||||
if not (box.waiting or box.done) then break end
|
||||
if box.blink < 20 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, path)
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
local function dismissText()
|
||||
for _ = 1, 80 do
|
||||
if not topIs(TextBox) then return end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
-- Youngster5 @ (27,40): "I ran out of POKé / BALLs to catch" then
|
||||
-- \v "POKéMON with!" — the bug auto-scrolled past this with no ▼.
|
||||
U.teleport(game, "VIRIDIAN_FOREST", 27, 41, "up")
|
||||
U.tap(game, "a")
|
||||
local box = waitForTextBox(90)
|
||||
check(box ~= nil, "youngster5 opened a TextBox")
|
||||
if box then
|
||||
check(waitUntilPrompt(box, 300), "youngster5 reached a prompt")
|
||||
check(box.waiting and box.contAdvance,
|
||||
"youngster5 first prompt is a cont wait (▼)")
|
||||
shotPrompt(box, DIR .. "/dialogue_cont_00_youngster5_arrow.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
check(waitUntilPrompt(box, 300), "youngster5 reached para/done after cont")
|
||||
shotPrompt(box, DIR .. "/dialogue_cont_01_youngster5_after.png")
|
||||
dismissText()
|
||||
end
|
||||
|
||||
-- Trainer Tips1 sign @ (24,40): para after title, then two conts.
|
||||
U.teleport(game, "VIRIDIAN_FOREST", 24, 41, "up")
|
||||
U.tap(game, "a")
|
||||
box = waitForTextBox(90)
|
||||
check(box ~= nil, "tips1 sign opened a TextBox")
|
||||
if box then
|
||||
check(waitUntilPrompt(box, 300), "tips1 reached first prompt")
|
||||
-- first prompt is the para after "TRAINER TIPS"
|
||||
check(box.waiting and not box.contAdvance,
|
||||
"tips1 first prompt is a para wait")
|
||||
shotPrompt(box, DIR .. "/dialogue_cont_02_tips1_para.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
check(waitUntilPrompt(box, 300), "tips1 typed into page 2")
|
||||
check(box.waiting and box.contAdvance,
|
||||
"tips1 page2 pauses on cont with ▼")
|
||||
shotPrompt(box, DIR .. "/dialogue_cont_03_tips1_cont_arrow.png")
|
||||
dismissText()
|
||||
end
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Driver: reproduce the door-warp flow. Teleports to Pallet Town in
|
||||
-- front of Red's house, walks in, tries to move inside, walks back out.
|
||||
-- Driver: door-warp flow (Pallet house) plus #187 S.S. Anne enter-from-above.
|
||||
-- Anne-only shots: tests/drivers/anne_door_test.lua
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
@@ -32,4 +32,20 @@ return function(game)
|
||||
U.shot(game, DIR .. "/door_9_back_outside.png")
|
||||
U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
|
||||
"transitioning:", tostring(ow.transitioning))
|
||||
|
||||
-- #187: enter SS Anne cabin from above; expect one tile south of door
|
||||
U.teleport(game, "SS_ANNE_1F", 31, 9, "up")
|
||||
U.shot(game, DIR .. "/door_10_anne_before.png")
|
||||
U.hold(game, "up", 20)
|
||||
for _ = 1, 90 do
|
||||
U.wait(1)
|
||||
if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 and not ow.player.moving then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/door_11_anne_after.png")
|
||||
U.log("anne map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
|
||||
"facing:", ow.player.facing)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Driver: gym-leader post-battle dialogue chain (#164).
|
||||
-- Invokes checkVictoryRewards for Brock then Misty (same path as a win)
|
||||
-- and screenshots pages that must include badge-effect + TM explanation
|
||||
-- text — not just a synthetic "received badge/TM" stub.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/gym164 POKEPORT_DRIVER=tests/drivers/gym_leader_victory_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local function currentPageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local page = top.pages and top.pages[top.pageIndex]
|
||||
if not page then return "" end
|
||||
return table.concat(page, "\n")
|
||||
end
|
||||
|
||||
local function mashUntil(cond)
|
||||
for _ = 1, 1200 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function advancePages(want, shotName)
|
||||
U.log(shotName, "waiting for:", want)
|
||||
local ok = mashUntil(function()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return false end
|
||||
-- only match once the current page has finished typing
|
||||
if not (top.waiting or top.done) then return false end
|
||||
return currentPageText():find(want, 1, true) ~= nil
|
||||
end)
|
||||
U.log(shotName, "found:", ok, "page:", currentPageText():gsub("\n", "\\n"))
|
||||
U.shot(game, DIR .. "/" .. shotName .. ".png")
|
||||
assert(ok, shotName .. " missing dialogue containing: " .. want)
|
||||
end
|
||||
|
||||
local function runLeader(mapId, x, y, class, party, shots)
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.defeatedTrainers = game.save.defeatedTrainers or {}
|
||||
game.stack:push(OW, mapId, x, y, "up")
|
||||
U.wait(5)
|
||||
local ow = game.stack:top()
|
||||
U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png")
|
||||
ow:checkVictoryRewards(class, party)
|
||||
U.wait(10)
|
||||
for _, s in ipairs(shots.pages) do
|
||||
advancePages(s.want, shots.prefix .. "_" .. s.name)
|
||||
end
|
||||
U.log(shots.prefix, "closing box:", mashUntil(function()
|
||||
return game.stack:top() == ow
|
||||
end))
|
||||
U.shot(game, DIR .. "/" .. shots.prefix .. "_done.png")
|
||||
end
|
||||
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, {
|
||||
prefix = "brock",
|
||||
pages = {
|
||||
{ want = "BOULDERBADGE", name = "1_badge" },
|
||||
{ want = "FLASH", name = "2_flash" },
|
||||
{ want = "Wait!", name = "3_wait" },
|
||||
{ want = "TM34", name = "4_tm34" },
|
||||
{ want = "BIDE", name = "5_bide" },
|
||||
},
|
||||
})
|
||||
assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK")
|
||||
assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE")
|
||||
assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE")
|
||||
|
||||
runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, {
|
||||
prefix = "misty",
|
||||
pages = {
|
||||
{ want = "CASCADEBADGE", name = "1_badge" },
|
||||
{ want = "CUT", name = "2_cut" },
|
||||
{ want = "TM11", name = "3_tm11" },
|
||||
},
|
||||
})
|
||||
assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY")
|
||||
assert(game.save.inventory.CASCADEBADGE, "CASCADEBADGE")
|
||||
assert((game.save.inventory.TM_BUBBLEBEAM or 0) >= 1, "TM_BUBBLEBEAM")
|
||||
|
||||
U.log("gym_leader_victory_test: ok")
|
||||
end
|
||||
@@ -1,10 +1,11 @@
|
||||
-- Driver: Pokémon Center nurse heal, welcome/choice dialogue, the
|
||||
-- machine monitor + per-mon balls, the jingle flash, and the farewell.
|
||||
-- Driver: Pokémon Center nurse heal + Mom home heal.
|
||||
-- Logs frames between healAnim end and the fighting-fit / looking-great text.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local mon = Pokemon.new(game.data, "CHARMANDER", 12)
|
||||
mon.hp = 3
|
||||
table.insert(game.save.party, mon)
|
||||
@@ -14,7 +15,6 @@ return function(game)
|
||||
U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up")
|
||||
local ow = game.overworld
|
||||
|
||||
-- mash A until a condition holds
|
||||
local function mashUntil(cond)
|
||||
for _ = 1, 400 do
|
||||
if cond() then return true end
|
||||
@@ -24,6 +24,11 @@ return function(game)
|
||||
return false
|
||||
end
|
||||
|
||||
local function topIsText()
|
||||
return getmetatable(game.stack:top()) == TextBox
|
||||
end
|
||||
|
||||
-- -------- Poké Center --------
|
||||
U.tap(game, "a") -- talk to the nurse
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/heal_00_welcome.png")
|
||||
@@ -38,7 +43,6 @@ return function(game)
|
||||
U.shot(game, DIR .. "/heal_02_ball1.png")
|
||||
U.wait(28)
|
||||
U.shot(game, DIR .. "/heal_03_ball2.png")
|
||||
-- overlay must stay glued to the machine under survey zoom
|
||||
game:zoomStep(-1); game:zoomStep(-1)
|
||||
U.wait(3)
|
||||
U.shot(game, DIR .. "/heal_03z_zoomed.png")
|
||||
@@ -51,14 +55,73 @@ return function(game)
|
||||
U.shot(game, DIR .. "/heal_05_flash_b.png")
|
||||
U.wait(10)
|
||||
U.shot(game, DIR .. "/heal_06_flash_c.png")
|
||||
-- wait out the jingle + 32-frame beat
|
||||
for _ = 1, 40 do
|
||||
if not ow.healAnim then break end
|
||||
U.wait(10)
|
||||
|
||||
local animEndFrame, fitFrame
|
||||
for _ = 1, 400 do
|
||||
if ow.healAnim == nil and not animEndFrame then
|
||||
animEndFrame = U.frame()
|
||||
if topIsText() then fitFrame = animEndFrame end
|
||||
U.shot(game, DIR .. "/heal_06b_anim_end.png")
|
||||
if fitFrame then break end
|
||||
end
|
||||
if animEndFrame and topIsText() and not fitFrame then
|
||||
fitFrame = U.frame()
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/heal_07_fit.png")
|
||||
U.log("pc anim_end_frame:", animEndFrame, "fit_frame:", fitFrame,
|
||||
"gap_anim_to_text:",
|
||||
(fitFrame and animEndFrame) and (fitFrame - animEndFrame) or "n/a")
|
||||
U.log("farewell reached:", mashUntil(function()
|
||||
return game.stack:top() == ow
|
||||
end))
|
||||
U.shot(game, DIR .. "/heal_08_end.png")
|
||||
|
||||
-- -------- Mom (Reds House) --------
|
||||
require("src.script.Flags").set(game.save, "EVENT_GOT_STARTER")
|
||||
for _, m in ipairs(game.save.party) do
|
||||
m.hp = 1
|
||||
end
|
||||
-- stand below Mom (5,4) facing up
|
||||
U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up")
|
||||
ow = game.overworld
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/heal_mom_00_rest.png")
|
||||
U.log("mom fade started:", mashUntil(function()
|
||||
return ow.fadeOverlay ~= nil
|
||||
end))
|
||||
local fadeStart = U.frame()
|
||||
U.shot(game, DIR .. "/heal_mom_01_fade.png")
|
||||
local Music = require("src.core.Music")
|
||||
local sawJingle, jingleDoneFrame, fadeGone, greatFrame = false
|
||||
for _ = 1, 600 do
|
||||
if Music.oneShotPlaying() then sawJingle = true end
|
||||
if sawJingle and not Music.oneShotPlaying() and not jingleDoneFrame then
|
||||
jingleDoneFrame = U.frame()
|
||||
end
|
||||
if ow.fadeOverlay == nil and fadeStart and not fadeGone
|
||||
and U.frame() > fadeStart + 5 then
|
||||
fadeGone = U.frame()
|
||||
end
|
||||
if fadeGone and topIsText() then
|
||||
greatFrame = U.frame()
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/heal_mom_02_great.png")
|
||||
U.log("mom saw_jingle:", sawJingle,
|
||||
"jingle_done_frame:", jingleDoneFrame,
|
||||
"fade_gone_frame:", fadeGone,
|
||||
"great_frame:", greatFrame,
|
||||
"gap_fade_to_text:",
|
||||
(greatFrame and fadeGone) and (greatFrame - fadeGone) or "n/a")
|
||||
mashUntil(function()
|
||||
return game.stack:top() == ow
|
||||
end)
|
||||
U.shot(game, DIR .. "/heal_mom_03_end.png")
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
-- Driver: level-up learn with a full moveset (#173).
|
||||
-- Injects the post-KO level-up queue (grew + StatBox + learnMove) inside
|
||||
-- a live wild battle so MoveLearnMenu runs the same path as a real win.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/learn173 POKEPORT_DRIVER=tests/drivers/learn_move_full_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/learn173"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local mon = Pokemon.new(game.data, "CHARMANDER", 14)
|
||||
mon.moves = {
|
||||
{ id = "SCRATCH", pp = 35 },
|
||||
{ id = "GROWL", pp = 40 },
|
||||
{ id = "EMBER", pp = 25 },
|
||||
{ id = "SMOKESCREEN", pp = 20 },
|
||||
}
|
||||
table.insert(game.save.party, 1, mon)
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "RATTATA", 2)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function stackIds()
|
||||
local parts = {}
|
||||
for _, st in ipairs(game.stack.states or {}) do
|
||||
local id = st.screenId or (st.pages and "TextBox")
|
||||
or (st.onChoose and "ChoiceBox")
|
||||
or (st.mon and st.stats and "StatBox")
|
||||
or (st.newMoveId and "MoveLearnMenu")
|
||||
or (st.phase and ("Battle:" .. tostring(st.phase)))
|
||||
or "?"
|
||||
if st.selecting ~= nil then
|
||||
id = id .. (st.selecting and "[sel]" or "[ask]")
|
||||
end
|
||||
if st.pages then
|
||||
id = id .. string.format("[p%d/%d%s%s]", st.pageIndex or 0, #st.pages,
|
||||
st.waiting and "W" or "", st.done and "D" or "")
|
||||
end
|
||||
parts[#parts + 1] = id
|
||||
end
|
||||
return table.concat(parts, ">")
|
||||
end
|
||||
local function isText()
|
||||
local t = top()
|
||||
return t and t.pages ~= nil
|
||||
end
|
||||
local function isChoice()
|
||||
local t = top()
|
||||
return t and t.onChoose ~= nil and t.index ~= nil and not t.mon
|
||||
end
|
||||
local function learnMenu()
|
||||
for _, st in ipairs(game.stack.states or {}) do
|
||||
if st.newMoveId and st.mon and st.index then return st end
|
||||
end
|
||||
end
|
||||
local function waitFor(cond, max)
|
||||
for i = 1, max or 900 do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function atPrompt()
|
||||
local t = top()
|
||||
return t and t.pages and (t.waiting or t.done)
|
||||
end
|
||||
-- #163: \v conts pause with ▼ mid-page; tap through them so the shot
|
||||
-- lands on the full page (or the done+choice tail)
|
||||
local function advanceTextPage()
|
||||
waitFor(atPrompt, 300)
|
||||
for _ = 1, 4 do
|
||||
local t = top()
|
||||
if not (t and t.pages and t.waiting and t.contAdvance) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
waitFor(atPrompt, 300)
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
waitFor(function() return battle.phase == "menu" end, 400)
|
||||
local StatBox = BattleState.StatBox
|
||||
battle.phase = "messages"
|
||||
battle.afterQueue = "menu"
|
||||
battle.queue = {}
|
||||
battle.current = nil
|
||||
battle.nextInsert = 0
|
||||
battle:say("CHARMANDER gained\n16 EXP. Points!")
|
||||
battle:say("CHARMANDER grew\nto level 15!")
|
||||
battle:ui(function() return StatBox.new(game, mon) end)
|
||||
mon.level = 15
|
||||
-- append (not uiNext): learnMove uses uiNext for mid-fn ordering
|
||||
battle:ui(function()
|
||||
return battle:buildScreen("MoveLearnMenu", mon, "LEER")
|
||||
end)
|
||||
|
||||
for _ = 1, 400 do
|
||||
if learnMenu() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
if not learnMenu() then error("MoveLearnMenu never opened: " .. stackIds()) end
|
||||
U.log("opened", stackIds())
|
||||
|
||||
-- page 1
|
||||
advanceTextPage()
|
||||
U.log("shot0", stackIds())
|
||||
U.shot(game, DIR .. "/learn_0_trying.png")
|
||||
U.tap(game, "a"); U.wait(2)
|
||||
|
||||
-- page 2
|
||||
advanceTextPage()
|
||||
U.log("shot1", stackIds())
|
||||
U.shot(game, DIR .. "/learn_1_cant_more.png")
|
||||
U.tap(game, "a"); U.wait(2)
|
||||
|
||||
-- page 3 types out (through its cont), then ChoiceBox overlays
|
||||
advanceTextPage()
|
||||
if not waitFor(isChoice, 120) then
|
||||
error("ChoiceBox never appeared: " .. stackIds())
|
||||
end
|
||||
U.wait(4) -- let choice settle over Delete prompt
|
||||
U.log("shot2", stackIds())
|
||||
U.shot(game, DIR .. "/learn_2_delete_yesno.png")
|
||||
U.tap(game, "a"); U.wait(4) -- YES
|
||||
|
||||
if not waitFor(function()
|
||||
local m = learnMenu()
|
||||
return m and m.selecting and top() == m
|
||||
end, 120) then
|
||||
error("forget list never active: " .. stackIds())
|
||||
end
|
||||
U.log("shot3", stackIds())
|
||||
U.shot(game, DIR .. "/learn_3_which_move.png")
|
||||
U.tap(game, "a"); U.wait(4) -- forget SCRATCH
|
||||
|
||||
local n = 4
|
||||
for _ = 1, 60 do
|
||||
if not isText() then break end
|
||||
local t = top()
|
||||
if t.done or t.waiting then
|
||||
U.log("shot" .. n, stackIds())
|
||||
U.shot(game, ("%s/learn_%d_after.png"):format(DIR, n))
|
||||
n = n + 1
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
|
||||
U.log("done", DIR, "moves[1]=", mon.moves[1] and mon.moves[1].id, stackIds())
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Driver: Mt Moon B2F fossil pick (scripts/MtMoonB2F.asm). After the
|
||||
-- Super Nerd is beaten, take one fossil and confirm he walks to the other
|
||||
-- and says "All right. Then this is mine!" before it vanishes.
|
||||
-- Runs DOME then HELIX paths (map is reset between).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
if #game.save.party == 0 then
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "PIKACHU", 20))
|
||||
end
|
||||
|
||||
local function runPick(tag, x, y, takeName, leaveName, itemId, expectNerdX, expectNerdY, gotFlag)
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_MT_MOON_3_SUPER_NERD = true
|
||||
game.save.flags.EVENT_GOT_DOME_FOSSIL = nil
|
||||
game.save.flags.EVENT_GOT_HELIX_FOSSIL = nil
|
||||
game.save.inventory.DOME_FOSSIL = nil
|
||||
game.save.inventory.HELIX_FOSSIL = nil
|
||||
game.save.objectToggles = game.save.objectToggles or {}
|
||||
game.save.objectToggles.MT_MOON_B2F = nil
|
||||
|
||||
U.teleport(game, "MT_MOON_B2F", x, y, "up")
|
||||
local ow = game.overworld
|
||||
U.wait(5)
|
||||
|
||||
local function idle()
|
||||
return game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0
|
||||
end
|
||||
local function mash(cond, label, cap)
|
||||
for _ = 1, cap or 400 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.log("TIMEOUT:", tag, label)
|
||||
return false
|
||||
end
|
||||
local function fossilVisible(name)
|
||||
for _, n in ipairs(ow.npcs) do
|
||||
if n.def and n.def.name == name then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function nerd()
|
||||
return ow:npcByIndex(1)
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. ("/mtmoon_%s_0_before.png"):format(tag))
|
||||
U.log(tag, "before", takeName .. ":", tostring(fossilVisible(takeName)),
|
||||
leaveName .. ":", tostring(fossilVisible(leaveName)),
|
||||
"nerd:", nerd() and (nerd().cellX .. "," .. nerd().cellY) or "nil")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(24)
|
||||
U.shot(game, DIR .. ("/mtmoon_%s_1_ask.png"):format(tag))
|
||||
U.tap(game, "a") -- YES
|
||||
mash(function()
|
||||
return game.stack:top() == ow or ow.runner:isRunning()
|
||||
end, "received / walk start", 200)
|
||||
|
||||
local sawMineText, midwalk = false, false
|
||||
for _ = 1, 500 do
|
||||
local n = nerd()
|
||||
if not midwalk and n and (n.cellX ~= 12 or n.cellY ~= 8
|
||||
or n.moving or #ow.scriptMoves > 0) then
|
||||
if n.cellX ~= 12 or n.cellY ~= 8 or n.moving then
|
||||
midwalk = true
|
||||
U.shot(game, DIR .. ("/mtmoon_%s_2_walk.png"):format(tag))
|
||||
end
|
||||
end
|
||||
local top = game.stack:top()
|
||||
if top and top ~= ow and top.pages and top.done then
|
||||
local flat = ""
|
||||
for _, page in ipairs(top.pages) do
|
||||
for _, line in ipairs(page) do flat = flat .. line .. " " end
|
||||
end
|
||||
if flat:find("this is mine", 1, true) then
|
||||
sawMineText = true
|
||||
U.shot(game, DIR .. ("/mtmoon_%s_3_mine.png"):format(tag))
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
mash(idle, "cutscene idle", 400)
|
||||
U.shot(game, DIR .. ("/mtmoon_%s_4_done.png"):format(tag))
|
||||
|
||||
local n = nerd()
|
||||
U.log(tag, "sawMineText:", tostring(sawMineText),
|
||||
"midwalk:", tostring(midwalk))
|
||||
U.log(tag, "after", takeName .. ":", tostring(fossilVisible(takeName)),
|
||||
leaveName .. ":", tostring(fossilVisible(leaveName)))
|
||||
U.log(tag, "nerd at:", n and (n.cellX .. "," .. n.cellY) or "nil",
|
||||
"expect:", expectNerdX .. "," .. expectNerdY)
|
||||
U.log(tag, "flag:", gotFlag, tostring(game.save.flags[gotFlag]),
|
||||
"bag:", tostring(game.save.inventory[itemId]))
|
||||
end
|
||||
|
||||
runPick("dome", 12, 7,
|
||||
"MTMOONB2F_DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "DOME_FOSSIL",
|
||||
13, 7, "EVENT_GOT_DOME_FOSSIL")
|
||||
runPick("helix", 13, 7,
|
||||
"MTMOONB2F_HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "HELIX_FOSSIL",
|
||||
12, 7, "EVENT_GOT_HELIX_FOSSIL")
|
||||
|
||||
U.log("DONE")
|
||||
love.event.quit()
|
||||
end
|
||||
+65
-11
@@ -1,6 +1,5 @@
|
||||
-- Driver: the full Pallet intro chain. Steps north to trigger Oak,
|
||||
-- follows him to the lab, takes a starter, watches the rival
|
||||
-- counter-pick, tries to leave (door gate + ambush battle).
|
||||
-- Driver: Pallet intro (Oak escort → starter → rival ambush) plus the
|
||||
-- #158 parcel-return / rival-join / Pokédex-gift cutscene.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
@@ -96,13 +95,68 @@ return function(game)
|
||||
U.log("final:", ow.map.id, ow.player.cellX, ow.player.cellY,
|
||||
"battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB),
|
||||
"battleSeen:", tostring(BattleSeen))
|
||||
U.log("stuck-state: runner=", tostring(ow.runner:isRunning()),
|
||||
"moves=", #ow.scriptMoves, "top=", tostring(game.stack:top() == ow),
|
||||
"transitioning=", tostring(ow.transitioning),
|
||||
"emote=", tostring(ow.emote ~= nil))
|
||||
for i, mv in ipairs(ow.scriptMoves) do
|
||||
U.log((" move %d: entity=%s dir=%s remaining=%d moving=%s"):format(
|
||||
i, tostring(mv.entity.def and mv.entity.def.sprite or "PLAYER"),
|
||||
tostring(mv.dir), mv.remaining, tostring(mv.entity.moving)))
|
||||
|
||||
-- ---- #158: return Oak's Parcel → rival joins → Pokédex gift ----
|
||||
local Flags = require("src.script.Flags")
|
||||
Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL")
|
||||
game.save.inventory.OAKS_PARCEL = 1
|
||||
-- desk Oak + both dexes visible; rival hidden until the script shows
|
||||
-- him. Keep the starter-ball hides from the pick (Squirtle + Bulbasaur).
|
||||
game.save.objectToggles = game.save.objectToggles or {}
|
||||
game.save.objectToggles.OAKS_LAB = {
|
||||
OAKSLAB_OAK1 = true,
|
||||
OAKSLAB_OAK2 = false,
|
||||
OAKSLAB_RIVAL = false,
|
||||
OAKSLAB_POKEDEX1 = true,
|
||||
OAKSLAB_POKEDEX2 = true,
|
||||
OAKSLAB_SQUIRTLE_POKE_BALL = false,
|
||||
OAKSLAB_BULBASAUR_POKE_BALL = false,
|
||||
}
|
||||
U.teleport(game, "OAKS_LAB", 5, 3, "up")
|
||||
ow = game.overworld
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/oak_12_parcel_ready.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/oak_13_deliver.png")
|
||||
local rivalSeen, atDeskShot, leaveShot = false, false, false
|
||||
for _ = 1, 1200 do
|
||||
if idle() and Flags.get(game.save, "EVENT_GOT_POKEDEX")
|
||||
and not ow:npcByIndex(1) then
|
||||
break
|
||||
end
|
||||
local rival = ow:npcByIndex(1)
|
||||
local top = game.stack:top()
|
||||
if rival and not rivalSeen then
|
||||
rivalSeen = true
|
||||
U.wait(24)
|
||||
U.shot(game, DIR .. "/oak_14_rival_arrive.png")
|
||||
end
|
||||
if rival and rival.cellY <= 3 and not atDeskShot then
|
||||
atDeskShot = true
|
||||
U.shot(game, DIR .. "/oak_14b_rival_at_desk.png")
|
||||
end
|
||||
if rival and rival.cellY >= 6 and Flags.get(game.save, "EVENT_GOT_POKEDEX")
|
||||
and not leaveShot then
|
||||
leaveShot = true
|
||||
U.shot(game, DIR .. "/oak_14c_rival_leave.png")
|
||||
end
|
||||
if top ~= ow then
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
mashUntil(idle, "pokedex cutscene done", 600)
|
||||
U.shot(game, DIR .. "/oak_15_after_pokedex.png")
|
||||
local toggles = game.save.objectToggles and game.save.objectToggles.OAKS_LAB
|
||||
local rivalAfter = ow:npcByIndex(1)
|
||||
U.log("parcel:",
|
||||
"gotDex=", tostring(Flags.get(game.save, "EVENT_GOT_POKEDEX")),
|
||||
"oakGot=", tostring(Flags.get(game.save, "EVENT_OAK_GOT_PARCEL")),
|
||||
"rivalSeen=", tostring(rivalSeen),
|
||||
"rivalGone=", tostring(rivalAfter == nil),
|
||||
"atDesk=", tostring(atDeskShot),
|
||||
"dex1=", tostring(toggles and toggles.OAKSLAB_POKEDEX1),
|
||||
"dex2=", tostring(toggles and toggles.OAKSLAB_POKEDEX2),
|
||||
"route22=", tostring(Flags.get(game.save, "EVENT_ROUTE22_RIVAL_WANTS_BATTLE")))
|
||||
end
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Driver: Player's PC deposit "How many?" with >7 bag stacks (#174).
|
||||
-- pokered PrintListMenuEntries shows 4 names; PrintText + quantity box
|
||||
-- must not overlap the list.
|
||||
-- SHOT_DIR=/tmp/pc_deposit POKEPORT_DRIVER=tests/drivers/pc_deposit_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PlayerPC = require("src.ui.PlayerPC")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local pass = true
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
|
||||
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
|
||||
game.save.pcItems = {}
|
||||
-- >7 distinct depositable stacks (bug reproduces only then)
|
||||
local seed = {
|
||||
"POTION", "SUPER_POTION", "ANTIDOTE", "AWAKENING", "BURN_HEAL",
|
||||
"ICE_HEAL", "PARLYZ_HEAL", "FULL_HEAL", "REPEL", "POKE_BALL",
|
||||
}
|
||||
for _, id in ipairs(seed) do Bag.add(game.save, id, 5) end
|
||||
check(#seed > 7, "seeded more than 7 bag stacks")
|
||||
|
||||
game.stack:push(PlayerPC.new(game))
|
||||
U.wait(4)
|
||||
U.tap(game, "down"); U.wait(1) -- DEPOSIT ITEM
|
||||
U.tap(game, "a"); U.wait(4)
|
||||
check(topIs(ListMenu), "DEPOSIT ITEM list opened")
|
||||
local list = game.stack:top()
|
||||
check(list.rows == 4, "deposit list uses 4 rows like PrintListMenuEntries")
|
||||
check(list.messageBox == true, "deposit list uses messageBox footer")
|
||||
check(#list.items > 7, "list has more than 7 depositable items")
|
||||
U.shot(game, DIR .. "/pc_deposit_0_list.png")
|
||||
|
||||
-- pick first non-key stack (seed items are all quantity-prompted)
|
||||
U.tap(game, "a"); U.wait(4)
|
||||
check(topIs(QuantityBox), "quantity box on stack")
|
||||
check(list.footer == "How many?", "How many? footer set")
|
||||
U.shot(game, DIR .. "/pc_deposit_1_how_many.png")
|
||||
|
||||
-- layout probe: list rows stay above the text box (ty=12 → y=96)
|
||||
local drawn = {}
|
||||
local Font = require("src.render.Font")
|
||||
local savedDraw, savedBox = Font.draw, Font.drawBox
|
||||
Font.draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { kind = "text", text = tostring(text), x = x, y = y }
|
||||
return savedDraw(text, x, y)
|
||||
end
|
||||
Font.drawBox = function(tx, ty, tw, th)
|
||||
drawn[#drawn + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th }
|
||||
return savedBox(tx, ty, tw, th)
|
||||
end
|
||||
game.stack:draw()
|
||||
Font.draw, Font.drawBox = savedDraw, savedBox
|
||||
|
||||
local howY, qtyBox, textBox, maxItemY
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.kind == "text" and d.text == "How many?" then howY = d.y end
|
||||
if d.kind == "box" and d.ty == 9 and d.tw == 5 then qtyBox = d end
|
||||
if d.kind == "box" and d.tx == 0 and d.ty == 12 and d.tw == 20 then
|
||||
textBox = d
|
||||
end
|
||||
if d.kind == "text" and tostring(d.text):match("^x%d") and d.y then
|
||||
if not maxItemY or d.y > maxItemY then maxItemY = d.y end
|
||||
end
|
||||
end
|
||||
check(howY == 112, "How many? sits on text-box first line (y=112)")
|
||||
check(textBox ~= nil, "bottom PrintText box drawn")
|
||||
check(qtyBox ~= nil, "quantity box at pokered row 9")
|
||||
check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)")
|
||||
if maxItemY and howY then
|
||||
check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?")
|
||||
end
|
||||
|
||||
U.tap(game, "a"); U.wait(6) -- confirm x01
|
||||
U.shot(game, DIR .. "/pc_deposit_2_stored.png")
|
||||
check(type(list.footer) == "string" and list.footer:find("stored", 1, true),
|
||||
"stored-via-PC footer after deposit")
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
love.event.quit(pass and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Driver: Bill's PC vs player's PC top menus (#176). Both should open
|
||||
-- on the left (pokered BillsPCMenu / PlayersPCMenu TextBoxBorder at 0,0).
|
||||
-- SHOT_DIR=/tmp/pc_sides POKEPORT_DRIVER=tests/drivers/pc_menu_sides_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local BoxMenu = require("src.ui.BoxMenu")
|
||||
local PlayerPC = require("src.ui.PlayerPC")
|
||||
|
||||
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_MET_BILL = true
|
||||
|
||||
game.stack:push(BoxMenu.new(game))
|
||||
U.wait(5)
|
||||
local bills = game.stack:top()
|
||||
U.log(("bills tx=%d ty=%d tw=%d th=%d"):format(
|
||||
bills.tx, bills.ty, bills.tw, bills.th))
|
||||
U.shot(game, DIR .. "/pc_0_bills.png")
|
||||
U.wait(8) -- let async captureScreenshot flush before pop
|
||||
game.stack:pop()
|
||||
U.wait(2)
|
||||
|
||||
game.stack:push(PlayerPC.new(game))
|
||||
U.wait(5)
|
||||
local player = game.stack:top()
|
||||
U.log(("player tx=%d ty=%d tw=%d th=%d"):format(
|
||||
player.tx, player.ty, player.tw, player.th))
|
||||
U.shot(game, DIR .. "/pc_1_player.png")
|
||||
U.wait(8)
|
||||
|
||||
local sameSide = player.tx == bills.tx and player.ty == bills.ty
|
||||
U.log(sameSide and "PASS: both menus share top-left origin"
|
||||
or "FAIL: menus not on the same side")
|
||||
U.log("DONE")
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Driver: Bill's PC RELEASE two mons in one list session (#171).
|
||||
-- SHOT_DIR=/tmp/pc_release POKEPORT_DRIVER=tests/drivers/pc_release_two_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local BoxMenu = require("src.ui.BoxMenu")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass = true
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
local function mash(btn, cond, n)
|
||||
for _ = 1, (n or 240) do
|
||||
if cond() then return true end
|
||||
U.tap(game, btn)
|
||||
U.wait(2)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_MET_BILL = true
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local box = Boxes.active(game.save)
|
||||
for i = #box, 1, -1 do box[i] = nil end
|
||||
box[1] = Pokemon.new(game.data, "RATTATA", 5)
|
||||
box[2] = Pokemon.new(game.data, "PIDGEY", 6)
|
||||
box[3] = Pokemon.new(game.data, "CATERPIE", 4)
|
||||
check(#box == 3, "seeded 3 boxed mons")
|
||||
|
||||
game.stack:push(BoxMenu.new(game))
|
||||
U.wait(4)
|
||||
U.tap(game, "down"); U.wait(1)
|
||||
U.tap(game, "down"); U.wait(1)
|
||||
U.tap(game, "a") -- RELEASE
|
||||
check(mash("a", function() return topIs(ListMenu) end, 20), "RELEASE list opened")
|
||||
U.shot(game, DIR .. "/pc_release_0_list.png")
|
||||
|
||||
local function releaseOne(label)
|
||||
check(topIs(ListMenu), label .. ": still on release list")
|
||||
local before = #Boxes.active(game.save)
|
||||
U.tap(game, "a") -- pick current row
|
||||
check(mash("a", function() return topIs(ChoiceBox) end), label .. ": confirm choice")
|
||||
U.shot(game, DIR .. "/pc_release_" .. label .. "_confirm.png")
|
||||
U.tap(game, "up"); U.wait(1) -- defaultNo -> YES
|
||||
U.tap(game, "a")
|
||||
check(mash("a", function()
|
||||
return topIs(ListMenu) or (#game.stack.states > 0 and topIs(TextBox) == false)
|
||||
end), label .. ": bye text advancing")
|
||||
check(mash("a", function() return topIs(ListMenu) end), label .. ": back on list")
|
||||
local after = #Boxes.active(game.save)
|
||||
check(after == before - 1, label .. ": box count " .. before .. " -> " .. after)
|
||||
U.shot(game, DIR .. "/pc_release_" .. label .. "_list.png")
|
||||
end
|
||||
|
||||
releaseOne("1st")
|
||||
releaseOne("2nd")
|
||||
|
||||
check(#Boxes.active(game.save) == 1, "one mon remains after two releases")
|
||||
check(topIs(ListMenu), "still in RELEASE list without re-entering")
|
||||
U.shot(game, DIR .. "/pc_release_done.png")
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
love.event.quit(pass and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,203 @@
|
||||
-- Driver: #154 Route 22 rival exit direction + #168 Cerulean Bill text
|
||||
-- and southbound walk-off.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/rival_walkoff POKEPORT_SPEED=8 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/rival_walkoff_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/rival_walkoff"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
if game.save.options then game.save.options.zoom = 0 end
|
||||
Zoom.reset()
|
||||
|
||||
local function tank()
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
mon.moves = {
|
||||
{ id = "PSYCHIC_M", pp = 99 },
|
||||
{ id = "THUNDERBOLT", pp = 99 },
|
||||
{ id = "ICE_BEAM", pp = 99 },
|
||||
{ id = "RECOVER", pp = 99 },
|
||||
}
|
||||
return mon
|
||||
end
|
||||
|
||||
game.save.party = { tank() }
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = "RED"
|
||||
game.save.player.rival = "BLUE"
|
||||
Flags.set(game.save, "EVENT_CHOSE_SQUIRTLE")
|
||||
|
||||
local function idle(ow)
|
||||
return game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and not ow.transitioning
|
||||
end
|
||||
|
||||
local function mashUntil(cond, label, cap)
|
||||
for _ = 1, cap or 800 do
|
||||
if cond() then return true end
|
||||
if game.stack:top() ~= game.overworld then U.tap(game, "a") end
|
||||
U.wait(2)
|
||||
end
|
||||
U.log("TIMEOUT waiting for " .. label)
|
||||
return false
|
||||
end
|
||||
|
||||
local function rivalNpc()
|
||||
local ow = game.overworld
|
||||
for _, npc in ipairs(ow.npcs or {}) do
|
||||
if npc.def and npc.def.name and npc.def.name:find("RIVAL") then
|
||||
return npc
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fight until the battle state leaves the stack. Do NOT mash the
|
||||
-- post-battle script text -- the caller screenshots that.
|
||||
local function winBattle(label)
|
||||
local ow = game.overworld
|
||||
local sawBattle = false
|
||||
for frames = 1, 5000 do
|
||||
local top = game.stack:top()
|
||||
if top and top.phase then
|
||||
sawBattle = true
|
||||
if top.phase == "menu" then
|
||||
top.menuIndex = 1
|
||||
elseif top.phase == "moveSelect" then
|
||||
top.moveIndex = 1
|
||||
end
|
||||
U.tap(game, "a")
|
||||
if frames > 2400 and top.onFinish then
|
||||
U.log("force-finishing stalled", label)
|
||||
top.onFinish("win")
|
||||
if game.stack:top() == top then game.stack:pop() end
|
||||
end
|
||||
elseif sawBattle then
|
||||
-- battle popped: either a TextBox or the overworld runner
|
||||
return true
|
||||
elseif top ~= ow then
|
||||
U.tap(game, "a") -- pre-battle dialogue
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
U.log("TIMEOUT winBattle", label)
|
||||
return false
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- #154
|
||||
-- Route 22 optional post-lab rival: after the fight he must walk
|
||||
-- toward Viridian (right / down), not back toward the League.
|
||||
Flags.set(game.save, "EVENT_GOT_POKEDEX")
|
||||
Flags.clear(game.save, "EVENT_BEAT_BROCK")
|
||||
Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE")
|
||||
Flags.clear(game.save, "EVENT_BEAT_GIOVANNI")
|
||||
Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE")
|
||||
|
||||
U.teleport(game, "ROUTE_22", 30, 5, "left")
|
||||
local ow = game.overworld
|
||||
U.shot(game, DIR .. "/r22_0_before.png")
|
||||
U.hold(game, "left", 20)
|
||||
|
||||
mashUntil(function()
|
||||
return ow.runner:isRunning() or game.stack:top() ~= ow
|
||||
end, "route22 ambush", 200)
|
||||
U.shot(game, DIR .. "/r22_1_ambush.png")
|
||||
mashUntil(function() return game.stack:top() ~= ow end, "route22 battle", 800)
|
||||
winBattle("route22")
|
||||
|
||||
local sawExit, exitFacing, exitX, exitY = false, nil, nil, nil
|
||||
for _ = 1, 600 do
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
local r = rivalNpc()
|
||||
if r and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||
if (r.facing == "right" or r.facing == "down" or r.cellX > 28)
|
||||
and not sawExit then
|
||||
sawExit = true
|
||||
exitFacing, exitX, exitY = r.facing, r.cellX, r.cellY
|
||||
U.shot(game, DIR .. "/r22_2_exit.png")
|
||||
U.log("r22 exit:", exitFacing, exitX, exitY)
|
||||
elseif sawExit and (r.facing == "down" or r.cellY > 5) then
|
||||
U.shot(game, DIR .. "/r22_3_exit_mid.png")
|
||||
U.log("r22 exit mid:", r.facing, r.cellX, r.cellY)
|
||||
break
|
||||
end
|
||||
end
|
||||
if idle(ow) and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||
break
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
mashUntil(function() return idle(ow) end, "route22 done", 800)
|
||||
U.shot(game, DIR .. "/r22_4_done.png")
|
||||
U.log("r22 beat:", tostring(game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE),
|
||||
"exitFacing:", tostring(exitFacing))
|
||||
|
||||
-- ---------------------------------------------------------- #168
|
||||
-- Nugget Bridge: Bill dialogue, then walk into Cerulean (south).
|
||||
Flags.clear(game.save, "EVENT_BEAT_CERULEAN_RIVAL")
|
||||
game.save.party = { tank() }
|
||||
|
||||
U.teleport(game, "CERULEAN_CITY", 20, 7, "up")
|
||||
ow = game.overworld
|
||||
U.shot(game, DIR .. "/cer_0_before.png")
|
||||
U.hold(game, "up", 20)
|
||||
|
||||
mashUntil(function()
|
||||
return ow.runner:isRunning() or game.stack:top() ~= ow
|
||||
end, "cerulean ambush", 200)
|
||||
U.shot(game, DIR .. "/cer_1_ambush.png")
|
||||
mashUntil(function() return game.stack:top() ~= ow end, "cerulean battle", 800)
|
||||
winBattle("cerulean")
|
||||
|
||||
local function boxText(top)
|
||||
if not top or type(top.pages) ~= "table" then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
local billShot, exitShot, lastBox, postBoxes = false, false, nil, 0
|
||||
for _ = 1, 2000 do
|
||||
local top = game.stack:top()
|
||||
if top ~= ow then
|
||||
local t = boxText(top)
|
||||
if game.save.flags.EVENT_BEAT_CERULEAN_RIVAL and t ~= "" and top ~= lastBox then
|
||||
lastBox = top
|
||||
postBoxes = postBoxes + 1
|
||||
U.log("post-battle box", postBoxes, t:sub(1, 80))
|
||||
-- Bill line is the second post-flag text (after DefeatedText)
|
||||
if not billShot and (t:find("BILL") or t:find("Storage")
|
||||
or t:find("invented") or t:find("MANIAC") or t:find("pages")
|
||||
or t:find("guess what") or postBoxes >= 2) then
|
||||
billShot = true
|
||||
for _ = 1, 30 do U.wait(2); U.tap(game, "a") end -- advance into Bill body
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/cer_2_bill.png")
|
||||
U.log("bill text:", boxText(game.stack:top()):sub(1, 160))
|
||||
end
|
||||
end
|
||||
U.tap(game, "a")
|
||||
elseif game.save.flags.EVENT_BEAT_CERULEAN_RIVAL then
|
||||
local r = rivalNpc()
|
||||
if r and r.cellY and r.cellY >= 6 and r.facing == "down" and not exitShot then
|
||||
exitShot = true
|
||||
U.shot(game, DIR .. "/cer_3_exit.png")
|
||||
U.log("cer exit:", r.facing, r.cellX, r.cellY)
|
||||
end
|
||||
if idle(ow) then break end
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
mashUntil(function() return idle(ow) end, "cerulean done", 800)
|
||||
U.shot(game, DIR .. "/cer_4_done.png")
|
||||
U.log("cer beat:", tostring(game.save.flags.EVENT_BEAT_CERULEAN_RIVAL),
|
||||
"billShot:", tostring(billShot), "exitShot:", tostring(exitShot))
|
||||
end
|
||||
@@ -731,6 +731,11 @@ local function switchTo(battle, slot)
|
||||
end
|
||||
local t = replacementMenu()
|
||||
if not t or t.index ~= slot then break end
|
||||
press("a") -- select mon -> SWITCH / STATS / CANCEL
|
||||
U.wait(8)
|
||||
t = replacementMenu()
|
||||
if not (t and t.submenu) then break end
|
||||
-- SWITCH is first (SwitchStatsCancelText)
|
||||
press("a")
|
||||
U.wait(10)
|
||||
ok = true
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Driver: S.S. Anne kitchen PrintTrashText bins (#188).
|
||||
-- Teleports beside each bin, interacts, screenshots the dialogue.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass = true
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
|
||||
local bins = game.data.field.hiddenExtras
|
||||
and game.data.field.hiddenExtras.printTrash
|
||||
and game.data.field.hiddenExtras.printTrash.SS_ANNE_KITCHEN
|
||||
check(bins and #bins == 2, "SS_ANNE_KITCHEN has 2 PrintTrashText bins")
|
||||
|
||||
for i, bin in ipairs(bins or {}) do
|
||||
-- stand west of the bin facing right (cooks occupy the column)
|
||||
U.teleport(game, "SS_ANNE_KITCHEN", bin.x - 1, bin.y, "right")
|
||||
local ow = game.overworld
|
||||
local fx, fy = ow.player:facingCell()
|
||||
U.log(("bin %d: at (%d,%d) facing %s -> (%d,%d)"):format(
|
||||
i, ow.player.cellX, ow.player.cellY, ow.player.facing, fx, fy))
|
||||
check(fx == bin.x and fy == bin.y, ("facing kitchen trash at (%d,%d)"):format(bin.x, bin.y))
|
||||
|
||||
U.tap(game, "a")
|
||||
local opened = false
|
||||
for _ = 1, 60 do
|
||||
if topIs(TextBox) then opened = true; break end
|
||||
U.wait(2)
|
||||
end
|
||||
check(opened, ("bin %d opened a TextBox"):format(i))
|
||||
if opened then
|
||||
local box = game.stack:top()
|
||||
for _ = 1, 180 do
|
||||
if not topIs(TextBox) then break end
|
||||
if box.waiting or box.done then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
local page = box.pages and box.pages[1]
|
||||
local body = type(page) == "table" and table.concat(page, "\n")
|
||||
or (type(page) == "string" and page or "")
|
||||
U.log("dialogue:", body:gsub("\n", "\\n"):sub(1, 80))
|
||||
check(body:find("only trash here", 1, true) ~= nil,
|
||||
("bin %d shows trash dialogue"):format(i))
|
||||
U.shot(game, DIR .. ("/ss_anne_kitchen_trash_%d.png"):format(i))
|
||||
for _ = 1, 40 do
|
||||
if not topIs(TextBox) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Driver: #189 S.S. Anne 1F door→room order. Enter each hallway cabin
|
||||
-- door left→right; screenshot room contents (SHOT_DIR).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots-189"
|
||||
|
||||
local doors = {}
|
||||
for i, w in ipairs(game.data.maps.SS_ANNE_1F.warps) do
|
||||
if w.destMap == "SS_ANNE_1F_ROOMS" then
|
||||
doors[#doors + 1] = { index = i, x = w.x, y = w.y, destWarp = w.destWarp }
|
||||
end
|
||||
end
|
||||
table.sort(doors, function(a, b) return a.x < b.x end)
|
||||
|
||||
U.teleport(game, "SS_ANNE_1F_ROOMS", 12, 8, "down")
|
||||
U.shot(game, DIR .. "/anne189_rooms_overview.png")
|
||||
|
||||
U.teleport(game, "SS_ANNE_1F", 20, 10, "up")
|
||||
U.shot(game, DIR .. "/anne189_hall_overview.png")
|
||||
|
||||
for n, d in ipairs(doors) do
|
||||
U.teleport(game, "SS_ANNE_1F", d.x, d.y + 1, "up")
|
||||
U.shot(game, string.format("%s/anne189_door%d_hall_x%d.png", DIR, n, d.x))
|
||||
U.hold(game, "up", 24)
|
||||
local ow = game.overworld
|
||||
for _ = 1, 120 do
|
||||
U.wait(1)
|
||||
if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning
|
||||
and #(ow.scriptMoves or {}) == 0 and not ow.player.moving then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(10)
|
||||
local names = {}
|
||||
local col = math.floor(ow.player.cellX / 10)
|
||||
local row = math.floor(ow.player.cellY / 10)
|
||||
for _, npc in ipairs(ow.npcs or {}) do
|
||||
if math.floor(npc.cellX / 10) == col and math.floor(npc.cellY / 10) == row then
|
||||
names[#names + 1] = npc.def.name or "?"
|
||||
end
|
||||
end
|
||||
table.sort(names)
|
||||
U.log(string.format(
|
||||
"door%d hall(%d,%d)#%d destWarp=%d -> %s (%d,%d) cell=%d,%d npcs: %s",
|
||||
n, d.x, d.y, d.index, d.destWarp, ow.map.id,
|
||||
ow.player.cellX, ow.player.cellY, col, row, table.concat(names, ",")))
|
||||
U.shot(game, string.format("%s/anne189_door%d_room.png", DIR, n))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
-- Driver for #169: poison status icon must appear AFTER move anim +
|
||||
-- "was poisoned!" text, not during the "used POISONPOWDER!" announce.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local mon = Pokemon.new(game.data, "BULBASAUR", 12)
|
||||
mon.moves[1] = { id = "POISONPOWDER", pp = 20 }
|
||||
mon.moves[2] = { id = "TACKLE", pp = 20 }
|
||||
table.insert(game.save.party, 1, mon)
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
-- force hit + make the foe never act (skip second half of the turn)
|
||||
battle.rng = function(a, b) return a or 0 end
|
||||
battle.enemyAction = function() return nil end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function waitFor(cond, max)
|
||||
for _ = 1, max or 600 do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 80 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
U.shot(game, DIR .. "/poison_0_menu.png")
|
||||
|
||||
U.tap(game, "a"); U.wait(8) -- FIGHT
|
||||
U.tap(game, "a"); U.wait(2) -- POISONPOWDER
|
||||
|
||||
-- announce: "BULBASAUR used POISONPOWDER!" — no PSN on HUD yet
|
||||
waitFor(function()
|
||||
return battle.current and battle.current.text
|
||||
and battle.current.text:find("POISONPOWDER", 1, true)
|
||||
end, 120)
|
||||
U.shot(game, DIR .. "/poison_1_announce.png")
|
||||
U.log(("announce shownStatus=%s mon.status=%s"):format(
|
||||
tostring(battle.enemy.shownStatus), tostring(battle.enemy.mon.status)))
|
||||
|
||||
-- mash through announce into the move anim / poisoned text
|
||||
local sawAnim, sawText = false, false
|
||||
for _ = 1, 200 do
|
||||
if battle.animPlaying or battle.animName == "POISONPOWDER" then
|
||||
if not sawAnim then
|
||||
sawAnim = true
|
||||
U.shot(game, DIR .. "/poison_2_anim.png")
|
||||
U.log(("anim shownStatus=%s"):format(tostring(battle.enemy.shownStatus)))
|
||||
end
|
||||
end
|
||||
if battle.current and battle.current.text
|
||||
and battle.current.text:find("poisoned", 1, true) then
|
||||
if not sawText then
|
||||
sawText = true
|
||||
U.shot(game, DIR .. "/poison_3_text.png")
|
||||
U.log(("text shownStatus=%s"):format(tostring(battle.enemy.shownStatus)))
|
||||
end
|
||||
end
|
||||
if battle.enemy.shownStatus == "PSN" and sawText then
|
||||
U.shot(game, DIR .. "/poison_4_icon.png")
|
||||
U.log("HUD PSN revealed after poisoned text")
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
waitFor(function() return battle.phase == "menu" end, 200)
|
||||
U.shot(game, DIR .. "/poison_5_menu.png")
|
||||
U.log(("final shownStatus=%s"):format(tostring(battle.enemy.shownStatus)))
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Driver: try a TM from the battle bag; Gen 1 refuses with Oak's
|
||||
-- "This isn't the time to use that!" (ItemUseTMHM -> ItemUseNotTime).
|
||||
--
|
||||
-- SHOT_DIR=/tmp/tm_battle_shots POKEPORT_DRIVER=tests/drivers/tm_battle_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12))
|
||||
Bag.add(game.save, "TM_TOXIC", 1)
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 80 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pageText(box)
|
||||
if not box or getmetatable(box) ~= TextBox then return "" end
|
||||
return table.concat(box.pages[box.pageIndex] or {}, "\n")
|
||||
end
|
||||
|
||||
U.log("to menu:", mashUntil(function() return battle.phase == "menu" end))
|
||||
U.shot(game, DIR .. "/tm_battle_0_menu.png")
|
||||
|
||||
-- FIGHT/PKMN / ITEM/RUN: down to ITEM, then A
|
||||
U.tap(game, "down"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(12)
|
||||
U.shot(game, DIR .. "/tm_battle_1_bag.png")
|
||||
U.log("bag open:", getmetatable(game.stack:top()) == ListMenu)
|
||||
|
||||
U.tap(game, "a")
|
||||
mashUntil(function()
|
||||
local top = game.stack:top()
|
||||
return getmetatable(top) == TextBox and top.waiting
|
||||
and pageText(top):find("isn't the", 1, true)
|
||||
end, 40)
|
||||
U.shot(game, DIR .. "/tm_battle_2_reject.png")
|
||||
|
||||
local top = game.stack:top()
|
||||
local text = pageText(top)
|
||||
U.log("reject TextBox:", getmetatable(top) == TextBox)
|
||||
U.log("oak text:", text:find("isn't the", 1, true) and "ok" or text)
|
||||
U.log("tm still in bag:", (game.save.inventory.TM_TOXIC or 0) >= 1)
|
||||
U.log("not booted:", not text:find("Booted", 1, true))
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Driver: NPC in-game trade cable animation (InternalClockTradeAnim).
|
||||
-- Vermilion Trade House: SPEAROW -> FARFETCH'D (DUX).
|
||||
--
|
||||
-- SHOT_DIR=/tmp/trade_shots POKEPORT_DRIVER=tests/drivers/trade_anim_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/trade_shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TradeAnim = require("src.ui.TradeAnim")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
local function topIs(cls)
|
||||
return getmetatable(game.stack:top()) == cls
|
||||
end
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) }
|
||||
U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up")
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/trade_00_house.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
for _ = 1, 200 do
|
||||
if topIs(ChoiceBox) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
U.shot(game, DIR .. "/trade_01_offer.png")
|
||||
U.tap(game, "a") -- YES
|
||||
U.wait(6)
|
||||
|
||||
for _ = 1, 60 do
|
||||
if topIs(PartyMenu) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("party menu:", topIs(PartyMenu))
|
||||
U.shot(game, DIR .. "/trade_02_party.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
|
||||
for _ = 1, 200 do
|
||||
if topIs(TradeAnim) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
local anim = game.stack:top()
|
||||
if getmetatable(anim) ~= TradeAnim then
|
||||
U.log("FAIL: TradeAnim never appeared")
|
||||
return
|
||||
end
|
||||
U.log("TradeAnim phase:", anim.phase)
|
||||
|
||||
local function ffUntil(phase, cap)
|
||||
for _ = 1, cap or 3000 do
|
||||
if anim.phase == phase or anim.phase == "done" then break end
|
||||
if anim.waitingText or topIs(TextBox) then
|
||||
U.wait(1)
|
||||
else
|
||||
anim:update(1 / 60)
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
-- hold mid show_player (scx settled, mon visible)
|
||||
ffUntil("show_player", 1)
|
||||
while anim.phase == "show_player" and (anim.sub ~= "hold" or anim.scx > 0) do
|
||||
anim:update(1 / 60)
|
||||
end
|
||||
for _ = 1, 10 do anim:update(1 / 60) end
|
||||
U.wait(1)
|
||||
U.shot(game, DIR .. "/trade_03_show_player.png")
|
||||
U.log("show_player sub=", anim.sub, "scx=", anim.scx)
|
||||
|
||||
ffUntil("open_cable", 500)
|
||||
-- mid scroll-in
|
||||
while anim.phase == "open_cable" and anim.scx > 40 do
|
||||
anim:update(1 / 60)
|
||||
end
|
||||
U.wait(1)
|
||||
U.shot(game, DIR .. "/trade_04_open_cable.png")
|
||||
U.log("open_cable scx=", anim.scx)
|
||||
|
||||
ffUntil("ball_enter", 200)
|
||||
while anim.phase == "ball_enter" and anim.ballX < 0x80 do
|
||||
anim:update(1 / 60)
|
||||
end
|
||||
U.wait(1)
|
||||
U.shot(game, DIR .. "/trade_05_ball_enter.png")
|
||||
U.log("ball at", anim.ballX, anim.ballY)
|
||||
|
||||
ffUntil("transfer_lr", 400)
|
||||
for _ = 1, 24 do anim:update(1 / 60) end
|
||||
U.wait(1)
|
||||
U.shot(game, DIR .. "/trade_06_transfer_lr.png")
|
||||
|
||||
ffUntil("went_to", 800)
|
||||
for _ = 1, 600 do
|
||||
if topIs(TextBox) then
|
||||
local tb = game.stack:top()
|
||||
if tb.done then break end
|
||||
-- finish typewriter so both lines are visible
|
||||
if tb.codes then tb.charIndex = #tb.codes end
|
||||
if tb.pages and tb.pageIndex and tb.lineIndex then
|
||||
local page = tb.pages[tb.pageIndex]
|
||||
if page and tb.lineIndex < #page then
|
||||
tb.lineIndex = #page
|
||||
tb.shown = {}
|
||||
for _, line in ipairs(page) do
|
||||
local codes = require("src.render.Font").encode(line)
|
||||
tb.shown[#tb.shown + 1] = codes
|
||||
end
|
||||
tb.charIndex = #(tb.shown[#tb.shown] or {})
|
||||
tb.done = true
|
||||
end
|
||||
end
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/trade_07_went_to.png")
|
||||
U.log("went-to:", topIs(TextBox))
|
||||
|
||||
ffUntil("transfer_rl", 2500)
|
||||
for _ = 1, 16 do anim:update(1 / 60) end
|
||||
U.wait(1)
|
||||
U.shot(game, DIR .. "/trade_08_transfer_rl.png")
|
||||
|
||||
ffUntil("show_enemy", 800)
|
||||
while anim.phase == "show_enemy" and not anim.monVisible do
|
||||
anim:update(1 / 60)
|
||||
end
|
||||
for _ = 1, 30 do anim:update(1 / 60) end
|
||||
U.wait(1)
|
||||
U.shot(game, DIR .. "/trade_09_show_enemy.png")
|
||||
|
||||
for _ = 1, 5000 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TradeAnim and not top.waitingText then
|
||||
top:update(1 / 60)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/trade_10_done.png")
|
||||
local mon = game.save.party[1]
|
||||
U.log("party:", mon and mon.species, "nick:", mon and mon.nickname,
|
||||
"ot:", mon and mon.ot)
|
||||
U.log("done")
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Driver: trainer card with empty badge slots (gym leader faces) and a
|
||||
-- partial set (face/badge mix), matching DrawBadges in pokered.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
game.save.player.name = "RED"
|
||||
game.save.money = 3000
|
||||
game.save.playTime = 3661
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
|
||||
local TrainerCard = require("src.ui.TrainerCard")
|
||||
|
||||
-- no badges: every slot should show the gym leader face
|
||||
for _, id in ipairs({
|
||||
"BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE",
|
||||
"SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE",
|
||||
}) do
|
||||
game.save.inventory[id] = nil
|
||||
end
|
||||
game.stack:push(TrainerCard.new(game))
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/trainer_card_0_faces.png")
|
||||
U.tap(game, "b"); U.wait(3)
|
||||
|
||||
-- boulder + cascade owned: first two slots swap to badges
|
||||
game.save.inventory.BOULDERBADGE = true
|
||||
game.save.inventory.CASCADEBADGE = true
|
||||
game.stack:push(TrainerCard.new(game))
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/trainer_card_1_partial.png")
|
||||
U.tap(game, "b"); U.wait(3)
|
||||
U.log("TRAINER_CARD_DRIVER: done")
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Driver: trainer sight must not engage off the GB screen (#153/#183).
|
||||
-- Oracle: engine/overworld/trainer_sight.asm TrainerEngage (IMAGEINDEX=$ff)
|
||||
-- + movement.asm CheckSpriteAvailability (10x9 window). 8-bit pixel
|
||||
-- distance wraps at ~12/28 tiles; without the on-screen gate, Viridian
|
||||
-- Forest / Victory Road trainers aggro through trees and walls.
|
||||
--
|
||||
-- Scene A: VIRIDIAN_FOREST (18,33), west of tree wall vs Bug Catcher
|
||||
-- (30,33) LEFT range 4 — dx=12 pixel-wrap must NOT engage.
|
||||
-- Scene B: same trainer at (26,33) on-screen — MUST engage + walk-up.
|
||||
-- Scene C: VICTORY_ROAD_2F (0,9) vs Hiker (12,9) — dx=12 must NOT engage.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local function trainerByIndex(ow, index)
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def.index == index then return npc end
|
||||
end
|
||||
end
|
||||
|
||||
-- --- A: Viridian Forest wrap through trees (dx=12, walkable west of wall) ---
|
||||
U.teleport(game, "VIRIDIAN_FOREST", 18, 33, "right")
|
||||
local ow = game.overworld
|
||||
local t = trainerByIndex(ow, 2)
|
||||
U.wait(10)
|
||||
U.log("VF far: player", ow.player.cellX, ow.player.cellY,
|
||||
"trainer", t and t.cellX, t and t.cellY,
|
||||
"engaging", tostring(ow.engaging))
|
||||
assert(t and t.cellX == 30 and t.cellY == 33, "VF Bug Catcher at (30,33)")
|
||||
assert(not ow.engaging, "VF: off-screen trainer must not engage through trees")
|
||||
assert(t.cellX == 30 and t.cellY == 33, "VF: trainer must not walk through trees")
|
||||
U.shot(game, DIR .. "/153_vf_far_no_aggro.png")
|
||||
|
||||
-- --- B: real on-screen sight line (same corridor, no tree wall) ---
|
||||
U.teleport(game, "VIRIDIAN_FOREST", 26, 33, "right")
|
||||
ow = game.overworld
|
||||
t = trainerByIndex(ow, 2)
|
||||
local guard = 0
|
||||
while not ow.engaging and guard < 30 do
|
||||
guard = guard + 1
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("VF near: engaging", tostring(ow.engaging),
|
||||
"emote", tostring(ow.emote and ow.emote.frames),
|
||||
"trainer", t and t.cellX, t and t.cellY)
|
||||
assert(ow.engaging, "VF: distance-4 on-screen must engage")
|
||||
U.shot(game, DIR .. "/153_vf_near_engage.png")
|
||||
-- EmotionBubble 60f + (dist-1)*16 walk frames
|
||||
U.wait(60 + 3 * 16 + 10)
|
||||
U.log("VF walkup: trainer", t.cellX, t.cellY, "player", ow.player.cellX, ow.player.cellY)
|
||||
assert(t.cellY == 33 and t.cellX >= 26 and t.cellX <= 30,
|
||||
"VF: walk-up stays on the open corridor")
|
||||
assert(t.cellX == 27, "VF: trainer stops adjacent after distance-1 walk-up")
|
||||
U.shot(game, DIR .. "/153_vf_near_walkup.png")
|
||||
|
||||
-- --- C: Victory Road 2F wrap (dx=12) ---
|
||||
U.teleport(game, "VICTORY_ROAD_2F", 0, 9, "right")
|
||||
ow = game.overworld
|
||||
t = trainerByIndex(ow, 1)
|
||||
U.wait(10)
|
||||
U.log("VR2 far: player", ow.player.cellX, ow.player.cellY,
|
||||
"trainer", t and t.cellX, t and t.cellY,
|
||||
"engaging", tostring(ow.engaging))
|
||||
assert(t and t.cellX == 12 and t.cellY == 9, "VR2 Hiker at (12,9)")
|
||||
assert(not ow.engaging, "VR2: off-screen trainer must not engage")
|
||||
assert(t.cellX == 12 and t.cellY == 9, "VR2: trainer must not walk through walls")
|
||||
U.shot(game, DIR .. "/183_vr2_far_no_aggro.png")
|
||||
|
||||
U.log("trainer_sight_walls_test OK")
|
||||
end
|
||||
@@ -33,7 +33,15 @@ end
|
||||
|
||||
function U.shot(game, path)
|
||||
game.capturePath = path
|
||||
U.wait(2) -- let the capture flush
|
||||
-- love.draw consumes capturePath once per rendered frame, but fast runs
|
||||
-- (POKEPORT_SPEED) step the driver many times per render; spin until the
|
||||
-- capture lands so later actions can't outrun it
|
||||
for _ = 1, 120 do
|
||||
if not game.capturePath then break end
|
||||
frame = frame + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
-- skip the intro movie + title into a fresh overworld game
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
-- Love-free coverage for the pure halves of src/mods/LauncherMods.lua: the
|
||||
-- status derivation (deriveList) over a synthetic manifest list + options
|
||||
-- table, and the archive-root location logic (locateRoot). The discovery and
|
||||
-- installZip paths need love.filesystem and are exercised by the launcher; the
|
||||
-- decision logic under them lives here so a bad range/conflict/root call fails
|
||||
-- one line instead of the app.
|
||||
-- luajit tests/engine/launcher_mods_tests.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local Version = require("src.core.Version")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
-- validated manifests are the exact shape deriveList/resolveToggle read
|
||||
local function mf(raw)
|
||||
return Manifest.validate(raw)
|
||||
end
|
||||
|
||||
-- index a deriveList result by mod id for assertions
|
||||
local function byId(list)
|
||||
local m = {}
|
||||
for _, row in ipairs(list) do m[row.id] = row end
|
||||
return m
|
||||
end
|
||||
|
||||
-- ------- badge derivation: category, then profile, then MOD (uppercased)
|
||||
|
||||
do
|
||||
local list = LauncherMods.deriveList({
|
||||
mf({ id = "cat", name = "Cat Mod", version = "1.0.0", entry = "m.lua",
|
||||
category = "gameplay" }),
|
||||
mf({ id = "prof", name = "Prof Mod", version = "1.0.0", entry = "m.lua",
|
||||
profile = "overhaul" }),
|
||||
mf({ id = "plain", name = "Plain", version = "1.0.0", entry = "m.lua" }),
|
||||
}, { mods = {} })
|
||||
local m = byId(list)
|
||||
eq(m.cat.badge, "GAMEPLAY", "badge uses the manifest category, uppercased")
|
||||
eq(m.prof.badge, "OVERHAUL", "badge falls back to the profile when no category")
|
||||
-- no category field, so the fallback reaches the profile default ("content")
|
||||
eq(m.plain.badge, "CONTENT", "bare manifest badge falls back to the profile")
|
||||
eq(#list, 3, "every discovered manifest yields one row")
|
||||
check(m.cat.id < m.plain.id and m.plain.id < m.prof.id,
|
||||
"rows come back sorted by id (cat < plain < prof)")
|
||||
end
|
||||
|
||||
-- ------- enabled defaults to true; a false entry disables
|
||||
|
||||
do
|
||||
local manifests = {
|
||||
mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }),
|
||||
mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua" }),
|
||||
}
|
||||
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
|
||||
check(m.aaa.enabled, "a mod with no options entry defaults to enabled")
|
||||
check(not m.bbb.enabled, "an explicit false disables the mod")
|
||||
eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
|
||||
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
|
||||
end
|
||||
|
||||
-- ------- conflict: only when this mod is enabled and the other is too
|
||||
|
||||
do
|
||||
local manifests = {
|
||||
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
|
||||
conflicts = { "beta" } }),
|
||||
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
|
||||
}
|
||||
-- both enabled: the declaring side (and, symmetrically, the other) conflict
|
||||
local both = byId(LauncherMods.deriveList(manifests, { mods = {} }))
|
||||
eq(both.alpha.status, "conflict", "enabled mod conflicting with an enabled mod")
|
||||
check(both.alpha.statusDetail:find("Beta", 1, true) ~= nil,
|
||||
"conflict detail names the other mod")
|
||||
eq(both.beta.status, "conflict",
|
||||
"resolveToggle conflict is bidirectional: the target is flagged too")
|
||||
|
||||
-- disable beta: alpha no longer conflicts (nothing enabled to conflict with)
|
||||
local off = byId(LauncherMods.deriveList(manifests, { mods = { beta = false } }))
|
||||
eq(off.alpha.status, "ok", "no conflict once the other side is disabled")
|
||||
eq(off.beta.status, "ok", "a disabled mod is never a conflict")
|
||||
end
|
||||
|
||||
-- ------- warn: unsatisfied game_version range against Version.engine
|
||||
|
||||
do
|
||||
-- a range the -dev engine cannot satisfy (needs a released >=1.0.0)
|
||||
local manifests = {
|
||||
mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua",
|
||||
game_version = ">=1.0.0" }),
|
||||
}
|
||||
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
|
||||
eq(m.future.status, "warn", "engine outside the game_version range warns")
|
||||
check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil,
|
||||
"version warn detail quotes the required range")
|
||||
check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil,
|
||||
"version warn detail quotes the engine version")
|
||||
end
|
||||
|
||||
-- ------- warn: hard dependency missing, disabled, or wrong version
|
||||
|
||||
do
|
||||
local base = { id = "base", name = "Base", version = "1.0.0", entry = "m.lua" }
|
||||
local needsMissing = { id = "needy", name = "Needy", version = "1.0.0",
|
||||
entry = "m.lua", dependencies = { "ghost" } }
|
||||
local m = byId(LauncherMods.deriveList({ mf(needsMissing) }, { mods = {} }))
|
||||
eq(m.needy.status, "warn", "a missing hard dependency warns")
|
||||
check(m.needy.statusDetail:find("not installed", 1, true) ~= nil,
|
||||
"missing-dep detail says not installed")
|
||||
|
||||
-- present but disabled
|
||||
local m2 = byId(LauncherMods.deriveList(
|
||||
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
|
||||
entry = "m.lua", dependencies = { "base" } }) },
|
||||
{ mods = { base = false } }))
|
||||
eq(m2.needy.status, "warn", "a disabled hard dependency warns")
|
||||
check(m2.needy.statusDetail:find("disabled", 1, true) ~= nil,
|
||||
"disabled-dep detail says disabled")
|
||||
|
||||
-- present, enabled, but the version is out of range
|
||||
local m3 = byId(LauncherMods.deriveList(
|
||||
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
|
||||
entry = "m.lua", dependencies = { "base@>=2.0.0" } }) },
|
||||
{ mods = {} }))
|
||||
eq(m3.needy.status, "warn", "a dependency below the required range warns")
|
||||
eq(m3.base.status, "ok", "the satisfied dependency itself stays ok")
|
||||
|
||||
-- the same dep satisfied: needy is ok
|
||||
local m4 = byId(LauncherMods.deriveList(
|
||||
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
|
||||
entry = "m.lua", dependencies = { "base@>=1.0.0" } }) },
|
||||
{ mods = {} }))
|
||||
eq(m4.needy.status, "ok", "a satisfied dependency clears the warn")
|
||||
end
|
||||
|
||||
-- ------- conflict outranks warn when a mod trips both
|
||||
|
||||
do
|
||||
local manifests = {
|
||||
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
|
||||
conflicts = { "beta" }, game_version = ">=1.0.0" }),
|
||||
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
|
||||
}
|
||||
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
|
||||
eq(m.alpha.status, "conflict",
|
||||
"conflict is reported ahead of a version warn on the same mod")
|
||||
end
|
||||
|
||||
-- ------- locateRoot: manifest at the archive root
|
||||
|
||||
do
|
||||
local root, err = LauncherMods.locateRoot({ "manifest.json", "main.lua" })
|
||||
eq(root, "", "a root-level manifest.json resolves to the empty prefix")
|
||||
eq(err, nil, "no error for a root-level manifest")
|
||||
end
|
||||
|
||||
-- ------- locateRoot: manifest inside a single top-level folder
|
||||
|
||||
do
|
||||
local root = LauncherMods.locateRoot({
|
||||
"mymod/manifest.json", "mymod/main.lua", "mymod/assets/x.png" })
|
||||
eq(root, "mymod", "a single wrapping folder resolves to that folder name")
|
||||
end
|
||||
|
||||
-- ------- locateRoot: no manifest anywhere
|
||||
|
||||
do
|
||||
local root, err = LauncherMods.locateRoot({ "readme.txt", "stuff/x.lua" })
|
||||
eq(root, nil, "an archive with no manifest.json resolves to nil")
|
||||
check(err:find("no manifest.json", 1, true) ~= nil,
|
||||
"the no-manifest reason is user-presentable")
|
||||
end
|
||||
|
||||
-- ------- locateRoot: multiple top-level folders is ambiguous
|
||||
|
||||
do
|
||||
local root, err = LauncherMods.locateRoot({
|
||||
"one/manifest.json", "two/manifest.json" })
|
||||
eq(root, nil, "two candidate mod folders resolves to nil")
|
||||
check(err:find("single mod folder", 1, true) ~= nil,
|
||||
"the ambiguous reason asks for a single mod folder")
|
||||
end
|
||||
|
||||
-- ------- locateRoot: a lone folder without a manifest is not a root
|
||||
|
||||
do
|
||||
local root, err = LauncherMods.locateRoot({ "assets/x.png" })
|
||||
eq(root, nil, "a single folder with no manifest is not a mod root")
|
||||
check(err ~= nil, "the no-root case carries a reason")
|
||||
end
|
||||
|
||||
T.finish("launcher_mods")
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Bill's PC vs player's PC top-menu origin/size (#176).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.pokemon and next(Data.pokemon)) then Data:load() end
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BoxMenu = require("src.ui.BoxMenu")
|
||||
local PlayerPC = require("src.ui.PlayerPC")
|
||||
|
||||
local game = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = { push = function() end },
|
||||
}
|
||||
|
||||
local bills = BoxMenu.new(game)
|
||||
local player = PlayerPC.new(game)
|
||||
|
||||
T.eq(bills.tx, 0, "Bill's PC TextBoxBorder at x=0")
|
||||
T.eq(player.tx, 0, "Player's PC TextBoxBorder at x=0")
|
||||
T.eq(player.ty, 0, "Player's PC at y=0")
|
||||
T.eq(player.tw, 16, "Player's PC width (players_pc.asm c=$e +2)")
|
||||
T.eq(player.th, 10, "Player's PC height (players_pc.asm b=$8 +2)")
|
||||
T.check(player.tx == bills.tx and player.ty == bills.ty,
|
||||
"both PC menus share the same top-left origin")
|
||||
local labelTiles = 2 + #"WITHDRAW ITEM"
|
||||
T.check(player.tx + labelTiles <= player.tx + player.tw - 1,
|
||||
"WITHDRAW ITEM fits inside the Player's PC border")
|
||||
|
||||
T.finish("pc_menu_sides")
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Headless regression: two Bill's PC releases in one list session (#171).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local BoxMenu = require("src.ui.BoxMenu")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local realCry, realPlay = Sound.playCry, Sound.play
|
||||
Sound.playCry = function() end
|
||||
Sound.play = function() end
|
||||
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) self.states[#self.states + 1] = s end
|
||||
function stack:pop()
|
||||
local t = self.states[#self.states]
|
||||
self.states[#self.states] = nil
|
||||
return t
|
||||
end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
function stack:update(dt)
|
||||
local t = self:top()
|
||||
if t and t.update then t:update(dt) end
|
||||
end
|
||||
|
||||
local pressed = {}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = stack,
|
||||
input = {
|
||||
wasPressed = function(_, key) return pressed[key] or false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local box = Boxes.active(game.save)
|
||||
box[1] = Pokemon.new(Data, "RATTATA", 5)
|
||||
box[2] = Pokemon.new(Data, "PIDGEY", 6)
|
||||
box[3] = Pokemon.new(Data, "CATERPIE", 4)
|
||||
|
||||
local function press(btn)
|
||||
pressed = { [btn] = true }
|
||||
stack:update(1 / 60)
|
||||
pressed = {}
|
||||
end
|
||||
|
||||
local function topMt() return getmetatable(stack:top()) end
|
||||
local function mash(btn, cond, n)
|
||||
for _ = 1, (n or 400) do
|
||||
if cond() then return true end
|
||||
press(btn)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
stack:push(BoxMenu.new(game))
|
||||
press("down"); press("down"); press("a")
|
||||
T.check(topMt() == ListMenu, "RELEASE opens the box list")
|
||||
T.eq(#box, 3, "box still has 3 before releases")
|
||||
|
||||
local function releaseCurrent()
|
||||
local before = #box
|
||||
press("a")
|
||||
T.check(mash("a", function() return topMt() == ChoiceBox end),
|
||||
"confirm choice opens")
|
||||
press("up") -- defaultNo -> YES
|
||||
press("a")
|
||||
T.check(mash("a", function() return topMt() == ListMenu end),
|
||||
"returns to RELEASE list")
|
||||
T.eq(#box, before - 1, "one mon removed from the box")
|
||||
end
|
||||
|
||||
releaseCurrent()
|
||||
releaseCurrent()
|
||||
T.eq(#box, 1, "two releases leave one mon")
|
||||
T.check(topMt() == ListMenu, "still on RELEASE list after the second")
|
||||
T.eq(box[1].species, "CATERPIE", "remaining mon is the third seeded one")
|
||||
|
||||
Sound.playCry, Sound.play = realCry, realPlay
|
||||
T.finish("pc_release")
|
||||
@@ -0,0 +1,233 @@
|
||||
-- Launcher save Import/Export glue (src/import/SaveFileIO.lua): the end-to-end
|
||||
-- importToSlot -> listSlots roundtrip and the exportActiveSlot output-byte
|
||||
-- sanity check, driven love-free through the same in-memory filesystem stub
|
||||
-- tests/engine/save_slots.lua uses. A synthetic 32KB SRAM image is built via
|
||||
-- GenSave.encode (no real save checked in); a fixture-gated case exercises the
|
||||
-- real .sav when POKEPORT_SAV_FIXTURE points at one.
|
||||
-- luajit tests/engine/save_file_io_tests.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SaveFileIO = require("src.import.SaveFileIO")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
-- A love.filesystem stub keyed by full path, extended past save_slots' memfs
|
||||
-- with the export surface SaveFileIO reaches for (createDirectory /
|
||||
-- getSaveDirectory). A directory key is implied by any file under it.
|
||||
local function memfs(files)
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
createDirectory = function() return true end,
|
||||
getSaveDirectory = function() return "/fake/save" end,
|
||||
}
|
||||
end
|
||||
|
||||
local function fresh()
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
-- ---- crosswalk data + synthetic 32KB save (built the way the codec tests do)
|
||||
|
||||
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
|
||||
local data = {
|
||||
pokemon = loadfile("data/generated/pokemon.lua")(),
|
||||
moves = loadfile("data/generated/moves.lua")(),
|
||||
items = loadfile("data/generated/items.lua")(),
|
||||
maps = loadfile("data/generated/maps.lua")(),
|
||||
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
|
||||
}
|
||||
|
||||
-- independent checksum re-derivation (complement of the additive byte sum) so
|
||||
-- the export sanity check does not trust the encoder that wrote it
|
||||
local bit = require("bit")
|
||||
local OFF = GenSave.OFFSETS
|
||||
local function rawChecksum(bytes, from, to)
|
||||
local sum = 0
|
||||
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
|
||||
return bit.band(bit.bnot(sum), 0xFF)
|
||||
end
|
||||
local function mainChecksumValid(bytes)
|
||||
return rawChecksum(bytes, OFF.checksumStart, OFF.checksumEnd)
|
||||
== bytes:byte(OFF.mainChecksum + 1)
|
||||
end
|
||||
|
||||
local function syntheticSave(name)
|
||||
local seed = SaveData.newGame({ playerName = name, rivalName = "BLUE" })
|
||||
seed.money = 4321
|
||||
seed.inventory = { POTION = 2, POKE_BALL = 7, BOULDERBADGE = 1 }
|
||||
seed.bagOrder = { "POTION", "POKE_BALL" }
|
||||
seed.party = { {
|
||||
species = "SQUIRTLE", level = 6, exp = 200,
|
||||
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
|
||||
hp = 22, status = nil,
|
||||
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
|
||||
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
|
||||
} }
|
||||
return GenSave.encode(seed, data, nil)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- importToSlot -> listSlots
|
||||
|
||||
do
|
||||
fresh()
|
||||
local bytes = syntheticSave("IMP")
|
||||
eq(#bytes, GenSave.SAVE_SIZE, "the synthetic save is 32768 bytes")
|
||||
|
||||
local ok, slotId = SaveFileIO.importToSlot(bytes, "red")
|
||||
eq(ok, true, "importToSlot succeeds on a valid 32KB save")
|
||||
eq(slotId, "slot1", "the first import registers slot1")
|
||||
|
||||
local slots = SaveData.listSlots("red")
|
||||
eq(#slots, 1, "the imported save shows up as exactly one slot")
|
||||
eq(slots[1].id, "slot1", "the listed slot is slot1")
|
||||
eq(slots[1].exists, true, "the imported slot reports a save present")
|
||||
eq(slots[1].name, "IMP", "the imported slot surfaces the decoded player name")
|
||||
eq(SaveData.activeSlot("red"), "slot1", "the imported slot is made active")
|
||||
|
||||
-- the slot loads cleanly (meta re-stamped from gen1_import to the numeric
|
||||
-- format, so runMigrations does not choke)
|
||||
local loaded = SaveData.load("red")
|
||||
check(loaded ~= nil, "the imported slot loads back")
|
||||
eq(loaded and loaded.player.name, "IMP", "loaded save keeps the player name")
|
||||
eq(loaded and loaded.money, 4321, "loaded save keeps the money")
|
||||
eq(loaded and #loaded.party, 1, "loaded save keeps the party")
|
||||
|
||||
-- a second import allocates a fresh slot and makes it active
|
||||
local ok2, slot2 = SaveFileIO.importToSlot(syntheticSave("TWO"), "red")
|
||||
eq(ok2, true, "a second import succeeds")
|
||||
eq(slot2, "slot2", "the second import allocates slot2")
|
||||
eq(#SaveData.listSlots("red"), 2, "both imported slots are listed")
|
||||
eq(SaveData.activeSlot("red"), "slot2", "the newest import becomes active")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- exportActiveSlot byte sanity
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveFileIO.importToSlot(syntheticSave("EXP"), "red")
|
||||
|
||||
local ok, path = SaveFileIO.exportActiveSlot("red")
|
||||
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
|
||||
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
|
||||
"the export path is absolute and names the version + slot")
|
||||
|
||||
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
|
||||
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
|
||||
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
|
||||
check(outBytes and mainChecksumValid(outBytes),
|
||||
"the export carries a valid main-data checksum")
|
||||
|
||||
-- the export re-imports to an equivalent save
|
||||
local re = SaveConvert.importSav(outBytes, "red")
|
||||
check(re ~= nil, "the export re-imports through SaveConvert")
|
||||
eq(re and re.player and re.player.name, "EXP", "the export round-trips the player name")
|
||||
eq(re and re.party[1] and re.party[1].species, "SQUIRTLE",
|
||||
"the export round-trips the party")
|
||||
eq(re and re.inventory and re.inventory.BOULDERBADGE, 1,
|
||||
"the export round-trips a badge")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- failure UX (never raises)
|
||||
|
||||
do
|
||||
fresh()
|
||||
-- wrong size via a DroppedFile-shaped source (100 bytes)
|
||||
local shortFile = {
|
||||
_bytes = string.rep("\0", 100),
|
||||
open = function() return true end,
|
||||
getSize = function(self) return #self._bytes end,
|
||||
read = function(self) return self._bytes end,
|
||||
close = function() return true end,
|
||||
}
|
||||
local ok, err = SaveFileIO.importToSlot(shortFile, "red")
|
||||
eq(ok, false, "a wrong-size save is rejected, not imported")
|
||||
check(type(err) == "string" and err:find("32", 1, true) ~= nil,
|
||||
"the wrong-size error names the required size")
|
||||
eq(#SaveData.listSlots("red"), 0, "a rejected import creates no slot")
|
||||
|
||||
-- bad checksum: flip a modeled byte in an otherwise valid image
|
||||
local good = syntheticSave("BAD")
|
||||
local corrupt = good:sub(1, OFF.money)
|
||||
.. string.char((good:byte(OFF.money + 1) + 1) % 256)
|
||||
.. good:sub(OFF.money + 2)
|
||||
local okc, errc = SaveFileIO.importToSlot(corrupt, "red")
|
||||
eq(okc, false, "a bad-checksum save is rejected")
|
||||
check(type(errc) == "string" and errc:find("checksum", 1, true) ~= nil,
|
||||
"the bad-checksum error mentions the checksum")
|
||||
eq(#SaveData.listSlots("red"), 0, "a rejected checksum creates no slot")
|
||||
|
||||
-- export with nothing to export
|
||||
local oke, erre = SaveFileIO.exportActiveSlot("red")
|
||||
eq(oke, false, "exportActiveSlot fails cleanly when there is no save")
|
||||
check(type(erre) == "string", "the empty-export failure carries a message")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- fixture-gated real save
|
||||
|
||||
do
|
||||
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
|
||||
local fixtureBytes
|
||||
if fixturePath then
|
||||
local ff = io.open(fixturePath, "rb")
|
||||
if ff then
|
||||
fixtureBytes = ff:read("*a")
|
||||
ff:close()
|
||||
if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end
|
||||
end
|
||||
end
|
||||
if not fixtureBytes then
|
||||
print("save_file_io fixture case skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav)")
|
||||
else
|
||||
local files = fresh()
|
||||
local ok, slotId = SaveFileIO.importToSlot(fixtureBytes, "red")
|
||||
eq(ok, true, "fixture: a real .sav imports to a slot")
|
||||
check(slotId ~= nil, "fixture: the import returns a slot id")
|
||||
|
||||
local slots = SaveData.listSlots("red")
|
||||
eq(#slots, 1, "fixture: the real save shows as one slot")
|
||||
check(slots[1].exists and type(slots[1].name) == "string" and #slots[1].name > 0,
|
||||
"fixture: the imported slot has a non-empty player name")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
check(loaded ~= nil and #loaded.party >= 1 and #loaded.party <= 6,
|
||||
"fixture: the imported slot loads with a 1..6 party")
|
||||
|
||||
local eok, path = SaveFileIO.exportActiveSlot("red")
|
||||
eq(eok, true, "fixture: the imported real save exports")
|
||||
local rel = path:gsub("^/fake/save/", "")
|
||||
local outBytes = files[rel]
|
||||
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "fixture: the export is 32768 bytes")
|
||||
check(outBytes and mainChecksumValid(outBytes),
|
||||
"fixture: the export has a valid main-data checksum")
|
||||
end
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
|
||||
T.finish("save_file_io")
|
||||
@@ -0,0 +1,211 @@
|
||||
-- Save-slot backend (src/core/SaveData.lua): legacy migration, the slot
|
||||
-- registry in options.lua, listSlots/setActiveSlot/createSlot, and the
|
||||
-- active-slot resolution behind saveNames/save/load. Self-contained: it
|
||||
-- installs the love stub only for a swappable in-memory filesystem, the
|
||||
-- same way tests/mod_save_tests isolates its save round-trips.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
-- an isolated love.filesystem: keys are full paths, so "saves/red/slot1.lua"
|
||||
-- needs no directory support (createDirectory is deliberately absent, which
|
||||
-- is exactly what the ensureParentDir no-op path handles)
|
||||
local function memfs(files)
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- a fresh filesystem + cleared process globals: each scenario is a first boot
|
||||
local function fresh()
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
-- a minimal but fully decodable Red save
|
||||
local function legacySave(name, dexOwned, badges, playTime)
|
||||
local owned = {}
|
||||
for _, id in ipairs(dexOwned or {}) do owned[id] = true end
|
||||
local inv = {}
|
||||
for _, id in ipairs(badges or {}) do inv[id] = true end
|
||||
return {
|
||||
version = "red",
|
||||
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
|
||||
pokedex = { seen = {}, owned = owned },
|
||||
inventory = inv,
|
||||
playTime = playTime or 0,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- slotSummary (pure)
|
||||
|
||||
do
|
||||
local name, meta = SaveData.slotSummary(
|
||||
legacySave("ASH", { "PIKACHU", "PIDGEY", "RATTATA" },
|
||||
{ "BOULDERBADGE", "CASCADEBADGE" }, 3661))
|
||||
T.eq(name, "ASH", "slotSummary reads the player name")
|
||||
T.eq(meta.dexCount, 3, "slotSummary counts owned dex entries")
|
||||
T.eq(meta.badges, 2, "slotSummary counts vanilla badges from inventory")
|
||||
T.eq(meta.timeText, "1:01", "slotSummary formats playTime as H:MM")
|
||||
|
||||
local n2, m2 = SaveData.slotSummary(nil)
|
||||
T.eq(n2, nil, "slotSummary of an empty slot has no name")
|
||||
T.eq(m2, nil, "slotSummary of an empty slot has no meta")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- legacy migration happy path
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
files["save.lua"] = SaveSerializer.encode(
|
||||
legacySave("RED", { "BULBASAUR", "CHARMANDER" }, { "BOULDERBADGE" }, 7325))
|
||||
|
||||
local slots = SaveData.listSlots("red")
|
||||
T.eq(#slots, 1, "legacy save migrates into exactly one slot")
|
||||
T.eq(slots[1].id, "slot1", "the migrated slot is slot1")
|
||||
T.eq(slots[1].exists, true, "the migrated slot reports a save present")
|
||||
T.eq(slots[1].name, "RED", "the migrated slot surfaces the player name")
|
||||
T.eq(slots[1].meta.badges, 1, "migrated slot meta carries the badge count")
|
||||
T.eq(slots[1].meta.dexCount, 2, "migrated slot meta carries the dex count")
|
||||
T.eq(slots[1].meta.timeText, "2:02", "migrated slot meta carries the time")
|
||||
|
||||
T.eq(files["save.lua"], nil, "the flat legacy file is removed after migration")
|
||||
T.check(files["saves/red/slot1.lua"] ~= nil, "the slot file now holds the save")
|
||||
|
||||
local opts = SaveSerializer.decode(files["options.lua"])
|
||||
T.eq(opts.saveSlots.red.active, "slot1", "options registers slot1 as active")
|
||||
T.eq(opts.saveSlots.red.list[1], "slot1", "options lists the migrated slot")
|
||||
|
||||
-- load() now resolves the active slot and reads the migrated save
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(loaded and loaded.player.name == "RED", "load reads the active slot")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- migration idempotence
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
files["save.lua"] = SaveSerializer.encode(legacySave("ONCE", { "MEW" }, {}, 0))
|
||||
SaveData.listSlots("red") -- first boot: migrates
|
||||
local slotBytes = files["saves/red/slot1.lua"]
|
||||
|
||||
-- a second boot: registry exists, no flat file, so nothing re-migrates
|
||||
SaveData.resetSlotState()
|
||||
local slots = SaveData.listSlots("red")
|
||||
T.eq(#slots, 1, "a re-boot does not duplicate the migrated slot")
|
||||
T.eq(files["save.lua"], nil, "no flat file reappears on re-boot")
|
||||
T.eq(files["saves/red/slot1.lua"], slotBytes, "the slot bytes are untouched")
|
||||
local opts = SaveSerializer.decode(files["options.lua"])
|
||||
T.eq(#opts.saveSlots.red.list, 1, "the registry still lists exactly one slot")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- mixed real / empty slots
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
files["save.lua"] = SaveSerializer.encode(
|
||||
legacySave("REAL", { "EEVEE" }, { "BOULDERBADGE" }, 60))
|
||||
SaveData.listSlots("red") -- slot1 = the migrated real save
|
||||
local empty = SaveData.createSlot("red")
|
||||
T.eq(empty, "slot2", "createSlot allocates slot2 alongside the migrated slot1")
|
||||
|
||||
local slots = SaveData.listSlots("red")
|
||||
T.eq(#slots, 2, "both the real and empty slots are listed")
|
||||
T.eq(slots[1].exists, true, "the migrated slot still reports a save")
|
||||
T.eq(slots[1].name, "REAL", "the real slot keeps its name")
|
||||
T.eq(slots[2].exists, false, "the freshly created slot is empty")
|
||||
T.eq(slots[2].name, nil, "an empty slot has no name")
|
||||
T.eq(slots[2].meta, nil, "an empty slot has no meta")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- setActiveSlot persistence
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveData.createSlot("red") -- slot1
|
||||
SaveData.createSlot("red") -- slot2
|
||||
SaveData.setActiveSlot("red", "slot2")
|
||||
|
||||
local opts = SaveSerializer.decode(files["options.lua"])
|
||||
T.eq(opts.saveSlots.red.active, "slot2", "setActiveSlot persists the active id")
|
||||
T.eq(opts.saveSlots.red.list[1], "slot1", "the slot list is preserved")
|
||||
T.eq(opts.saveSlots.red.list[2], "slot2", "the target slot stays in the list")
|
||||
|
||||
-- selecting a slot that was never registered adds it
|
||||
SaveData.setActiveSlot("red", "slot7")
|
||||
opts = SaveSerializer.decode(files["options.lua"])
|
||||
T.eq(opts.saveSlots.red.active, "slot7", "an unregistered active slot is added")
|
||||
T.eq(opts.saveSlots.red.list[3], "slot7", "the added slot lands in the list")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- createSlot id allocation
|
||||
|
||||
do
|
||||
fresh()
|
||||
T.eq(SaveData.createSlot("red"), "slot1", "first slot is slot1")
|
||||
T.eq(SaveData.createSlot("red"), "slot2", "second slot is slot2")
|
||||
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- saveNames follows the slot
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveData.createSlot("red") -- slot1
|
||||
SaveData.createSlot("red") -- slot2
|
||||
SaveData.setActiveSlot("red", "slot2")
|
||||
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "SLOT2"
|
||||
T.check(SaveData.save(save), "save writes to the active slot")
|
||||
T.check(files["saves/red/slot2.lua"] ~= nil, "bytes land in slot2's file")
|
||||
T.eq(files["saves/red/slot1.lua"], nil, "slot1 is untouched by a slot2 save")
|
||||
T.eq(files["save.lua"], nil, "no flat file is written once a slot is active")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- a version with no slots
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
local slots = SaveData.listSlots("red")
|
||||
T.eq(#slots, 0, "a fresh install with no legacy save lists no slots")
|
||||
|
||||
-- with nothing registered, save/load use the flat legacy path, exactly
|
||||
-- as they did before slots existed
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "FLAT"
|
||||
T.check(SaveData.save(save), "a slotless version saves to the flat file")
|
||||
T.check(files["save.lua"] ~= nil, "the flat save.lua is written")
|
||||
T.eq(files["saves/red/slot1.lua"], nil, "no slot directory is created")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(loaded and loaded.player.name == "FLAT", "load reads the flat file")
|
||||
|
||||
T.eq(SaveData.saveFilename("red"), "save.lua",
|
||||
"saveFilename still resolves the flat name with no slot in use")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
|
||||
T.finish("save_slots")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user