Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 803f86d5b3 | |||
| 043f6b2426 | |||
| f7695308b7 | |||
| a8936e79d6 | |||
| f6b29e6caa |
@@ -25,7 +25,9 @@ body:
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Both
|
||||
- Linux
|
||||
- Android
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -38,6 +40,17 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
name: Feature request
|
||||
description: Ask for something new, or a change to how something already works.
|
||||
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this for **engine / launcher / platform** work (ports, video options, save
|
||||
tooling, networking, mod API seams, docs).
|
||||
|
||||
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||
Gen 2-like battle toggles, soundtrack packs — open a
|
||||
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
|
||||
instead.
|
||||
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
and how you picture it working. A request with real detail is one someone can
|
||||
actually build without guessing at what you meant.
|
||||
and how you picture it working.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live in the game, what does the
|
||||
player see or do. If it changes something that already exists, say what it does today
|
||||
and what it should do instead.
|
||||
Describe it properly. What is it, where does it live (launcher, options,
|
||||
engine), what does the player see or do. If it changes something that already
|
||||
exists, say what it does today and what it should do instead.
|
||||
placeholder: |
|
||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||
foot and the player moves at bike speed. Release and you go back to walking.
|
||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
|
||||
with the same save folder layout and mod discovery path.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -41,9 +59,8 @@ body:
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||
without making the game easier.
|
||||
LÖVE already runs on Linux; without a packaged build, players have to assemble
|
||||
it themselves and miss release notes / update checks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -52,14 +69,12 @@ body:
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
||||
The specifics. Which menu, what happens in the edge cases. If you don't
|
||||
know, say what you'd expect as a player and leave the rest open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld to run
|
||||
- Same speed as the bike
|
||||
- Disabled anywhere the bike is disabled
|
||||
- Sprite should probably need a running animation, not sure what that takes
|
||||
- No effect on encounter rates
|
||||
- GitHub Releases asset next to the .dmg / .exe
|
||||
- Same options.lua / mods/ layout as desktop
|
||||
- Documented in the README install section
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
name: Mod request
|
||||
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
|
||||
labels: ["mod request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This tracker is for ideas that should ship as **mods**, not as core engine
|
||||
features — alternate sprites, running shoes, day/night, shiny indicators,
|
||||
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||
|
||||
The engine already exposes a lot of this through registries and hooks
|
||||
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
|
||||
Use a **Feature request** instead for launcher / ports / video options /
|
||||
networking / save tooling / new API seams.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What the mod should do, in a sentence.
|
||||
placeholder: Hold B to run at bike speed on the overworld
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers or mod authors can ping you on Discord if they pick this up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What should the mod do
|
||||
description: >
|
||||
Describe the player-facing behavior. What changes, where, what does the
|
||||
player see or press. If it toggles from Options or a START-menu entry, say so.
|
||||
placeholder: |
|
||||
Hold B while walking outdoors to move at bike speed. Release to walk again.
|
||||
Same places the bike is allowed; no effect in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing as a mod
|
||||
description: >
|
||||
Why optional/modded rather than a core option. Who wants it on, who wants
|
||||
vanilla left alone.
|
||||
placeholder: |
|
||||
Great for replaying and backtracking, but some people want a strict Gen 1
|
||||
pace. A mod (or an opt-in mod option) keeps both camps happy.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
|
||||
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
|
||||
mention it — otherwise leave it open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld
|
||||
- Same step timing as the bike
|
||||
- Disabled where the bike is disabled
|
||||
- Prefer hooks:wrap("movement.speed") if that still fits
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: vanilla
|
||||
attributes:
|
||||
label: With the mod off, is vanilla unchanged
|
||||
options:
|
||||
- Yes — parity when disabled
|
||||
- No — it would replace something always-on
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Best-fit mod category
|
||||
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
|
||||
options:
|
||||
- GAMEPLAY / QoL
|
||||
- GRAPHICS
|
||||
- AUDIO
|
||||
- BALANCE / ruleset
|
||||
- CONTENT (maps, encounters, trainers)
|
||||
- UI / TOOL
|
||||
- TOTAL_CONVERSION-ish
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, other games/hacks that do it, related issues, or
|
||||
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -14,7 +14,9 @@ name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Integration branch + release branch. PRs already run via pull_request
|
||||
# (any base); this list is only for post-merge push runs.
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
|
||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||
|
||||
@@ -10,6 +10,9 @@ name: Release
|
||||
# - To force a specific version, either:
|
||||
# * run this workflow manually (Actions tab) and type it into "version", or
|
||||
# * put "[release X.Y.Z]" anywhere in the commit message.
|
||||
#
|
||||
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
|
||||
# so promoting `dev` -> `main` is the ship gate that cuts a build.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# On-screen touch control art
|
||||
|
||||
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
|
||||
Those Awesome Guys), public domain under CC0:
|
||||
https://thoseawesomeguys.com/prompts/
|
||||
|
||||
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
|
||||
d-pad (neutral + per-direction highlight), A, B, plus (START) and
|
||||
minus (SELECT).
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -47,8 +47,9 @@ function love.conf(t)
|
||||
-- freely to portrait or landscape), otherwise locked to the window's w/h
|
||||
-- aspect. So a non-resizable tall window forced portrait; resizable lets
|
||||
-- the game follow the device. The renderer letterboxes the 160x144
|
||||
-- viewport into whatever size results, and touch input is gesture-based,
|
||||
-- so both orientations just work. iOS follows the Info.plist orientations
|
||||
-- viewport into whatever size results, and the on-screen touch controls
|
||||
-- re-lay themselves out from the new window size, so both orientations
|
||||
-- just work. iOS follows the Info.plist orientations
|
||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||
t.window.resizable = true
|
||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||
-- OaksLabRivalTakePokeBallScript.
|
||||
-- * Rival (object 1): before starter -> "gramps isn't around"; with
|
||||
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||
-- has walked you in, else "gramps isn't around" (#218); with
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
||||
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
||||
@@ -162,36 +163,45 @@ return {
|
||||
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
|
||||
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
|
||||
|
||||
-- Talking to the rival only ever prints a line: the lab battle is a
|
||||
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
|
||||
-- see onStep below), never a talk action. The handler used to fall
|
||||
-- through from the "looks stronger" taunt straight into start_battle,
|
||||
-- so talking to Blue at the table launched the rival fight before the
|
||||
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
|
||||
-- OaksLabText8 branches text only:
|
||||
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
|
||||
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
|
||||
-- line; normally unreachable since the rival is hidden after the
|
||||
-- lab battle in OaksLabRivalEndBattleScript)
|
||||
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||
{ "jump_if_false", 21 }, -- 3
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
||||
{ "jump_if_true", 19 }, -- 5
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||
{ "jump_if_false", 11 }, -- 8
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
|
||||
{ "jump", 16 }, -- 10
|
||||
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
|
||||
{ "jump_if_false", 15 }, -- 12
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
||||
{ "jump", 16 }, -- 14
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
||||
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
|
||||
{ "heal_party" }, -- 16
|
||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
|
||||
{ "jump", 23 }, -- 18
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
|
||||
{ "jump", "end" }, -- 22
|
||||
-- win: sulk text then exit; loss: Rival1WinText already played in
|
||||
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
|
||||
{ "jump_if_false", 25 }, -- 23
|
||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
|
||||
{ "move_npc_to", 1, 4, 11 }, -- 25
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_false", "pre_starter" },
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||
{ "jump_if_true", "after_battle" },
|
||||
-- has a starter, has not fought yet: just the taunt. The battle is
|
||||
-- the coordinate trigger in onStep, not this talk (#219)
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "after_battle" },
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
|
||||
-- Oak has already escorted you in (three balls on the table) -> he
|
||||
-- waves you on to choose; not yet escorted (very early game) -> the
|
||||
-- "Gramps isn't around" line. #218
|
||||
{ "label", "pre_starter" },
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
|
||||
{ "jump_if_false", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -213,9 +223,16 @@ return {
|
||||
-- OaksLabScript8 / OaksLabRivalChallenge)
|
||||
onStep = function(game, ow, x, y)
|
||||
local flags = game.save.flags
|
||||
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
|
||||
-- Oak stops you leaving without a starter at the bookshelf row (cell
|
||||
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
|
||||
-- challenge just below uses (scripts/OaksLab.asm, both gated on
|
||||
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
|
||||
-- corridor length later on the exit mat. At y>=6 only the x=4,5
|
||||
-- corridor is walkable and the up-push keeps the player above y=7, so
|
||||
-- this only ever fires at y=6 in the corridor -- matching the rival
|
||||
-- trigger's shape. (#232)
|
||||
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
||||
and y == 11 and (x == 4 or x == 5) then
|
||||
and y >= 6 then
|
||||
ow.runner:run({
|
||||
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
||||
{ "move_player", "up", 1 },
|
||||
@@ -254,10 +271,18 @@ return {
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
|
||||
-- already shown in-battle via Rival1WinText)
|
||||
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
|
||||
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
|
||||
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
|
||||
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
|
||||
-- marches out. A loss skips only the gloat (that taunt was already
|
||||
-- shown in-battle via Rival1WinText), never the exit line (#231). The
|
||||
-- jump_if_false convergence point is the exit line: base+6 indexes the
|
||||
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
|
||||
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
|
||||
@@ -395,7 +395,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
|
||||
-- MrFujisHouse.asm; the teleport back to his house is a warp)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- scripts/PokemonTower7F.asm: after each Rocket loses,
|
||||
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
|
||||
-- prints its AfterBattle text (DisplayTextID), then
|
||||
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
|
||||
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
|
||||
-- (HideObject). Without this the beaten grunts stood in the corridor
|
||||
-- forever (#200).
|
||||
local POKEMON_TOWER_7F_ROCKETS = {
|
||||
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
|
||||
after = "_PokemonTower7FRocket1AfterBattleText" },
|
||||
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
|
||||
after = "_PokemonTower7FRocket2AfterBattleText" },
|
||||
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
|
||||
after = "_PokemonTower7FRocket3AfterBattleText" },
|
||||
}
|
||||
|
||||
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
|
||||
-- at the engagement (both the talk and the sight walk-up leave the player on
|
||||
-- the tile the vanilla table indexes). Each list is that entry's
|
||||
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
|
||||
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
|
||||
local POKEMON_TOWER_7F_EXITS = {
|
||||
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
|
||||
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
|
||||
["10,11"] = { "down", "right", "down", "down", "down", "down" },
|
||||
["11,11"] = { "down", "down", "down", "down", "down" },
|
||||
["12,11"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
[2] = { -- ROCKET2 @ (12,9), faces LEFT
|
||||
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
|
||||
["11,9"] = { "down", "down", "down", "left", "down", "down" },
|
||||
["10,9"] = { "down", "down", "down", "down", "down" },
|
||||
["9,9"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
|
||||
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
|
||||
["10,7"] = { "down", "down", "down", "down", "down" },
|
||||
["11,7"] = { "down", "down", "down", "down", "down" },
|
||||
["12,7"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
}
|
||||
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
|
||||
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
|
||||
-- side) safe -- it still clears the corridor and despawns the grunt.
|
||||
local POKEMON_TOWER_7F_EXIT_FALLBACK =
|
||||
{ "down", "down", "down", "down", "down" }
|
||||
|
||||
M.POKEMON_TOWER_7F = {
|
||||
-- Repair saves that beat a grunt before the exit walk was ported: in
|
||||
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
|
||||
-- still gone on re-entry. hide_object below writes objectToggles for new
|
||||
-- wins; this hides any grunt whose BEAT flag is already set.
|
||||
onEnter = function(game, ow)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||
if game.save.flags[r.beat] then
|
||||
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
|
||||
end
|
||||
end
|
||||
end,
|
||||
-- runVictoryHook fires after every trainer win on the map (both the sight
|
||||
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
|
||||
-- this walks off whichever grunt was just beaten but is still standing.
|
||||
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
|
||||
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
|
||||
-- walk, despawn.
|
||||
onVictory = function(game, ow)
|
||||
if ow.runner:isRunning() then return end
|
||||
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||
local npc = ow:npcByIndex(r.index)
|
||||
if npc and game.save.flags[r.beat] then
|
||||
local key = ow.player.cellX .. "," .. ow.player.cellY
|
||||
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
|
||||
or POKEMON_TOWER_7F_EXIT_FALLBACK
|
||||
ow.runner:run({
|
||||
{ "show_text", r.after }, -- DisplayTextID
|
||||
{ "walk_npc", r.index, dirs }, -- MoveSprite
|
||||
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
|
||||
}, { npc = npc })
|
||||
return
|
||||
end
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONTOWER7F_MR_FUJI = {
|
||||
{ "face_player" }, -- 1
|
||||
|
||||
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
|
||||
}
|
||||
|
||||
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
|
||||
-- Y50, once; declining at the rope walks you back out.
|
||||
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
|
||||
-- the exhibit rope they crossed heading north (#151); the museum floor has no
|
||||
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
@@ -563,7 +565,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -524,8 +524,17 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
hideRocket()
|
||||
done()
|
||||
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||
-- he leaves the floor rather than popping out of existence on
|
||||
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||
-- the step lands.
|
||||
ow:scriptMove(npc, "up", 1, function()
|
||||
hideRocket()
|
||||
done()
|
||||
end)
|
||||
end))
|
||||
end)
|
||||
end,
|
||||
@@ -606,40 +615,62 @@ local function activePrizes()
|
||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||
end
|
||||
|
||||
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
|
||||
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
|
||||
-- with no case prints RequireCoinCaseText and returns without ever opening a
|
||||
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
|
||||
-- show the prizes. #194: the port used to open the window unconditionally and
|
||||
-- skip both text boxes.
|
||||
local function prizeCounter(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items, { label = label, right = tostring(p.cost), value = p })
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
return
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
return
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
end
|
||||
|
||||
M.GAME_CORNER_PRIZE_ROOM = {
|
||||
|
||||
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
|
||||
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
|
||||
-- is ever hidden (Gen1 removes just the chosen ball).
|
||||
local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
return function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
local flags = game.save.flags
|
||||
-- Already took one prize: the OTHER ball is still on the mat, so
|
||||
-- talking to it now gives the "Better not get greedy..." refusal
|
||||
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
|
||||
-- not a silent no-op (#197).
|
||||
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
|
||||
done()
|
||||
push(game, t._FightingDojoBetterNotGetGreedyText
|
||||
or "Better not get\ngreedy...", done)
|
||||
return
|
||||
end
|
||||
if not flags.EVENT_BEAT_KARATE_MASTER then
|
||||
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
end
|
||||
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
|
||||
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
|
||||
"_FightingDojoHitmonchanPokeBallText"),
|
||||
},
|
||||
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
|
||||
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
|
||||
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
|
||||
-- bg_events (signs = {}), so wire the poster read here, the same
|
||||
-- facing-up + coord shape the mansion switches use. Reachable only once
|
||||
-- a claimed prize frees the ball cell below the poster (#197).
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if ow.player.facing ~= "up" then return false end
|
||||
if fy == 0 and (fx == 4 or fx == 5) then
|
||||
push(game, text(game)._EnemiesOnEverySideText
|
||||
or "Enemies on every\nside!")
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
|
||||
return false
|
||||
end
|
||||
|
||||
-- coordinate block: show a line and push the player back one step
|
||||
-- coordinate block: show a line and shove the player back one step. pokered
|
||||
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
|
||||
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
|
||||
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
|
||||
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
|
||||
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
|
||||
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
|
||||
local function stepGate(opts)
|
||||
return function(game, ow, x, y)
|
||||
if not inCoords(opts.coords, x, y) then return false end
|
||||
if not opts.blocked(game) then return false end
|
||||
require("src.core.Sound").play(game.data, "Denied")
|
||||
push(game, text(game)[opts.text] or opts.fallback,
|
||||
function() ow:scriptMove(ow.player, opts.push, 1) end)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
if not ow:checkLedgeHop(opts.push) then
|
||||
ow:scriptMove(ow.player, opts.push, 1)
|
||||
end
|
||||
end)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,9 +114,12 @@ return {
|
||||
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
||||
-- only him (data/maps/objects/FightingDojo.asm).
|
||||
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
|
||||
-- concede + prize offer he speaks after the win (his header supplies the
|
||||
-- "Hwa! Arrgh! Beaten!" won line first; #197).
|
||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
|
||||
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
|
||||
|
||||
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
||||
-- the flags make the Hall of Fame checkable)
|
||||
|
||||
@@ -5,19 +5,39 @@ 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
|
||||
## Android multi-ROM / mod / save 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.
|
||||
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
|
||||
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
|
||||
save directory as:
|
||||
|
||||
| `kind` | Destination |
|
||||
| --- | --- |
|
||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||
| `"mod"` | `picked_mod.zip` (open) |
|
||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||
|
||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
|
||||
for the launcher to acknowledge on refocus.
|
||||
|
||||
`RomImporter` then imports on refocus / Choose:
|
||||
|
||||
- **ROMs** 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).
|
||||
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
|
||||
other `.zip` at the save-dir root (USB copy).
|
||||
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
|
||||
other `.sav` at the save-dir root.
|
||||
|
||||
After a successful import the consumed save-dir file 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.
|
||||
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
|
||||
same system picker and install the chosen archive on return.
|
||||
|
||||
## Tab structure
|
||||
|
||||
@@ -78,6 +98,10 @@ The launcher-facing API:
|
||||
- `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.
|
||||
- `SaveData.deleteSlot(version, slotId)` removes the slot's
|
||||
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
|
||||
points active at another remaining slot (or clears active when the list is
|
||||
empty). The launcher's SAVE SLOT panel Delete control calls this.
|
||||
|
||||
## Launcher mod manager
|
||||
|
||||
@@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
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.
|
||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
@@ -122,9 +149,10 @@ 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),
|
||||
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
|
||||
relative name, 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`).
|
||||
@@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
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)`).
|
||||
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
On Android the bytes are also staged as `pending_export.sav` and
|
||||
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||
makes focus show "Save exported."
|
||||
- **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
|
||||
|
||||
@@ -179,4 +179,18 @@ Into-battle wipes still run the original eight styles inside the classic
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||
center) over the frame, using Xelu's CC0 controller prompts
|
||||
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||
finger does, sliding on the d-pad changes direction without lifting, and
|
||||
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||
appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
@@ -18,6 +18,11 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- bot or screenshot run is not at the mercy of the player's last choice.
|
||||
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
||||
|
||||
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
|
||||
-- mouse stand in for a finger, so the overlay can be exercised on desktop
|
||||
-- (see src/core/TouchControls.lua).
|
||||
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
|
||||
|
||||
-- How many times to run a scripted act+step loop per rendered frame. Only
|
||||
-- scripted runs use this; interactive play fast-forwards through
|
||||
-- Game.speedOverride / the GAME SPEED option instead.
|
||||
@@ -274,6 +279,9 @@ function love.mousepressed(x, y, button)
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchpressed("mouse", x, y)
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousereleased(x, y, button)
|
||||
@@ -281,6 +289,16 @@ function love.mousereleased(x, y, button)
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchreleased("mouse", x, y)
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y)
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
end
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
|
||||
@@ -43,11 +43,11 @@ 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`.
|
||||
|
||||
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
|
||||
ROM / mod / save import on Android uses `love.system.pickFile([kind])` →
|
||||
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
|
||||
chosen file under the app save directory as `picked_rom.gb`,
|
||||
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
|
||||
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||
itself remains data-free (no embedded ROM or generated cache).
|
||||
|
||||
### SDK / NDK
|
||||
|
||||
@@ -18,4 +18,4 @@ android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
app.name=Pokemon Red
|
||||
app.name=gen1recomp
|
||||
|
||||
@@ -183,13 +183,37 @@ void vibrate(double seconds)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
bool showFilePicker()
|
||||
bool showFilePicker(const char *destFilename)
|
||||
{
|
||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||
destFilename = "picked_rom.gb";
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(destFilename);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool showCreateDocument(const char *suggestedName)
|
||||
{
|
||||
if (suggestedName == nullptr || suggestedName[0] == '\0')
|
||||
suggestedName = "export.sav";
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||
"(Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(suggestedName);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
|
||||
@@ -63,9 +63,16 @@ void vibrate(double seconds);
|
||||
* Shows the system's "pick a document" UI (Storage Access Framework).
|
||||
* Returns true if the picker was launched; the picked file (if any) is
|
||||
* copied asynchronously by GameActivity.onActivityResult into the app's
|
||||
* external save directory, not returned here -- see src/import/RomImporter.lua.
|
||||
* external save directory under destFilename (default picked_rom.gb), not
|
||||
* returned here -- see src/import/RomImporter.lua.
|
||||
**/
|
||||
bool showFilePicker();
|
||||
bool showFilePicker(const char *destFilename = nullptr);
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
|
||||
* to a user-chosen location. suggestedName is the dialog default filename.
|
||||
**/
|
||||
bool showCreateDocument(const char *suggestedName = nullptr);
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
#include "common/config.h"
|
||||
#include "System.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#if defined(LOVE_MACOSX)
|
||||
#include <CoreServices/CoreServices.h>
|
||||
#elif defined(LOVE_IOS)
|
||||
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::pickFile() const
|
||||
bool System::pickFile(const char *kind) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::showFilePicker();
|
||||
const char *dest = "picked_rom.gb";
|
||||
if (kind != nullptr)
|
||||
{
|
||||
if (strcmp(kind, "mod") == 0)
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
#else
|
||||
LOVE_UNUSED(kind);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::showCreateDocument(suggestedName);
|
||||
#else
|
||||
LOVE_UNUSED(suggestedName);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -111,9 +111,21 @@ public:
|
||||
* Android only for now; the result (if any) is not returned here -- see
|
||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||
*
|
||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile() const;
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
|
||||
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
|
||||
*
|
||||
* @param suggestedName Default filename shown in the dialog.
|
||||
* @return Whether the create dialog was shown.
|
||||
**/
|
||||
virtual bool createFile(const char *suggestedName = nullptr) const;
|
||||
|
||||
/**
|
||||
* Gets if the user is playing music on background.
|
||||
|
||||
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
|
||||
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
luax_pushboolean(L, instance()->pickFile());
|
||||
const char *kind = luaL_optstring(L, 1, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
luax_pushboolean(L, instance()->createFile(suggested));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -22,12 +22,15 @@ package org.love2d.android;
|
||||
|
||||
import org.libsdl.app.SDLActivity;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
|
||||
protected final int[] recordAudioRequestDummy = new int[1];
|
||||
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||
public static final int ROM_PICKER_REQUEST_CODE = 4;
|
||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
|
||||
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
||||
public static final int FILE_CREATE_REQUEST_CODE = 5;
|
||||
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
|
||||
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
|
||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
|
||||
// is dropped so RomImporter's existing folder scan finds it -- see
|
||||
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
|
||||
// save directory to getExternalFilesDir()/save/<identity>).
|
||||
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
|
||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
private static boolean immersiveActive = false;
|
||||
private static boolean needToCopyGameInArchive = false;
|
||||
private boolean storagePermissionUnnecessary = false;
|
||||
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
/**
|
||||
* Shows the system document picker (Storage Access Framework) so the
|
||||
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
|
||||
* without needing to know where the app's external files folder is.
|
||||
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
|
||||
* etc.) without needing to know where the app's external files folder
|
||||
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||
* arrives later in onActivityResult, not synchronously here.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showRomFilePicker() {
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
destFilename = PICKED_ROM_FILENAME;
|
||||
}
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pendingPickFilename = destFilename;
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
try {
|
||||
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
|
||||
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
|
||||
@Keep
|
||||
public static boolean showRomFilePicker() {
|
||||
return showFilePicker(PICKED_ROM_FILENAME);
|
||||
}
|
||||
|
||||
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
|
||||
@Keep
|
||||
public static boolean showModFilePicker() {
|
||||
return showFilePicker(PICKED_MOD_FILENAME);
|
||||
}
|
||||
|
||||
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
|
||||
@Keep
|
||||
public static boolean showSaveFilePicker() {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||
* etc. Suggested name is the dialog's default filename.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (suggestedName == null || suggestedName.length() == 0) {
|
||||
suggestedName = "export.sav";
|
||||
}
|
||||
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
|
||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||
return false;
|
||||
}
|
||||
File source = new File(
|
||||
new File(self.getExternalFilesDir(null), "save"),
|
||||
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "no pending export at " + source);
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pendingCreateSuggestedName = suggestedName;
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("application/octet-stream");
|
||||
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private File saveIdentityDir() {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
private boolean copyFileToUri(File source, Uri destUri) {
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
try {
|
||||
in = new BufferedInputStream(new FileInputStream(source));
|
||||
out = getContentResolver().openOutputStream(destUri);
|
||||
if (out == null) return false;
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
out.flush();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
try { if (in != null) in.close(); } catch (IOException ignored) {}
|
||||
try { if (out != null) out.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
|
||||
if (requestCode == FILE_CREATE_REQUEST_CODE) {
|
||||
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
||||
Log.d("GameActivity", "create-document cancelled");
|
||||
return;
|
||||
}
|
||||
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "pending export missing at result time");
|
||||
return;
|
||||
}
|
||||
Uri uri = data.getData();
|
||||
if (copyFileToUri(source, uri)) {
|
||||
// Signal Lua on next focus that the SAF export finished.
|
||||
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(flag, false);
|
||||
fos.write("ok".getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
|
||||
}
|
||||
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||
} else {
|
||||
Log.d("GameActivity", "could not write export to " + uri);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
|
||||
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
||||
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
|
||||
Log.d("GameActivity", "file picker returned no file (cancelled?)");
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = data.getData();
|
||||
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
File destDir = saveIdentityDir();
|
||||
if (!destDir.exists() && !destDir.mkdirs()) {
|
||||
Log.d("GameActivity", "could not create " + destDir);
|
||||
return;
|
||||
}
|
||||
File destFile = new File(destDir, PICKED_ROM_FILENAME);
|
||||
String destName = pendingPickFilename != null
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
|
||||
InputStream source;
|
||||
try {
|
||||
source = getContentResolver().openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
|
||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (source == null) {
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Example mod gallery
|
||||
|
||||
Seven reference mods, one per modder persona. Each is small enough to read
|
||||
Eight reference mods, one per modder persona. Each is small enough to read
|
||||
in one sitting, exercises a different slice of the mod API, and is a real,
|
||||
runnable, tested mod — not a snippet.
|
||||
|
||||
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
|
||||
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
|
||||
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
|
||||
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
|
||||
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
|
||||
|
||||
## None of these load by default
|
||||
|
||||
@@ -46,7 +47,8 @@ and `field`, and exercises:
|
||||
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
||||
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
||||
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
||||
`game.ready` and `assets.transformed`
|
||||
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
|
||||
`intro.oak_speech.answered` and `intro.oak_speech.finished`
|
||||
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
|
||||
`mod:read`, `mod.log` and `mod.path`
|
||||
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
|
||||
@@ -66,7 +68,7 @@ mods/examples/<id>/
|
||||
.modkitignore keeps the suite out of the distributed package
|
||||
```
|
||||
|
||||
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
|
||||
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
|
||||
together and asserts the above, so the gallery cannot rot.
|
||||
|
||||
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Silly Oak Intro Example
|
||||
|
||||
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||
|
||||
## Try it (play through yourself)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
love .
|
||||
```
|
||||
|
||||
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||
|
||||
## Headless check
|
||||
|
||||
```sh
|
||||
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
```
|
||||
|
||||
## Auto driver (screenshots + save asserts)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||
```
|
||||
|
||||
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
|
||||
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
|
||||
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,107 @@
|
||||
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
|
||||
-- swaps (oak / rival / player / pokemon / a custom image), and answers
|
||||
-- that land in mod.save.
|
||||
--
|
||||
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
|
||||
|
||||
return function(mod)
|
||||
local toastPic = mod.path .. "/assets/toast_kid.png"
|
||||
|
||||
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||
steps = next(steps, speech)
|
||||
|
||||
-- after oak says hello, immediately derail
|
||||
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||
id = "silly_quiz_intro",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
|
||||
id = "silly_toast",
|
||||
kind = "yesno",
|
||||
pic = "oak",
|
||||
saveKey = "likes_toast",
|
||||
text = "Do you like\ntoast?",
|
||||
})
|
||||
|
||||
-- brand new sprite mid-speech
|
||||
mod.ui.insertStepAfter(steps, "silly_toast", {
|
||||
id = "silly_toast_kid",
|
||||
kind = "say",
|
||||
pic = { type = "image", path = toastPic },
|
||||
reveal = "fade",
|
||||
saveKey = nil,
|
||||
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
|
||||
})
|
||||
|
||||
-- existing mon with a wipe + cry, parked after the real demo mon
|
||||
mod.ui.insertStepAfter(steps, "demo_mon", {
|
||||
id = "silly_mew",
|
||||
kind = "say",
|
||||
pic = { type = "pokemon", id = "MEW" },
|
||||
reveal = "wipe",
|
||||
cry = "MEW",
|
||||
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_mew", {
|
||||
id = "silly_snack",
|
||||
kind = "choice",
|
||||
pic = "oak",
|
||||
saveKey = "snack",
|
||||
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
|
||||
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
|
||||
})
|
||||
|
||||
-- swap to rival pic for a loaded question before naming him
|
||||
mod.ui.insertStepBefore(steps, "ask_rival_name", {
|
||||
id = "silly_trust",
|
||||
kind = "choice",
|
||||
pic = "rival",
|
||||
reveal = "fade",
|
||||
saveKey = "trusts_rival",
|
||||
text = "Look at this kid.\nTrustworthy?",
|
||||
choices = { "SURE", "NO" },
|
||||
values = { true, false },
|
||||
})
|
||||
|
||||
-- player pic for one last bit after both names are set
|
||||
mod.ui.insertStepAfter(steps, "name_rival", {
|
||||
id = "silly_pineapple",
|
||||
kind = "yesno",
|
||||
pic = "player",
|
||||
saveKey = "pineapple_on_pizza",
|
||||
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_pineapple", {
|
||||
id = "silly_closing",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
|
||||
})
|
||||
|
||||
return steps
|
||||
end)
|
||||
|
||||
-- every answered step with a saveKey lands in mod.save (and therefore
|
||||
-- save.modData[mod.id] once the slot is written)
|
||||
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||
if not ev.saveKey then return end
|
||||
mod.save:set(ev.saveKey, ev.value)
|
||||
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
|
||||
end)
|
||||
|
||||
mod.events:on("intro.oak_speech.finished", function(ev)
|
||||
local answers = ev.answers or {}
|
||||
for key, value in pairs(answers) do
|
||||
if mod.save:get(key) == nil then
|
||||
mod.save:set(key, value)
|
||||
end
|
||||
end
|
||||
mod.save:set("quiz_done", true)
|
||||
mod.log:info("silly oak quiz done")
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "example_silly_oak",
|
||||
"name": "Silly Oak Intro Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "UI",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Sharing metadata for the manager detail pane.
|
||||
return {
|
||||
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "intro", "ui", "oak", "hooks" },
|
||||
differences = {
|
||||
changed = {
|
||||
"Oak's NEW GAME speech gains extra questions and sprite beats",
|
||||
},
|
||||
added = {
|
||||
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
|
||||
"Custom Toast Kid pic mid-intro",
|
||||
},
|
||||
known = { "vanilla naming and the shrink-away still run" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
|
||||
},
|
||||
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
|
||||
-- and answers landing in mod.save.
|
||||
--
|
||||
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
|
||||
-- fresh checkout without a ROM skip cleanly -- the gallery is also
|
||||
-- covered by tests/mod_examples_tests.lua when generated data is present.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local function hasGenerated()
|
||||
local handle = io.open("data/generated/constants.lua", "r")
|
||||
if handle then handle:close() return true end
|
||||
return false
|
||||
end
|
||||
if not hasGenerated() then
|
||||
print("example_silly_oak_test skipped (needs data/generated/)")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local mod = run.mod
|
||||
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local bucket = function()
|
||||
return run.loader.modSave.example_silly_oak or {}
|
||||
end
|
||||
local toastPath = (mod.path or "mods/examples/example_silly_oak")
|
||||
.. "/assets/toast_kid.png"
|
||||
|
||||
-- ------- build hook injects every silly beat around vanilla anchors
|
||||
|
||||
local speech = OakSpeech.new({
|
||||
data = Data,
|
||||
save = { player = { name = "RED", rival = "BLUE" } },
|
||||
stack = { push = function() end, pop = function() end },
|
||||
}, nil)
|
||||
local steps = speech:buildSteps()
|
||||
|
||||
local ids = {}
|
||||
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
|
||||
local function has(id)
|
||||
for _, x in ipairs(ids) do if x == id then return true end end
|
||||
return false
|
||||
end
|
||||
|
||||
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
|
||||
"vanilla anchors still present")
|
||||
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
|
||||
"toast quiz beats injected")
|
||||
T.check(has("silly_mew") and has("silly_snack"),
|
||||
"MEW reveal and snack choice injected")
|
||||
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
|
||||
"rival trust + pineapple beats injected")
|
||||
|
||||
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
|
||||
local function indexOf(id)
|
||||
for i, x in ipairs(ids) do if x == id then return i end end
|
||||
return 0
|
||||
end
|
||||
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
|
||||
"Toast Kid shows before the demo mon")
|
||||
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
|
||||
"MEW shows after the demo mon")
|
||||
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
|
||||
"trust question is before rival naming")
|
||||
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
|
||||
and indexOf("silly_pineapple") < indexOf("legend"),
|
||||
"pineapple lands between rival name and the legend beat")
|
||||
|
||||
-- ------- step shapes cover choice / yesno / custom image / pokemon
|
||||
|
||||
local byId = {}
|
||||
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||
|
||||
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
|
||||
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
|
||||
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
|
||||
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
|
||||
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
|
||||
"Toast Kid uses a custom image pic")
|
||||
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
|
||||
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
|
||||
"MEW beat uses pokemon pic + cry")
|
||||
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
|
||||
|
||||
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
|
||||
|
||||
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
|
||||
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
|
||||
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
|
||||
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "pokemon", id = "MEW", flip = true }, speech)
|
||||
local customImg = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "image", path = toastPath }, speech)
|
||||
-- headless love stub may return nil images; the call itself must not throw
|
||||
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
|
||||
or type(oakImg) == "table",
|
||||
"oak shorthand resolves without error")
|
||||
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
|
||||
or type(rivalImg) == "table",
|
||||
"rival shorthand resolves without error")
|
||||
T.check(playerImg == speech.playerPic or playerImg == nil
|
||||
or type(playerImg) == "userdata" or type(playerImg) == "table",
|
||||
"player shorthand resolves without error")
|
||||
T.check(mewFlip == true, "pokemon flip flag is honored")
|
||||
T.check(customImg ~= nil or true, "custom image path is accepted")
|
||||
|
||||
-- ------- answered event writes mod.save (loader.modSave bucket)
|
||||
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "likes_toast", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_toast, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
|
||||
step = byId.silly_snack, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
|
||||
step = byId.silly_trust, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_pineapple, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.finished", {
|
||||
speech = speech, answers = speech.answers,
|
||||
})
|
||||
|
||||
local saved = bucket()
|
||||
T.eq(saved.likes_toast, true, "likes_toast saved")
|
||||
T.eq(saved.snack, "OLD ROD", "snack saved")
|
||||
T.eq(saved.trusts_rival, false, "trusts_rival saved")
|
||||
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
|
||||
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
|
||||
|
||||
-- ------- ModUI step helpers (public surface)
|
||||
|
||||
local tiny = {
|
||||
{ id = "a", kind = "say" },
|
||||
{ id = "b", kind = "say" },
|
||||
}
|
||||
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
|
||||
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
|
||||
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
|
||||
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
|
||||
ModUI.removeStep(tiny, "mid")
|
||||
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
|
||||
|
||||
run.release()
|
||||
T.finish("example_silly_oak")
|
||||
@@ -183,7 +183,22 @@ end
|
||||
|
||||
-- the image a battler pic actually draws with this frame
|
||||
function BattleState:picImage(img)
|
||||
if self.grayPics then return grayImage(img) end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
-- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that
|
||||
-- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone
|
||||
-- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the
|
||||
-- WHOLE finished frame through the shade shader a second time (keyed on the
|
||||
-- red channel). A pic baked with the species' SGB color is then remapped
|
||||
-- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in
|
||||
-- the c0 bucket, collapsing CHARMANDER's body into the white paper and
|
||||
-- leaving only the outline. Emit the raw DMG-gray build instead (exactly
|
||||
-- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap
|
||||
-- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool
|
||||
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
|
||||
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
return fadeImage(img, self:activeBgp())
|
||||
end
|
||||
|
||||
@@ -284,8 +299,13 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
curStats = mon.stats,
|
||||
curTypes = def.types,
|
||||
curMoves = mon.moves,
|
||||
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
|
||||
monPalette(data, mon.species), def.trueColor),
|
||||
sprite = (function()
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(data, mon.species,
|
||||
isPlayer and "back" or "front",
|
||||
{ mon = mon, kind = "battle" })
|
||||
return getImage(path, monPalette(data, mon.species), tc)
|
||||
end)(),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -303,13 +323,16 @@ BattleState.makeBattler = makeBattler
|
||||
function BattleState:speciesSprite(species, isPlayerSide)
|
||||
local def = self.data.pokemon[species]
|
||||
if not def then return nil end
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(self.data, species,
|
||||
isPlayerSide and "back" or "front", { kind = "battle" })
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors = PaletteFX.monPal(self.data, species, true)
|
||||
local name = "GRAYMON"
|
||||
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
||||
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
||||
return getImage(path,
|
||||
colors and { name = name, colors = colors } or nil,
|
||||
def.trueColor)
|
||||
tc)
|
||||
end
|
||||
|
||||
local function markSeen(game, species)
|
||||
@@ -666,16 +689,53 @@ local function shownHP(b)
|
||||
return math.floor(shown)
|
||||
end
|
||||
|
||||
-- Parse a battle message into its rendered lines. The extractor marks
|
||||
-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking
|
||||
-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted /
|
||||
-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua
|
||||
-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont },
|
||||
-- cont true when the line was preceded by \v. Splitting before Font.encode
|
||||
-- keeps the control chars out of the glyph stream. The box then types into
|
||||
-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives
|
||||
-- instead of drawing it off-screen at y=144 (#216).
|
||||
function BattleState:startMessage(item)
|
||||
self.current = item
|
||||
self.lines = {}
|
||||
self.total = 0
|
||||
for chunk in (item.text .. "\n"):gmatch("(.-)\n") do
|
||||
local text = item.text or ""
|
||||
local pos, cont = 1, false
|
||||
while true do
|
||||
local npos = text:find("[\n\v]", pos)
|
||||
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||
local codes = Font.encode(chunk)
|
||||
table.insert(self.lines, codes)
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||
self.total = self.total + #codes
|
||||
if not npos then break end
|
||||
cont = text:sub(npos, npos) == "\v"
|
||||
pos = npos + 1
|
||||
end
|
||||
self.shown = {} -- up to two visible lines of revealed glyph codes
|
||||
self.lineIndex = 0
|
||||
-- self.charIndex counts glyphs typed across the WHOLE message (drivers read
|
||||
-- it against self.total); the current line's revealed count is #shown[last]
|
||||
self.charIndex = 0
|
||||
self.msgWaiting = nil
|
||||
self.scrollPx = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
|
||||
-- Start typing the next line into the rolling window. When the box already
|
||||
-- shows two lines, drop the top one and set the pixel scroll-up
|
||||
-- (ScrollTextUpOneLine), mirroring TextBox:beginLine.
|
||||
function BattleState:beginMsgLine()
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
local ln = self.lines[self.lineIndex]
|
||||
self.codes = ln and ln.codes or {}
|
||||
if #self.shown >= 2 then
|
||||
table.remove(self.shown, 1)
|
||||
self.scrollPx = 8
|
||||
end
|
||||
self.shown[#self.shown + 1] = {}
|
||||
end
|
||||
|
||||
function BattleState:updateQueue()
|
||||
@@ -823,8 +883,32 @@ function BattleState:updateQueue()
|
||||
end
|
||||
self:startMessage(item)
|
||||
end
|
||||
if self.charIndex < self.total then
|
||||
self.charIndex = math.min(self.total, self.charIndex + 2)
|
||||
local input = self.game.input
|
||||
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
|
||||
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
|
||||
if self.msgWaiting then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgWaiting = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
return true
|
||||
end
|
||||
local cur = self.shown[#self.shown]
|
||||
if #cur < #self.codes then
|
||||
-- battle typewriter cadence: two glyphs per fixed step (as before)
|
||||
for _ = 1, 2 do
|
||||
if #cur >= #self.codes then break end
|
||||
cur[#cur + 1] = self.codes[#cur + 1]
|
||||
self.charIndex = self.charIndex + 1
|
||||
end
|
||||
elseif self.lineIndex < #self.lines then
|
||||
-- current line finished, more lines remain: \v waits for A/B + ▼ before
|
||||
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
||||
if self.lines[self.lineIndex + 1].cont then
|
||||
self.msgWaiting = true
|
||||
else
|
||||
self:beginMsgLine()
|
||||
end
|
||||
else
|
||||
local item = self.current
|
||||
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
|
||||
@@ -841,7 +925,6 @@ function BattleState:updateQueue()
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local input = self.game.input
|
||||
if not (item and item.choice)
|
||||
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.current = nil
|
||||
@@ -2706,6 +2789,13 @@ function BattleState:enemyMonFainted()
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, mon.traded)
|
||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||
-- without this gate it re-triggers after every later fight (#213).
|
||||
if #levels > 0 then
|
||||
self.leveledUp = self.leveledUp or {}
|
||||
self.leveledUp[mon] = true
|
||||
end
|
||||
Runtime.emit("battle.exp_gained", {
|
||||
battle = self, mon = mon, gained = gained, levels = levels,
|
||||
})
|
||||
@@ -2715,12 +2805,15 @@ function BattleState:enemyMonFainted()
|
||||
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
|
||||
-- pass beats the traded boost (wBoostExpByExpAll checks first),
|
||||
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
|
||||
-- captured before the max-level cap (experience.asm:92-100)
|
||||
-- captured before the max-level cap (experience.asm:92-100).
|
||||
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
|
||||
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
|
||||
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
|
||||
local tail = "%d EXP. Points!"
|
||||
if announce == "expAll" then
|
||||
tail = "with EXP.ALL,\n" .. tail
|
||||
tail = "with EXP.ALL,\v" .. tail
|
||||
elseif mon.traded then
|
||||
tail = "a boosted\n" .. tail
|
||||
tail = "a boosted\v" .. tail
|
||||
end
|
||||
self:sayNext(("%s gained\n" .. tail):format(name, gained))
|
||||
end
|
||||
@@ -2733,6 +2826,17 @@ function BattleState:enemyMonFainted()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
end)
|
||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||
-- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the
|
||||
-- higher current HP. Experience.lua:84 already raised mon.hp by
|
||||
-- (newMaxHP - oldMaxHP); the party mon and the battler share one table
|
||||
-- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the
|
||||
-- new max instantly while the battler's shownHP numerator lags at the
|
||||
-- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the
|
||||
-- new current HP (house convention: potions drain the bar too, see
|
||||
-- itemUsed) so the bar grows instead. Only the active player battler
|
||||
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
||||
if mon == self.player.mon then self:drainNext() end
|
||||
for _, moveId in ipairs(Experience.movesLearnedAt(
|
||||
self.data.pokemon[mon.species], lv)) do
|
||||
self:learnMove(mon, moveId)
|
||||
@@ -3772,8 +3876,11 @@ function BattleState:colorMode()
|
||||
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
|
||||
and love.image and PaletteFX.pack(self.data)
|
||||
and PaletteFX.shader() then
|
||||
local ok1, bg = pcall(g.newCanvas, 160, 144)
|
||||
local ok2, wv = pcall(g.newCanvas, 160, 144)
|
||||
-- 160x144 real pixels, not DPI units, or the colored battle background
|
||||
-- resamples against the UI canvas on mobile (#208; PixelCanvas.lua)
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local ok1, bg = pcall(PixelCanvas.new, 160, 144)
|
||||
local ok2, wv = pcall(PixelCanvas.new, 160, 144)
|
||||
if ok1 and ok2 and bg and wv then
|
||||
self.bgCanvas, self.waveCanvas = bg, wv
|
||||
ready = true
|
||||
@@ -4114,7 +4221,12 @@ function BattleState:drawHUDs(slide)
|
||||
-- the HUD clears with the send-out text (ClearScreenArea,
|
||||
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
|
||||
-- it after the grow-in + cry
|
||||
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
|
||||
-- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1)
|
||||
-- recolors the bar's DMG gray fill by region, so drawHPBar must skip its
|
||||
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
||||
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
||||
local grayFill = self:colorMode()
|
||||
local barData = self.data
|
||||
local fx = self.fx
|
||||
local hudShake = (fx and fx.hudShakeX) or 0
|
||||
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
||||
@@ -4139,7 +4251,8 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
hudTile(0x73, 8, 16)
|
||||
drawHPBar(barData, 2, 2,
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats })
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||
nil, grayFill)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
@@ -4187,7 +4300,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
drawHPBar(barData, 10, 9,
|
||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||
1) -- wHPBarType 1: the $6D cap
|
||||
1, grayFill) -- wHPBarType 1: the $6D cap
|
||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
@@ -4200,16 +4313,27 @@ function BattleState:drawTextArea()
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "messages" and self.current then
|
||||
local shown = 0
|
||||
for li, codes in ipairs(self.lines) do
|
||||
-- battle text uses every other tile row (hlcoord *,14 / *,16)
|
||||
local y = 112 + (li - 1) * 16
|
||||
for i = 1, #codes do
|
||||
if shown >= self.charIndex then break end
|
||||
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
||||
shown = shown + 1
|
||||
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
|
||||
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
||||
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
||||
-- view instead of drawing off-screen at y=144 (#216).
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
|
||||
-- box, bottom-right of the box like TextBox / home/text.asm
|
||||
if self.msgWaiting and self.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
end
|
||||
elseif self.phase == "menu" and self.demo then
|
||||
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
|
||||
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
|
||||
@@ -4360,6 +4484,11 @@ function BattleState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
|
||||
-- the vanilla link is a no-op so an empty chain costs nothing.
|
||||
if Runtime.wantsHook("battle.overlay") then
|
||||
Runtime.call("battle.overlay", function() end, self)
|
||||
end
|
||||
end
|
||||
|
||||
return BattleState
|
||||
|
||||
@@ -581,8 +581,12 @@ MoveEffects.full = {
|
||||
},
|
||||
HYPER_BEAM_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- no recharge when the target faints OR its substitute breaks
|
||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
||||
-- Gen 1: no recharge when the target faints OR its substitute breaks.
|
||||
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
|
||||
local ruleset = ctx.battle and ctx.battle.ruleset
|
||||
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
|
||||
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
|
||||
if not skipOnKO or not targetDown then
|
||||
ctx.user.mustRecharge = true
|
||||
end
|
||||
end,
|
||||
|
||||
@@ -16,4 +16,7 @@ return {
|
||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||
enemyUnlimitedPP = true,
|
||||
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
|
||||
-- substitute breaks). Set false to always recharge like Gen 2+.
|
||||
hyperBeamSkipRechargeOnKO = true,
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ return {
|
||||
focusEnergyBug = false,
|
||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||
enemyUnlimitedPP = false,
|
||||
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
|
||||
hyperBeamSkipRechargeOnKO = false,
|
||||
}
|
||||
|
||||
@@ -102,10 +102,41 @@ 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()
|
||||
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
|
||||
-- writes no header for him -- seed one so he engages on sight and has
|
||||
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
||||
self:seedFightingDojoKarateMaster()
|
||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||
require("src.world.SsAnneLayout").apply(self.maps)
|
||||
end
|
||||
|
||||
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
|
||||
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||
-- object index [1] so he behaves like the real leader:
|
||||
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
||||
-- spots the player in his DOWN line and he challenges on sight (#197),
|
||||
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
|
||||
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
|
||||
-- this header also set it, the reward's flag guard would early-return and
|
||||
-- swallow the prize dialogue. trainerDefeated tracks him via
|
||||
-- defeatedTrainers[npc.id] like the leaders.
|
||||
function Data:seedFightingDojoKarateMaster()
|
||||
local headers = self.trainer_headers
|
||||
if not headers then return end
|
||||
headers.FightingDojo = headers.FightingDojo or {}
|
||||
if headers.FightingDojo[1] then return end
|
||||
headers.FightingDojo[1] = {
|
||||
range = 4,
|
||||
battle = "_FightingDojoKarateMasterText",
|
||||
won = "_FightingDojoKarateMasterDefeatedText",
|
||||
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||
}
|
||||
end
|
||||
|
||||
function Data:seedCinnabarGymTrainerHeaders()
|
||||
local headers = self.trainer_headers
|
||||
if not headers or headers.CinnabarGym then return end
|
||||
|
||||
@@ -783,5 +783,6 @@ end
|
||||
-- test / debug helpers
|
||||
DiscordPresence._state = state
|
||||
DiscordPresence.locationName = locationName
|
||||
DiscordPresence.handleJoinRequest = handleJoinRequest
|
||||
|
||||
return DiscordPresence
|
||||
|
||||
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TouchInput = require("src.core.TouchInput")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local ModLoader = require("src.mods.Loader")
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -44,8 +44,8 @@ function Game:load()
|
||||
self.input = Input
|
||||
Input:init()
|
||||
|
||||
self.touchInput = TouchInput
|
||||
TouchInput:init()
|
||||
self.touchControls = TouchControls
|
||||
TouchControls:init()
|
||||
|
||||
self.renderer = Renderer
|
||||
Renderer:init()
|
||||
@@ -96,6 +96,20 @@ function Game:load()
|
||||
end
|
||||
|
||||
Logger.info("game loaded")
|
||||
-- Scaling bug reports (#87, #208) are unanswerable without these three
|
||||
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
|
||||
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
|
||||
-- looks stretched" into something reproducible.
|
||||
if love.graphics and love.graphics.getDimensions then
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
Logger.info(string.format(
|
||||
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
|
||||
ww, wh, pw, ph, Renderer:fitScale()))
|
||||
end
|
||||
end
|
||||
|
||||
-- the merged field.boot: spawn, names, money and the naming presets a
|
||||
@@ -180,9 +194,6 @@ function Game:logicSpeed()
|
||||
end
|
||||
|
||||
function Game:update(dt)
|
||||
-- Touch timers / prior-frame auto-releases before the fixed step so
|
||||
-- deferred A and edge pulses land in Input's press queue for this step.
|
||||
TouchInput:update(dt)
|
||||
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||
-- Give the accumulator room for one full frame at the current speed,
|
||||
-- or the anti-spiral clamp quietly caps every level above ~15X.
|
||||
@@ -246,6 +257,8 @@ function Game:draw()
|
||||
worldZones = self.overworld:sgbWorldZones()
|
||||
end
|
||||
Renderer:endFrame(zones, worldZones)
|
||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
|
||||
@@ -375,6 +388,9 @@ function Game:keyreleased(key)
|
||||
end
|
||||
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
-- a controller is being used: the touch overlay steps aside until the
|
||||
-- next screen touch (mobile only; a no-op elsewhere)
|
||||
TouchControls:noteGamepad()
|
||||
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadPressed then
|
||||
@@ -389,6 +405,8 @@ function Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
function Game:gamepadaxis(joystick, axis, value)
|
||||
-- past-deadzone only, so resting-stick drift can't hide the overlay
|
||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -398,12 +416,12 @@ end
|
||||
-- state is worse than asking the player to re-press.
|
||||
function Game:focus(f)
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
TouchControls:reset()
|
||||
end
|
||||
|
||||
function Game:visible(v)
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
TouchControls:reset()
|
||||
end
|
||||
|
||||
-- A disconnected/dropped controller can't send the button-up for whatever
|
||||
@@ -411,19 +429,19 @@ end
|
||||
-- flags it owned.
|
||||
function Game:joystickremoved(joystick)
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
function Game:touchpressed(id, x, y)
|
||||
TouchInput:touchpressed(id, x, y)
|
||||
TouchControls:touchpressed(id, x, y)
|
||||
end
|
||||
|
||||
function Game:touchmoved(id, x, y)
|
||||
TouchInput:touchmoved(id, x, y)
|
||||
TouchControls:touchmoved(id, x, y)
|
||||
end
|
||||
|
||||
function Game:touchreleased(id, x, y)
|
||||
TouchInput:touchreleased(id, x, y)
|
||||
TouchControls:touchreleased(id, x, y)
|
||||
end
|
||||
|
||||
-- Point the loader's mod.save backing at this save's modData so per-mod
|
||||
|
||||
@@ -154,6 +154,17 @@ function Input:step()
|
||||
self.pressQueue = {}
|
||||
end
|
||||
|
||||
-- The on-screen touch overlay (src/core/TouchControls.lua) presses GB
|
||||
-- buttons directly by name -- not through a keyboard alias -- so a player
|
||||
-- rebind can never detach or shadow the overlay.
|
||||
function Input:overlayPressed(btn)
|
||||
press(self, btn, "touch:" .. btn)
|
||||
end
|
||||
|
||||
function Input:overlayReleased(btn)
|
||||
release(self, btn, "touch:" .. btn)
|
||||
end
|
||||
|
||||
function Input:gamepadpressed(joystick, button)
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
|
||||
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||
local filterLevel = 0
|
||||
|
||||
local function applyVolume(src)
|
||||
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
|
||||
if not src then return end
|
||||
local vol = VOLUME * volumeScale
|
||||
if Runtime.wantsHook("music.volume") then
|
||||
local ctx = {
|
||||
song = state.current,
|
||||
mapSong = state.mapSong,
|
||||
onBike = state.onBike,
|
||||
surfing = state.surfing,
|
||||
fading = state.fade ~= nil,
|
||||
optionScale = volumeScale,
|
||||
}
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if ok and Game then
|
||||
local ow = Game.overworld
|
||||
if ow and ow.player then
|
||||
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
|
||||
ctx.mapId = ow.map and ow.map.id
|
||||
ctx.tod = ow.tod
|
||||
end
|
||||
end
|
||||
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
|
||||
vol = tonumber(vol) or (VOLUME * volumeScale)
|
||||
if vol < 0 then vol = 0 end
|
||||
end
|
||||
pcall(src.setVolume, src, vol)
|
||||
end
|
||||
|
||||
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
||||
@@ -408,6 +432,12 @@ end
|
||||
-- restores the map theme after a one-shot jingle
|
||||
function Music.update(data)
|
||||
if state.chip then require("src.core.ChipAudio").update() end
|
||||
-- distance / indoor muffling mods re-apply volume every frame while
|
||||
-- subscribed; otherwise applyVolume only runs on song/option changes
|
||||
if Runtime.wantsHook("music.volume") and not state.fade then
|
||||
applyVolume(state.source)
|
||||
applyVolume(state.loopSource)
|
||||
end
|
||||
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
||||
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
||||
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
||||
|
||||
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
|
||||
-- options registry, and if it was active point active at another remaining
|
||||
-- slot (or clear active when the list is empty). Returns true, or false +
|
||||
-- an error string when the id is unknown / not registered.
|
||||
function SaveData.deleteSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version]
|
||||
if not reg or not reg.list then return false, "slot not registered" end
|
||||
local found, idx = false, nil
|
||||
for i, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true; idx = i; break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = reg.active or false
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
-- On-screen touch controls: a visible d-pad, A, B, START and SELECT drawn
|
||||
-- over the finished frame (art: Xelu's CC0 controller prompts, see
|
||||
-- assets/touch/README.md). Replaces the old touch gesture recognizer:
|
||||
-- every control is a real button under the thumb, so there is no
|
||||
-- tap-vs-swipe classification, no deferred-A double-tap window, and no
|
||||
-- added latency.
|
||||
--
|
||||
-- Mobile only, and only while no controller is being used: the overlay
|
||||
-- shows on Android/iOS, disappears the moment a gamepad button or stick
|
||||
-- is used, and comes back on the next screen touch (Game routes both
|
||||
-- events here). POKEPORT_TOUCH=1 forces it on for desktop testing
|
||||
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
||||
-- off everywhere.
|
||||
--
|
||||
-- Controls press GB buttons through Input:overlayPressed/Released -- their
|
||||
-- own input source, not a keyboard alias -- so a held overlay direction
|
||||
-- merges cleanly with a keyboard key or stick holding the same button,
|
||||
-- and a player rebind can never detach the overlay.
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local TouchControls = {}
|
||||
|
||||
-- idle vs pressed overlay opacity
|
||||
local ALPHA = 0.65
|
||||
local ALPHA_PRESSED = 0.95
|
||||
-- translucent backing disc behind each control: the prompt art is dark
|
||||
-- gray, so without it the controls melt into dark map areas
|
||||
local BACK = 0.24
|
||||
local BACK_PRESSED = 0.38
|
||||
|
||||
-- neutral zone at the d-pad center, as a fraction of the d-pad width;
|
||||
-- inside it no direction is held (keeps a resting thumb from jittering)
|
||||
local DPAD_DEAD = 0.16
|
||||
|
||||
-- hit slop: how far past the visible edge a press still counts, as a
|
||||
-- multiplier on the control's half-width. START/SELECT get more because
|
||||
-- the glyphs are small.
|
||||
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
||||
|
||||
local BUTTONS = { "a", "b", "start", "select" }
|
||||
|
||||
local IMAGES = {
|
||||
dpad = "assets/touch/dpad.png",
|
||||
dpad_up = "assets/touch/dpad_up.png",
|
||||
dpad_down = "assets/touch/dpad_down.png",
|
||||
dpad_left = "assets/touch/dpad_left.png",
|
||||
dpad_right = "assets/touch/dpad_right.png",
|
||||
a = "assets/touch/a.png",
|
||||
b = "assets/touch/b.png",
|
||||
start = "assets/touch/start.png",
|
||||
select = "assets/touch/select.png",
|
||||
}
|
||||
|
||||
local function wantsOverlay()
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
if env == "1" then return true end
|
||||
if env == "0" then return false end
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
function TouchControls:init()
|
||||
self.active = wantsOverlay()
|
||||
self.controllerHidden = false
|
||||
self.touches = {}
|
||||
-- per-GB-button owner count: two fingers on A must not double-press it,
|
||||
-- and lifting one of them must not release the other's hold
|
||||
self.held = {}
|
||||
self.dpadTouch = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.img = nil
|
||||
if not self.active then return end
|
||||
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
|
||||
-- stays off and keyboard/controller play still works
|
||||
local img = {}
|
||||
for name, path in pairs(IMAGES) do
|
||||
local ok, im = pcall(love.graphics.newImage, path)
|
||||
if not ok then
|
||||
img = nil
|
||||
break
|
||||
end
|
||||
-- smooth UI icons; the global default filter is nearest for GB pixels
|
||||
im:setFilter("linear", "linear")
|
||||
img[name] = im
|
||||
end
|
||||
self.img = img
|
||||
end
|
||||
|
||||
function TouchControls:visible()
|
||||
return self.active and self.img ~= nil and not self.controllerHidden
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
|
||||
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
local short = math.min(ww, wh)
|
||||
-- ~a third of the short edge, capped so tablets don't get a dinner plate
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
-- START/SELECT hug the bottom center: on a narrow portrait phone the
|
||||
-- d-pad and B leave little room, so a tight pair is what keeps them off
|
||||
-- the neighboring controls
|
||||
self.L = {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
||||
if not self.labelFont or self.fontSize ~= fontSize then
|
||||
self.fontSize = fontSize
|
||||
self.labelFont = love.graphics.newFont(fontSize)
|
||||
end
|
||||
return self.L
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
local r = zone.w * 0.5 * slop
|
||||
local dx, dy = x - zone.cx, y - zone.cy
|
||||
return dx * dx + dy * dy <= r * r
|
||||
end
|
||||
|
||||
local function dpadDir(zone, x, y)
|
||||
local dx, dy = x - zone.cx, y - zone.cy
|
||||
local dead = zone.w * DPAD_DEAD
|
||||
if math.abs(dx) < dead and math.abs(dy) < dead then return nil end
|
||||
if math.abs(dx) >= math.abs(dy) then
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
local function pressBtn(self, btn)
|
||||
local n = (self.held[btn] or 0) + 1
|
||||
self.held[btn] = n
|
||||
if n == 1 then Input:overlayPressed(btn) end
|
||||
end
|
||||
|
||||
local function releaseBtn(self, btn)
|
||||
local n = self.held[btn]
|
||||
if not n then return end
|
||||
if n > 1 then
|
||||
self.held[btn] = n - 1
|
||||
else
|
||||
self.held[btn] = nil
|
||||
Input:overlayReleased(btn)
|
||||
end
|
||||
end
|
||||
|
||||
-- the d-pad touch's held direction changed (or ended): swap the GB hold
|
||||
local function setDpad(self, touch, dir)
|
||||
if touch.dir == dir then return end
|
||||
if touch.dir then releaseBtn(self, touch.dir) end
|
||||
touch.dir = dir
|
||||
if dir then pressBtn(self, dir) end
|
||||
end
|
||||
|
||||
function TouchControls:touchpressed(id, x, y)
|
||||
if not (self.active and self.img) then return end
|
||||
-- a controller hid the overlay; the first touch only brings it back
|
||||
if self.controllerHidden then
|
||||
self.controllerHidden = false
|
||||
return
|
||||
end
|
||||
local L = self:layout()
|
||||
for _, btn in ipairs(BUTTONS) do
|
||||
if inCircle(L[btn], x, y, SLOP[btn]) then
|
||||
self.touches[id] = { control = btn }
|
||||
pressBtn(self, btn)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- square hit zone a bit past the cross art; one owning finger at a time
|
||||
local dz = L.dpad
|
||||
local half = dz.w * 0.65
|
||||
if not self.dpadTouch
|
||||
and math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
|
||||
self.dpadTouch = id
|
||||
local touch = { control = "dpad", dir = nil }
|
||||
self.touches[id] = touch
|
||||
setDpad(self, touch, dpadDir(dz, x, y))
|
||||
end
|
||||
end
|
||||
|
||||
function TouchControls:touchmoved(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
-- only the d-pad tracks movement (slide between directions without
|
||||
-- lifting); buttons hold until release wherever the finger wanders
|
||||
if not touch or touch.control ~= "dpad" then return end
|
||||
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
|
||||
end
|
||||
|
||||
function TouchControls:touchreleased(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
if not touch then return end
|
||||
self.touches[id] = nil
|
||||
if touch.control == "dpad" then
|
||||
setDpad(self, touch, nil)
|
||||
self.dpadTouch = nil
|
||||
else
|
||||
releaseBtn(self, touch.control)
|
||||
end
|
||||
end
|
||||
|
||||
-- LÖVE has no touchcancelled: a touch interrupted by the OS (app
|
||||
-- backgrounded, a system gesture stealing the finger) never fires
|
||||
-- touchreleased and would strand its button held forever. Called from
|
||||
-- Game alongside Input:reset() on focus/visibility loss.
|
||||
function TouchControls:reset()
|
||||
for btn in pairs(self.held) do
|
||||
Input:overlayReleased(btn)
|
||||
end
|
||||
self.held = {}
|
||||
self.touches = {}
|
||||
self.dpadTouch = nil
|
||||
end
|
||||
|
||||
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
||||
-- until the next screen touch asks for it back
|
||||
function TouchControls:noteGamepad()
|
||||
if not self.active or self.controllerHidden then return end
|
||||
self.controllerHidden = true
|
||||
self:reset()
|
||||
end
|
||||
|
||||
-- last controller unplugged: show the overlay again immediately instead
|
||||
-- of requiring a blind first tap
|
||||
function TouchControls:joystickremoved()
|
||||
self:reset()
|
||||
if love.joystick and love.joystick.getJoystickCount
|
||||
and love.joystick.getJoystickCount() == 0 then
|
||||
self.controllerHidden = false
|
||||
end
|
||||
end
|
||||
|
||||
local function drawIcon(img, zone, pressed)
|
||||
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
|
||||
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
|
||||
local scale = zone.w / img:getWidth()
|
||||
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
|
||||
love.graphics.draw(img, zone.cx - zone.w / 2,
|
||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||
end
|
||||
|
||||
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
||||
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
|
||||
function TouchControls:draw()
|
||||
if not self:visible() then return end
|
||||
local L = self:layout()
|
||||
love.graphics.push("all")
|
||||
love.graphics.origin()
|
||||
|
||||
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
||||
local dir = dpadTouch and dpadTouch.dir
|
||||
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
||||
dir ~= nil)
|
||||
for _, btn in ipairs(BUTTONS) do
|
||||
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
|
||||
end
|
||||
|
||||
-- the +/- glyphs alone don't say which is which; shadowed so the text
|
||||
-- reads on both the black letterbox and battle's white one
|
||||
love.graphics.setFont(self.labelFont)
|
||||
local ly = L.start.cy + L.start.w * 0.66
|
||||
local function label(text, zone)
|
||||
local w = self.labelFont:getWidth(text)
|
||||
love.graphics.setColor(0, 0, 0, 0.6)
|
||||
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
||||
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
|
||||
love.graphics.print(text, zone.cx - w / 2, ly)
|
||||
end
|
||||
label("START", L.start)
|
||||
label("SELECT", L.select)
|
||||
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
return TouchControls
|
||||
@@ -1,297 +0,0 @@
|
||||
-- Touch gesture recognizer → virtual keyboard keys for Input.lua.
|
||||
--
|
||||
-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second
|
||||
-- tap. That adds ~280ms latency to every A press so a double-tap can be
|
||||
-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect
|
||||
-- input needs, so the latency is acceptable.
|
||||
--
|
||||
-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct
|
||||
-- touch ID lands while another short, low-movement touch is active, fire
|
||||
-- SELECT (press + one-frame auto-release).
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local TouchInput = {}
|
||||
|
||||
local function dpiScale()
|
||||
if love and love.window then
|
||||
if love.window.getDPIScale then
|
||||
return love.window.getDPIScale()
|
||||
end
|
||||
if love.window.toPixels then
|
||||
return love.window.toPixels(1)
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing.
|
||||
local SWIPE_THRESHOLD_PX = 14
|
||||
local EDGE_PX = 24
|
||||
local EDGE_SWIPE_PX = 24
|
||||
local DOUBLE_TAP_MS = 280
|
||||
local TAP_MAX_MS = 320
|
||||
local TAP_MAX_MOVE_PX = 12
|
||||
|
||||
local function scaled(px)
|
||||
return px * dpiScale()
|
||||
end
|
||||
|
||||
local DIRS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names).
|
||||
local KEY = {
|
||||
up = "up",
|
||||
down = "down",
|
||||
left = "left",
|
||||
right = "right",
|
||||
a = "z",
|
||||
b = "x",
|
||||
start = "escape",
|
||||
select = "tab",
|
||||
}
|
||||
|
||||
local function nowMs()
|
||||
return love.timer.getTime() * 1000
|
||||
end
|
||||
|
||||
local function dominantDir(dx, dy)
|
||||
if math.abs(dx) >= math.abs(dy) then
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
function TouchInput:init()
|
||||
self.touches = {}
|
||||
self.pendingA = nil -- { deadlineMs = number }
|
||||
-- Edge pulses (B / START / SELECT / deferred-A): press now, release on a
|
||||
-- later update so FixedStep can consume wasPressed first.
|
||||
-- `armed` = pressed during events since last update; promoted to
|
||||
-- `autoRelease` at the start of update (released on the *following* update).
|
||||
-- Deferred-A fired inside update goes straight into `autoRelease`.
|
||||
self.armed = {}
|
||||
self.autoRelease = {}
|
||||
self.selectFired = false -- one SELECT per two-finger gesture cluster
|
||||
end
|
||||
|
||||
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
|
||||
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
|
||||
-- touchreleased and would otherwise strand its direction held forever.
|
||||
-- Called from Game alongside Input:reset() on focus/visibility loss.
|
||||
function TouchInput:reset()
|
||||
for _, touch in pairs(self.touches) do
|
||||
releaseDir(self, touch)
|
||||
end
|
||||
self.touches = {}
|
||||
self.pendingA = nil
|
||||
self.armed = {}
|
||||
self.autoRelease = {}
|
||||
self.selectFired = false
|
||||
end
|
||||
|
||||
local function pulse(self, key)
|
||||
Input:keypressed(key)
|
||||
self.armed[#self.armed + 1] = key
|
||||
end
|
||||
|
||||
local function pulseInUpdate(self, key)
|
||||
Input:keypressed(key)
|
||||
self.autoRelease[#self.autoRelease + 1] = key
|
||||
end
|
||||
|
||||
local function releaseDir(self, touch)
|
||||
if touch.dir and DIRS[touch.dir] then
|
||||
Input:keyreleased(KEY[touch.dir])
|
||||
touch.dir = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function pressDir(self, touch, dir)
|
||||
if touch.dir == dir then return end
|
||||
releaseDir(self, touch)
|
||||
touch.dir = dir
|
||||
Input:keypressed(KEY[dir])
|
||||
end
|
||||
|
||||
local function totalMove(touch, x, y)
|
||||
local dx = x - touch.x0
|
||||
local dy = y - touch.y0
|
||||
return math.abs(dx), math.abs(dy), dx, dy
|
||||
end
|
||||
|
||||
local function isTapLike(touch, x, y, tMs)
|
||||
local ax, ay = totalMove(touch, x, y)
|
||||
local elapsed = tMs - touch.t0
|
||||
return elapsed <= TAP_MAX_MS
|
||||
and ax <= scaled(TAP_MAX_MOVE_PX)
|
||||
and ay <= scaled(TAP_MAX_MOVE_PX)
|
||||
and not touch.classified
|
||||
end
|
||||
|
||||
local function countActive(self)
|
||||
local n = 0
|
||||
for _ in pairs(self.touches) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
local function tryTwoFingerSelect(self, tMs)
|
||||
if self.selectFired then return false end
|
||||
local ids = {}
|
||||
for id, touch in pairs(self.touches) do
|
||||
if isTapLike(touch, touch.x, touch.y, tMs) then
|
||||
ids[#ids + 1] = id
|
||||
end
|
||||
end
|
||||
if #ids < 2 then return false end
|
||||
|
||||
self.selectFired = true
|
||||
self.pendingA = nil
|
||||
for _, id in ipairs(ids) do
|
||||
local touch = self.touches[id]
|
||||
touch.classified = true
|
||||
touch.consumed = true
|
||||
releaseDir(self, touch)
|
||||
end
|
||||
pulse(self, KEY.select)
|
||||
return true
|
||||
end
|
||||
|
||||
function TouchInput:update(dt)
|
||||
-- Releases armed on a prior update (FixedStep already saw wasPressed).
|
||||
for i = 1, #self.autoRelease do
|
||||
Input:keyreleased(self.autoRelease[i])
|
||||
end
|
||||
-- Promote event-phase pulses from since the last update; they release next time.
|
||||
self.autoRelease = self.armed
|
||||
self.armed = {}
|
||||
|
||||
local tMs = nowMs()
|
||||
|
||||
-- Deferred A: fire once the double-tap window closes with no second tap.
|
||||
-- Queued into autoRelease so the next update clears hold after this FixedStep.
|
||||
if self.pendingA and tMs >= self.pendingA.deadlineMs then
|
||||
self.pendingA = nil
|
||||
pulseInUpdate(self, KEY.a)
|
||||
end
|
||||
|
||||
-- Keep two-finger SELECT detection live while both fingers stay down.
|
||||
if countActive(self) >= 2 then
|
||||
tryTwoFingerSelect(self, tMs)
|
||||
elseif countActive(self) == 0 then
|
||||
self.selectFired = false
|
||||
end
|
||||
end
|
||||
|
||||
function TouchInput:touchpressed(id, x, y)
|
||||
local tMs = nowMs()
|
||||
|
||||
-- Second tap inside the deferred-A window → START instead of A.
|
||||
if self.pendingA and tMs < self.pendingA.deadlineMs then
|
||||
self.pendingA = nil
|
||||
pulse(self, KEY.start)
|
||||
-- Still record this touch so a lingering finger doesn't become a stray swipe.
|
||||
self.touches[id] = {
|
||||
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
|
||||
edge = x < scaled(EDGE_PX),
|
||||
classified = true,
|
||||
consumed = true,
|
||||
dir = nil,
|
||||
}
|
||||
return
|
||||
end
|
||||
|
||||
self.touches[id] = {
|
||||
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
|
||||
edge = x < scaled(EDGE_PX),
|
||||
classified = false,
|
||||
consumed = false,
|
||||
dir = nil,
|
||||
}
|
||||
|
||||
if countActive(self) >= 2 then
|
||||
tryTwoFingerSelect(self, tMs)
|
||||
end
|
||||
end
|
||||
|
||||
function TouchInput:touchmoved(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
if not touch or touch.consumed then return end
|
||||
|
||||
touch.x, touch.y = x, y
|
||||
local ax, ay, dx, dy = totalMove(touch, x, y)
|
||||
local swipeTh = scaled(SWIPE_THRESHOLD_PX)
|
||||
|
||||
-- Edge-origin swipes become B on release; never promote to d-pad.
|
||||
if touch.edge then
|
||||
if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then
|
||||
touch.classified = true
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not touch.classified then
|
||||
if ax < swipeTh and ay < swipeTh then return end
|
||||
touch.classified = true
|
||||
pressDir(self, touch, dominantDir(dx, dy))
|
||||
return
|
||||
end
|
||||
|
||||
-- Mid-hold direction change: release old, press new (dominant axis).
|
||||
if touch.dir then
|
||||
local fromLastX = x - touch.x0
|
||||
local fromLastY = y - touch.y0
|
||||
-- Re-evaluate from origin so small jitter doesn't flip; require threshold
|
||||
-- distance from origin along the new dominant axis.
|
||||
if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then
|
||||
local newDir = dominantDir(fromLastX, fromLastY)
|
||||
if newDir ~= touch.dir then
|
||||
pressDir(self, touch, newDir)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function TouchInput:touchreleased(id, x, y)
|
||||
local touch = self.touches[id]
|
||||
if not touch then return end
|
||||
|
||||
local tMs = nowMs()
|
||||
touch.x, touch.y = x, y
|
||||
local ax, ay = totalMove(touch, x, y)
|
||||
|
||||
if touch.dir then
|
||||
releaseDir(self, touch)
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
return
|
||||
end
|
||||
|
||||
if touch.consumed then
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
return
|
||||
end
|
||||
|
||||
-- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX
|
||||
-- (or already marked classified while moving). Prefer over d-pad / tap.
|
||||
if touch.edge then
|
||||
local edgeTh = scaled(EDGE_SWIPE_PX)
|
||||
if touch.classified or ax >= edgeTh or ay >= edgeTh then
|
||||
pulse(self, KEY.b)
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Plain tap → defer A (or it was already classified as swipe without dir, ignore).
|
||||
if isTapLike(touch, x, y, tMs) then
|
||||
self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS }
|
||||
end
|
||||
|
||||
self.touches[id] = nil
|
||||
if countActive(self) == 0 then self.selectFired = false end
|
||||
end
|
||||
|
||||
return TouchInput
|
||||
@@ -290,6 +290,39 @@ local function findPendingRom(ready)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
|
||||
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
|
||||
-- copies (Choose / Import); focus only consumes the SAF basename so a random
|
||||
-- leftover archive is never auto-installed on every refocus.
|
||||
local function findPendingMod(preferAny)
|
||||
local preferred = "picked_mod.zip"
|
||||
if love.filesystem.getInfo(preferred, "file") then
|
||||
return preferred
|
||||
end
|
||||
if not preferAny then return nil end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
|
||||
local function findPendingSav(preferAny)
|
||||
local preferred = "picked_save.sav"
|
||||
if love.filesystem.getInfo(preferred, "file") then
|
||||
return preferred
|
||||
end
|
||||
if not preferAny then return nil end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function chooseRom(promptName)
|
||||
promptName = promptName or "Pokemon"
|
||||
local prompt = "Choose your " .. promptName .. " ROM"
|
||||
@@ -320,8 +353,8 @@ local function chooseRom(promptName)
|
||||
end
|
||||
|
||||
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
|
||||
-- Returns the chosen absolute path or nil. Android has no picker; the mods
|
||||
-- panel steers that case to the drag-drop hint instead.
|
||||
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
|
||||
-- ("mod") instead -- see RomImporter:chooseMod.
|
||||
local function chooseZip()
|
||||
local prompt = "Choose a mod .zip"
|
||||
local platform = love.system.getOS()
|
||||
@@ -351,8 +384,8 @@ local function chooseZip()
|
||||
end
|
||||
|
||||
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
|
||||
-- dialogs). Returns the chosen absolute path or nil. Android has no picker;
|
||||
-- the SAVE FILES card steers that case to the drag-drop hint instead.
|
||||
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
||||
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
||||
local function chooseSav()
|
||||
local prompt = "Choose a .sav save file"
|
||||
local platform = love.system.getOS()
|
||||
@@ -429,10 +462,16 @@ function RomImporter.new(onComplete, opts)
|
||||
-- affordance (desktop love.system.openURL).
|
||||
saveNotice = {},
|
||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||
-- (refreshed lazily on first draw and after any toggle/install); modScroll
|
||||
-- is the list scroll offset (px, clamped in draw); modNotice is the last
|
||||
-- install result { ok, text } shown as a line above the list.
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- Android SAF: which game tab should receive the next picked_save.sav when
|
||||
-- focus consumes it (set by chooseSaveImport before opening the picker).
|
||||
androidPendingVersion = nil,
|
||||
-- Android SAF create-document: which game's SAVE FILES card should show
|
||||
-- "Save exported." when export_done.flag appears on focus.
|
||||
androidPendingExportVersion = nil,
|
||||
}, RomImporter)
|
||||
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
@@ -491,10 +530,40 @@ end
|
||||
-- The system picker runs as a separate top activity, so LOVE's own
|
||||
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
|
||||
-- player returns here with a file picked, GameActivity has already copied
|
||||
-- it into the save directory, so a pending-ROM rescan on refocus picks it
|
||||
-- up without the player needing to tap the button again.
|
||||
-- it into the save directory, so a pending-file rescan on refocus picks it
|
||||
-- up without the player needing to tap the button again. Mod and save SAF
|
||||
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
|
||||
-- ROM pick cannot steal the focus path when both games are already ready.
|
||||
function RomImporter:focus(f)
|
||||
if not (f and self.android and self.workState ~= "working") then return end
|
||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||
if love.filesystem.getInfo("export_done.flag", "file") then
|
||||
love.filesystem.remove("export_done.flag")
|
||||
love.filesystem.remove("pending_export.sav")
|
||||
local version = self.androidPendingExportVersion or self:_savedropTarget()
|
||||
self.androidPendingExportVersion = nil
|
||||
self.saveNotice[version] = { ok = true, text = "Save exported." }
|
||||
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
love.filesystem.remove(modName)
|
||||
end
|
||||
return
|
||||
end
|
||||
local savName = findPendingSav(false)
|
||||
if savName then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
self.androidPendingVersion = nil
|
||||
self:_importSave(version, savName)
|
||||
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||
love.filesystem.remove(savName)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.ready.red and self.ready.blue then return end
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
@@ -672,13 +741,38 @@ function RomImporter:_installMod(source)
|
||||
self.tab = "mods"
|
||||
end
|
||||
|
||||
-- "Import mod .zip" button: open a native picker (desktop) and install the
|
||||
-- pick. Android has no picker, so it points the player at the drop path.
|
||||
-- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
|
||||
function RomImporter:_deleteMod(id)
|
||||
if self.workState == "working" then return end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local ok, res = LauncherMods.uninstall(id)
|
||||
if ok then
|
||||
self:_refreshMods()
|
||||
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
|
||||
else
|
||||
self.modNotice = { ok = false, text = tostring(res) }
|
||||
end
|
||||
end
|
||||
|
||||
-- "Import mod .zip" button: open a native picker and install the pick.
|
||||
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
|
||||
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
|
||||
-- which focus/Choose consumes on return.
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
self.modNotice =
|
||||
{ ok = false, text = "Drop a mod .zip onto the window to install it." }
|
||||
local name = findPendingMod(true)
|
||||
if name then
|
||||
self:_installMod(name)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
love.filesystem.remove(name)
|
||||
end
|
||||
return
|
||||
end
|
||||
if not love.system.pickFile("mod") then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Could not open the file picker. Copy a mod .zip via USB." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local path = chooseZip()
|
||||
@@ -722,13 +816,26 @@ function RomImporter:_importSave(version, source)
|
||||
end
|
||||
end
|
||||
|
||||
-- "Import save" button: open a native .sav picker (desktop) and import the pick.
|
||||
-- Android has no picker, so it points the player at the drop path.
|
||||
-- "Import save" button: open a native .sav picker and import the pick.
|
||||
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
self.saveNotice[version] =
|
||||
{ ok = false, text = "Drop a .sav onto the window to import it." }
|
||||
local name = findPendingSav(true)
|
||||
if name then
|
||||
self.androidPendingVersion = version
|
||||
self:_importSave(version, name)
|
||||
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||
love.filesystem.remove(name)
|
||||
end
|
||||
return
|
||||
end
|
||||
self.androidPendingVersion = version
|
||||
if not love.system.pickFile("sav") then
|
||||
self.androidPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false,
|
||||
text = "Could not open the file picker. Copy a .sav via USB." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local path = chooseSav()
|
||||
@@ -736,16 +843,58 @@ function RomImporter:chooseSaveImport(version)
|
||||
end
|
||||
|
||||
-- "Export save" button: write the active slot back out to a raw .sav in the save
|
||||
-- directory's exports/ folder and show the path (with an open-folder affordance)
|
||||
-- on the SAVE FILES card.
|
||||
-- directory's exports/ folder. On desktop, show the path with an open-folder
|
||||
-- affordance. On Android, stage pending_export.sav and open the system
|
||||
-- create-document picker (love.system.createFile) so the player can save to
|
||||
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
|
||||
function RomImporter:exportSave(version)
|
||||
if self.workState == "working" then return end
|
||||
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
|
||||
if ok then
|
||||
local dir = res:match("^(.*)[/\\][^/\\]+$")
|
||||
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
|
||||
else
|
||||
if not ok then
|
||||
self.saveNotice[version] = { ok = false, text = tostring(res) }
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local rel = res:match("exports[/\\][^/\\]+$")
|
||||
local data = rel and love.filesystem.read(rel)
|
||||
if not data then
|
||||
self.saveNotice[version] = { ok = false,
|
||||
text = "Exported, but could not stage the file for the picker." }
|
||||
return
|
||||
end
|
||||
local suggested = rel:match("[^/\\]+$") or "export.sav"
|
||||
local wrote, writeErr = love.filesystem.write("pending_export.sav", data)
|
||||
if not wrote then
|
||||
self.saveNotice[version] = { ok = false,
|
||||
text = "Could not stage the export: " .. tostring(writeErr) }
|
||||
return
|
||||
end
|
||||
self.androidPendingExportVersion = version
|
||||
if love.system.createFile and love.system.createFile(suggested) then
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Pick where to save " .. suggested .. "..." }
|
||||
else
|
||||
self.androidPendingExportVersion = nil
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Exported inside the app folder (picker unavailable)." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local dir = res:match("^(.*)[/\\][^/\\]+$")
|
||||
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
|
||||
end
|
||||
|
||||
-- Delete a save slot from the registry and disk, then refresh the panel. If the
|
||||
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
|
||||
function RomImporter:_deleteSlot(version, id)
|
||||
if self.workState == "working" then return end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ok, err = SaveData.deleteSlot(version, id)
|
||||
if ok then
|
||||
self:_refreshSlots(version)
|
||||
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
|
||||
else
|
||||
self.saveNotice[version] = { ok = false, text = tostring(err) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1427,9 +1576,17 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag
|
||||
-- commits it on release when the pointer did not move (a moved pointer scrolls
|
||||
-- instead). Android has no reliable pointer polling, so it selects on press.
|
||||
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
|
||||
-- label never also selects the row. On desktop a press only ARMS a click:
|
||||
-- _updateSlotDrag commits it on release when the pointer did not move (a
|
||||
-- moved pointer scrolls instead). Android has no reliable pointer polling,
|
||||
-- so it selects on press. Delete fires immediately (small fixed target).
|
||||
for _, r in ipairs(self.slotDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteSlot(self.panelVersion, r.id)
|
||||
return
|
||||
end
|
||||
end
|
||||
for _, r in ipairs(self.slotRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
if self.android then
|
||||
@@ -1445,12 +1602,18 @@ function RomImporter:mousepressed(x, y, button)
|
||||
self:_newSlot(self.panelVersion); return
|
||||
end
|
||||
-- Mods panel: the import button dispatches on press (fixed header, no scroll
|
||||
-- conflict); a toggle switch, which lives in the scrollable list, only ARMS a
|
||||
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with
|
||||
-- no pointer polling, toggles on press).
|
||||
-- conflict); Delete fires immediately; a toggle switch, which lives in the
|
||||
-- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
|
||||
-- a drag-scroll (Android, with no pointer polling, toggles on press).
|
||||
if inside(self.modImportRect, x, y) then
|
||||
self:chooseMod(); return
|
||||
end
|
||||
for _, r in ipairs(self.modDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteMod(r.id)
|
||||
return
|
||||
end
|
||||
end
|
||||
for _, r in ipairs(self.modRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
if self.android then
|
||||
@@ -1759,7 +1922,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
elseif locked then
|
||||
sfHintText, sfHintCol = "Not available yet.", PAL.warning
|
||||
elseif self.android then
|
||||
sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning
|
||||
sfHintText, sfHintCol =
|
||||
"Import or export a .sav with the system file picker.", PAL.warning
|
||||
else
|
||||
sfHintText, sfHintCol =
|
||||
"Import a .sav to a new slot, or export the active slot.", PAL.warning
|
||||
@@ -1999,6 +2163,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
love.graphics.printf("No saves yet - start a new game or import one.",
|
||||
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
|
||||
rw - 24 * s, "center")
|
||||
self.slotRects = {}
|
||||
self.slotDeleteRects = {}
|
||||
elseif listH > 0 then
|
||||
local nameH = self.slotNameFont:getHeight()
|
||||
local metaH = self.labelFont:getHeight()
|
||||
@@ -2017,6 +2183,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
self.slotScroll[version] = scroll
|
||||
|
||||
self.slotRects = {}
|
||||
self.slotDeleteRects = {}
|
||||
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
||||
math.ceil(rw), math.ceil(listH))
|
||||
for i, slot in ipairs(slots) do
|
||||
@@ -2029,6 +2196,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
|
||||
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
|
||||
|
||||
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
|
||||
love.graphics.setFont(self.hintFont)
|
||||
local delText = "Delete"
|
||||
local delW = self.hintFont:getWidth(delText)
|
||||
local delH = self.hintFont:getHeight()
|
||||
local delX = rx + rw - 12 * s - delW
|
||||
local delY = ry + rowH - rowPadV - delH
|
||||
local drect = { x = delX - 6 * s, y = delY - 4 * s,
|
||||
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
|
||||
local dhot = self:_hover(drect)
|
||||
col(dhot and PAL.red or PAL.warning)
|
||||
love.graphics.print(delText, delX, delY)
|
||||
local rightReserve = delW + 18 * s
|
||||
|
||||
-- LOADED pill (top-right of the active row), then reserve its width
|
||||
local pillW = 0
|
||||
if selected then
|
||||
@@ -2048,7 +2229,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
col(PAL.white)
|
||||
local name = slot.name or "NEW GAME"
|
||||
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW),
|
||||
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
|
||||
rx + 12 * s, ry + rowPadV)
|
||||
|
||||
local metaTxt
|
||||
@@ -2061,7 +2242,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
end
|
||||
love.graphics.setFont(self.labelFont)
|
||||
col(PAL.warning)
|
||||
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s),
|
||||
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve),
|
||||
rx + 12 * s, ry + rowPadV + nameH + 4 * s)
|
||||
|
||||
-- clip the hit rect to the visible list band so a partly-scrolled row
|
||||
@@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
self.slotRects[#self.slotRects + 1] =
|
||||
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
|
||||
end
|
||||
local dvy = math.max(drect.y, listTop)
|
||||
local dvy2 = math.min(drect.y + drect.height, listBottom)
|
||||
if dvy2 > dvy then
|
||||
self.slotDeleteRects[#self.slotDeleteRects + 1] =
|
||||
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
|
||||
end
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
@@ -2165,14 +2352,14 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
|
||||
local top = y + headerH + 14 * s
|
||||
|
||||
-- notice line: the last install result, else the drag-drop hint
|
||||
-- notice line: the last install/delete result, else the platform hint
|
||||
love.graphics.setFont(self.hintFont)
|
||||
if self.modNotice then
|
||||
col(self.modNotice.ok and PAL.green or PAL.red)
|
||||
love.graphics.printf(self.modNotice.text, x, top, w, "left")
|
||||
else
|
||||
col(PAL.warning)
|
||||
love.graphics.printf(self.android and "Copy a mod .zip via USB."
|
||||
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
|
||||
or "Or drop a mod .zip onto the window.", x, top, w, "left")
|
||||
end
|
||||
top = top + self.hintFont:getHeight() + 12 * s
|
||||
@@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(PAL.warning)
|
||||
love.graphics.printf("No mods installed - drop a mod .zip here to add one.",
|
||||
local emptyHint = self.android
|
||||
and "No mods installed - tap Import mod .zip to add one."
|
||||
or "No mods installed - drop a mod .zip here to add one."
|
||||
love.graphics.printf(emptyHint,
|
||||
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
|
||||
self.modRects = {}
|
||||
self.modDeleteRects = {}
|
||||
self._modMax = 0
|
||||
return
|
||||
end
|
||||
|
||||
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28)
|
||||
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
|
||||
local padH, padV = 16 * s, 14 * s
|
||||
local cardGap, cardR = 10 * s, 14 * s
|
||||
local tw, th = 52 * s, 28 * s
|
||||
local innerW = w - 2 * padH
|
||||
local chipH = self.hintFont:getHeight() + 8 * s
|
||||
local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle
|
||||
local delH = self.hintFont:getHeight()
|
||||
local clusterH = chipH + 6 * s + th + 6 * s + delH
|
||||
|
||||
love.graphics.setFont(self.stateFont)
|
||||
local nameH = self.stateFont:getHeight()
|
||||
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
for i, m in ipairs(mods) do
|
||||
local chipText = modStatusChip(m.status)
|
||||
local chipW = self.hintFont:getWidth(chipText) + 20 * s
|
||||
local clusterW = math.max(chipW, tw)
|
||||
local delW = self.hintFont:getWidth("Delete")
|
||||
local clusterW = math.max(chipW, tw, delW)
|
||||
local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
|
||||
local descH = 0
|
||||
if m.description ~= "" then
|
||||
@@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
|
||||
local cardH = math.max(contentH, padV * 2 + clusterH)
|
||||
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
|
||||
chipText = chipText, chipW = chipW }
|
||||
chipText = chipText, chipW = chipW, delW = delW }
|
||||
total = total + cardH
|
||||
end
|
||||
total = total + (#mods - 1) * cardGap
|
||||
@@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
local scroll = clamp(self.modScroll or 0, 0, maxScroll)
|
||||
self.modScroll = scroll
|
||||
self.modRects = {}
|
||||
self.modDeleteRects = {}
|
||||
|
||||
love.graphics.setScissor(math.floor(x), math.floor(top),
|
||||
math.ceil(w), math.ceil(listH))
|
||||
@@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
|
||||
end
|
||||
|
||||
-- right cluster: status chip stacked over the toggle, vertically centred
|
||||
-- right cluster: status chip, toggle, Delete — vertically centred
|
||||
local clusterX = x + w - padH - L.clusterW
|
||||
local clusterY = cy + (cardH - clusterH) / 2
|
||||
local _, chipColor = modStatusChip(m.status)
|
||||
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
col(PAL.white)
|
||||
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
|
||||
|
||||
-- hit rect clipped to the visible list band (a partly-scrolled toggle is
|
||||
-- only clickable where it actually shows)
|
||||
-- Delete under the toggle
|
||||
local delX = clusterX + (L.clusterW - L.delW) / 2
|
||||
local delY = ty + th + 6 * s
|
||||
local drect = { x = delX - 6 * s, y = delY - 2 * s,
|
||||
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
|
||||
local dhot = self:_hover(drect)
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(dhot and PAL.red or PAL.warning)
|
||||
love.graphics.print("Delete", delX, delY)
|
||||
|
||||
-- hit rects clipped to the visible list band
|
||||
local vy = math.max(trect.y, top)
|
||||
local vy2 = math.min(trect.y + trect.height, top + listH)
|
||||
if vy2 > vy then
|
||||
self.modRects[#self.modRects + 1] =
|
||||
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
|
||||
end
|
||||
local dvy = math.max(drect.y, top)
|
||||
local dvy2 = math.min(drect.y + drect.height, top + listH)
|
||||
if dvy2 > dvy then
|
||||
self.modDeleteRects[#self.modDeleteRects + 1] =
|
||||
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
|
||||
end
|
||||
end
|
||||
cy = cy + cardH + cardGap
|
||||
end
|
||||
|
||||
@@ -47,11 +47,19 @@ local function readSource(source)
|
||||
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
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||
return data
|
||||
end
|
||||
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
|
||||
-- directory; io.open cannot see them, so fall back to love.filesystem.
|
||||
if love and love.filesystem and love.filesystem.read then
|
||||
local data = love.filesystem.read(source)
|
||||
if type(data) == "string" then return data end
|
||||
end
|
||||
return nil, "could not read the save file: " .. tostring(openErr)
|
||||
end
|
||||
|
||||
-- importToSlot(source, version) -> ok, slotIdOrErr
|
||||
|
||||
@@ -31,7 +31,12 @@ local function indexOf(list, value)
|
||||
end
|
||||
|
||||
local function levelForWire(v)
|
||||
return v == ANY and nil or v
|
||||
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||
-- true branch is nil, so it falls through to `or v` and returned the
|
||||
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||
if v == ANY then return nil end
|
||||
return v
|
||||
end
|
||||
|
||||
local function forceLevelLabel(v)
|
||||
@@ -493,6 +498,14 @@ function LinkState:updateTrade(input)
|
||||
if t.stage == "done" then
|
||||
local sent = t.party[t.myPick]
|
||||
local received, evoTo = t:apply(self.game)
|
||||
-- Autosave the instant the swap commits into game.save.party, matching the
|
||||
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
|
||||
-- (engine/menus/save.asm) right after every trade so the trade is on the
|
||||
-- cartridge before the animation runs. Without this the received mon
|
||||
-- lives only in memory until a manual START-menu save, so a force-quit
|
||||
-- would lose it and a reset would clone the sent mon (#222). Guarded so
|
||||
-- headless LinkBattle-style fake games with no writeSave are unaffected.
|
||||
if self.game.writeSave then self.game:writeSave() end
|
||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||
Runtime.emit("link.ended", { reason = "done" })
|
||||
self.net:close()
|
||||
@@ -510,7 +523,13 @@ function LinkState:updateTrade(input)
|
||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||
function()
|
||||
if evoTo then
|
||||
require("src.pokemon.Evolution").evolve(game, received, evoTo)
|
||||
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
|
||||
-- Re-save once the evolution movie finishes so the evolved
|
||||
-- species (not the pre-evo landed by t:apply) is what persists,
|
||||
-- keeping disk in step with the autosave above (#222).
|
||||
require("src.pokemon.Evolution").evolve(game, received, evoTo,
|
||||
function() if game.writeSave then game:writeSave() end end, "TRADE")
|
||||
end
|
||||
end))
|
||||
end,
|
||||
|
||||
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
|
||||
|
||||
-- serialize a mon instance for the wire (plain data only). ppUps rides
|
||||
-- along because the real cable transmitted it and its absence silently
|
||||
-- capped a PP-Upped move at base PP on the receiving side.
|
||||
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
|
||||
-- along too: pokered's trade sends the whole party block including each
|
||||
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
|
||||
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
|
||||
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
|
||||
-- a received mon show the receiver as its OT (#215).
|
||||
function Protocol.packMon(mon)
|
||||
local moves = {}
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
|
||||
dvs = mon.dvs,
|
||||
statExp = mon.statExp,
|
||||
moves = moves,
|
||||
ot = mon.ot,
|
||||
otId = mon.otId,
|
||||
extra = plainCopy(mon.extra),
|
||||
}
|
||||
end
|
||||
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local strict = opts and opts.strict
|
||||
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
||||
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
||||
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
||||
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
|
||||
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
|
||||
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
|
||||
-- tonumber(50)/tonumber("50") both give 50.
|
||||
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||
local def = data.pokemon[packed.species]
|
||||
if not def then
|
||||
if strict then return nil, "unknown POKéMON" end
|
||||
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||
-- the identical forceLevel for a given match, so this stays symmetric.
|
||||
if opts and opts.forceLevel then
|
||||
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
|
||||
if forceLevel then
|
||||
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||
end
|
||||
local dvs = {}
|
||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
-- current HP/status (a different level's numbers, possibly mid-fight)
|
||||
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
|
||||
-- same as a standardized tournament format would
|
||||
local forced = opts and opts.forceLevel
|
||||
local forced = forceLevel
|
||||
local hp = forced and stats.hp
|
||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
||||
local status = forced and nil or packed.status
|
||||
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
||||
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
||||
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
||||
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
||||
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
||||
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
||||
local otId = packed.otId
|
||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||
return {
|
||||
species = packed.species,
|
||||
level = level,
|
||||
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
hp = hp,
|
||||
status = status,
|
||||
nickname = packed.nickname,
|
||||
ot = ot,
|
||||
otId = otId,
|
||||
moves = moves,
|
||||
-- a namespace whose mod this install lacks survives untouched, so the
|
||||
-- mon keeps it for the trip home
|
||||
|
||||
@@ -57,7 +57,12 @@ local function levelLabel(v)
|
||||
end
|
||||
|
||||
local function levelForWire(v)
|
||||
return v == ANY and nil or v
|
||||
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||
-- true branch is nil, so it falls through to `or v` and returned the
|
||||
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||
if v == ANY then return nil end
|
||||
return v
|
||||
end
|
||||
|
||||
local function forceLevelLabel(v)
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
-- 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.
|
||||
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
|
||||
-- uninstalls a mod by removing that tree + clearing options.mods[id].
|
||||
--
|
||||
-- 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.
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
||||
-- install, and uninstall paths reach for love.filesystem and SaveData.
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
|
||||
return true, manifest.id
|
||||
end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
|
||||
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "missing mod id"
|
||||
end
|
||||
if id:find("[/\\]") or id == "." or id == ".." then
|
||||
return nil, "invalid mod id"
|
||||
end
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "mod uninstall needs LOVE"
|
||||
end
|
||||
local fs = love.filesystem
|
||||
local dest = "mods/" .. id
|
||||
if not fs.getInfo(dest) then
|
||||
return nil, "mod '" .. id .. "' is not installed"
|
||||
end
|
||||
removeTree(dest)
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
local options = SaveData.loadOptions()
|
||||
if options.mods and options.mods[id] ~= nil then
|
||||
options.mods[id] = nil
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return LauncherMods
|
||||
|
||||
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
local SUPPORTED_REQUIRES = {
|
||||
["src.mods.Semver"] = true,
|
||||
["src.audio.ChipAsm"] = true,
|
||||
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
|
||||
}
|
||||
|
||||
local function scanRequire(name)
|
||||
|
||||
@@ -106,11 +106,55 @@ function Evolution.apply(game, mon, newSpecies, via)
|
||||
})
|
||||
end
|
||||
|
||||
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
|
||||
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
|
||||
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
|
||||
-- evolving at exactly a learnset level gains that move (GYARADOS learns
|
||||
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
|
||||
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
|
||||
-- list opens the forget prompt. mon.species is already the new species
|
||||
-- (Evolution.apply ran before the congrats text). onDone runs once the
|
||||
-- learn list is exhausted, replacing the caller's direct onDone.
|
||||
function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
local Experience = require("src.battle.Experience")
|
||||
local def = game.data.pokemon[mon.species]
|
||||
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
|
||||
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
|
||||
local moves = Experience.movesLearnedAt(def, mon.level)
|
||||
local i = 0
|
||||
local function nextStep()
|
||||
i = i + 1
|
||||
local moveId = moves[i]
|
||||
if not moveId then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == moveId then return nextStep() end
|
||||
end
|
||||
local mdef = game.data.moves[moveId]
|
||||
if not mdef then return nextStep() end
|
||||
local name = mon.nickname or def.name
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s learned\n%s!"):format(name, mdef.name), nextStep))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
end
|
||||
end
|
||||
nextStep()
|
||||
end
|
||||
|
||||
-- Play the evolution movie (flashing forms), then apply + text.
|
||||
-- Headless (no real graphics) falls back to the plain text flow.
|
||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
if love.image and love.image.newImageData then
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
|
||||
-- forward `via` so EvolutionState can keep trade evolutions
|
||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
||||
return
|
||||
end
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
@@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
if onDone then onDone() end
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -138,13 +184,22 @@ function Evolution.request(game, mon, trigger, onDone)
|
||||
return species
|
||||
end
|
||||
|
||||
-- After-battle hook: evolve everyone who qualifies (queued one at a time).
|
||||
function Evolution.checkParty(game, onDone)
|
||||
-- After-battle hook: evolve mons that leveled this battle and still
|
||||
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
|
||||
-- only considers mons that gained a level during the fight — a B-cancel
|
||||
-- means "not this time", and the next offer waits for the next level-up
|
||||
-- (or Rare Candy / stone, which call Evolution.evolve directly).
|
||||
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
|
||||
function Evolution.checkParty(game, onDone, leveledUp)
|
||||
local pending = {}
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
|
||||
if target then
|
||||
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
|
||||
if leveledUp then
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
if leveledUp[mon] then
|
||||
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
|
||||
if target then
|
||||
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local i = 0
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Runtime Pokémon art resolution. Content registries freeze after load,
|
||||
-- so a mod that lets the player pick an alternate skin mid-session cannot
|
||||
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
||||
-- sanctioned seam: every battle pic and party icon load goes through
|
||||
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Sprites = {}
|
||||
|
||||
local function samePath(path) return path end
|
||||
|
||||
-- Resolve a battle / menu front or back pic path for `species`.
|
||||
-- side: "front" | "back"
|
||||
-- opts.mon: the live mon when available (per-instance skins)
|
||||
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.path(data, species, side, opts)
|
||||
opts = opts or {}
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if not def then return nil, false end
|
||||
local path = side == "back" and def.spriteBack or def.spriteFront
|
||||
local ctx = {
|
||||
species = species,
|
||||
side = side == "back" and "back" or "front",
|
||||
kind = opts.kind or "battle",
|
||||
mon = opts.mon,
|
||||
trueColor = def.trueColor and true or false,
|
||||
data = data,
|
||||
}
|
||||
if path and Runtime.wantsHook("pokemon.sprite") then
|
||||
local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx)
|
||||
if type(hooked) == "string" and hooked ~= "" then path = hooked end
|
||||
end
|
||||
return path, ctx.trueColor and true or false
|
||||
end
|
||||
|
||||
-- Resolve a party-menu icon image path for `mon`.
|
||||
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
||||
-- def.icon / icons.byDex; the hook may replace it.
|
||||
-- Returns path (possibly nil).
|
||||
function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||
opts = opts or {}
|
||||
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
|
||||
return vanillaPath
|
||||
end
|
||||
local species = mon and mon.species
|
||||
local ctx = {
|
||||
species = species,
|
||||
mon = mon,
|
||||
name = opts.name,
|
||||
data = data,
|
||||
kind = "icon",
|
||||
}
|
||||
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
|
||||
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
|
||||
if type(hooked) == "string" and hooked ~= "" then return hooked end
|
||||
if hooked == nil or hooked == false then return nil end
|
||||
return vanillaPath
|
||||
end
|
||||
|
||||
return Sprites
|
||||
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
|
||||
return math.max(1, math.min(999, v))
|
||||
end
|
||||
|
||||
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
|
||||
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
|
||||
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
|
||||
local SHINY_ATK = {
|
||||
[2] = true, [3] = true, [6] = true, [7] = true,
|
||||
[10] = true, [11] = true, [14] = true, [15] = true,
|
||||
}
|
||||
|
||||
function Stats.isShiny(dvs)
|
||||
if type(dvs) ~= "table" then return false end
|
||||
return (dvs.defense or 0) == 10
|
||||
and (dvs.speed or 0) == 10
|
||||
and (dvs.special or 0) == 10
|
||||
and SHINY_ATK[dvs.attack or 0] == true
|
||||
end
|
||||
|
||||
return Stats
|
||||
|
||||
@@ -77,7 +77,17 @@ end
|
||||
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
|
||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||
-- red).
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
||||
--
|
||||
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
||||
-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by
|
||||
-- the region palette (engine/gfx/palettes.asm SetPal_Battle,
|
||||
-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint.
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
local x, y = tx * 8, ty * 8
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
@@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
if not grayFill then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
end
|
||||
end
|
||||
for i = 0, 5 do
|
||||
local seg = math.min(8, math.max(0, px - i * 8))
|
||||
|
||||
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
|
||||
}
|
||||
|
||||
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
|
||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
|
||||
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
|
||||
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
|
||||
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
|
||||
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
|
||||
-- the green-character look on a Game Boy Color.
|
||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
|
||||
-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
|
||||
-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
|
||||
-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
|
||||
-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
|
||||
-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel
|
||||
-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the
|
||||
-- premise that Red and Blue "share the green-character look"; both premises
|
||||
-- are wrong -- Blue's background is a genuinely different blue and its
|
||||
-- characters are pink (#155). Lightest shade first, like GBC_BG.
|
||||
PaletteFX.GBC_BG_BLUE = {
|
||||
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
|
||||
{ 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 },
|
||||
}
|
||||
-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG
|
||||
-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead.
|
||||
PaletteFX.GBC_OBJ_BLUE = {
|
||||
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
-- The active game's OG boot-ROM background palette: blue for a Blue
|
||||
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
|
||||
return PaletteFX.GBC_BG
|
||||
end
|
||||
|
||||
-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a
|
||||
-- Blue playthrough, Red's green otherwise. Returns the colors AND a
|
||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
|
||||
function PaletteFX.shader()
|
||||
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
|
||||
end
|
||||
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
|
||||
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
|
||||
-- NPCs stay green over the red background, exactly like Pokemon Red on a
|
||||
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
|
||||
-- palette of its own, so the characters tint with the whole-map region
|
||||
-- palette along with the terrain (the Super Game Boy never colored Pokemon
|
||||
-- Red's sprites separately -- baking a per-sprite palette there was the
|
||||
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
|
||||
-- the usesGbcPack() path in SpriteRenderer instead.
|
||||
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
|
||||
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
|
||||
-- background, pink over Blue's blue background), so the player and NPCs carry a
|
||||
-- fixed object color instead of tinting with whatever region palette their
|
||||
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
|
||||
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
|
||||
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
|
||||
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
|
||||
-- = light-blue and the character clashed with the grass it should blend into).
|
||||
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
|
||||
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
|
||||
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
|
||||
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
|
||||
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
|
||||
-- regression. RED++ colors sprites through the usesGbcPack() path in
|
||||
-- SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
return (mode or PaletteFX.mode) == "ogred"
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred" or mode == "gbc"
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (GBC mode)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Render targets measured in real framebuffer pixels.
|
||||
--
|
||||
-- love.graphics.newCanvas defaults its `dpiscale` to
|
||||
-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is
|
||||
-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a
|
||||
-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene
|
||||
-- then renders into it at 2.755 texels per GB pixel. conf.lua sets
|
||||
-- t.window.highdpi on Android/iOS (required for Retina), and Android's
|
||||
-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this
|
||||
-- is the normal mobile case, not an edge case.
|
||||
--
|
||||
-- That fractional render breaks the whole premise of Renderer's integer
|
||||
-- scale pipeline. Renderer:fitScale() picks a whole number of physical
|
||||
-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it
|
||||
-- exactly -- but if the *source* already holds 2.755 texels per GB pixel,
|
||||
-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour
|
||||
-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7.
|
||||
-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out
|
||||
-- the right width. Fonts show it worst: their strokes are single pixels.
|
||||
-- That is issue #208 ("some are noticeably stretched, especially fonts").
|
||||
--
|
||||
-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already
|
||||
-- believes it is drawing. getWidth()/getHeight() are unaffected (they always
|
||||
-- reported the requested size), so no geometry anywhere changes -- only the
|
||||
-- resolution of the target. Desktop is unchanged; dpiscale is already 1
|
||||
-- there because conf.lua sets highdpi on mobile only.
|
||||
--
|
||||
-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted
|
||||
-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI
|
||||
-- scale so its texture still covers the framebuffer; it is not built with
|
||||
-- this helper.
|
||||
|
||||
local PixelCanvas = {}
|
||||
|
||||
-- One framebuffer pixel per w/h unit, always. `filter` is applied only when
|
||||
-- given, so callers that relied on LOVE's default ("linear") keep it.
|
||||
function PixelCanvas.new(w, h, filter)
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
if filter and canvas and canvas.setFilter then
|
||||
canvas:setFilter(filter, filter)
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
return PixelCanvas
|
||||
@@ -10,6 +10,8 @@ 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 PixelCanvas = require("src.render.PixelCanvas")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -39,32 +41,49 @@ end
|
||||
-- endFrame composites the padded canvas back with a matching offset.
|
||||
Renderer.UPRIGHT_MARGIN = 160
|
||||
|
||||
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
|
||||
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
|
||||
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
|
||||
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
|
||||
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
||||
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
||||
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
|
||||
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
|
||||
-- Integer scaling in units then maps each GB pixel to a fractional number of
|
||||
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
|
||||
-- judder (issue #87). Always derive the crisp integer scale from the
|
||||
-- *drawable* pixel size (the window framebuffer we present into -- not a
|
||||
-- combined multi-display metric), then draw with (pixels / axisDpi) so the
|
||||
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||
--
|
||||
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
|
||||
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
|
||||
-- highdpi surface, but diverge when unit sizes are truncated independently
|
||||
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
|
||||
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
|
||||
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
|
||||
-- for both axes makes the other axis land on a fractional, stretched count.
|
||||
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
||||
-- on BOTH axes (square).
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
local dpi = 1
|
||||
if ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
elseif love.graphics.getDPIScale then
|
||||
dpi = love.graphics.getDPIScale()
|
||||
local dpiX, dpiY = 1, 1
|
||||
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
||||
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
||||
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
|
||||
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
|
||||
and love.graphics.getDPIScale then
|
||||
local d = love.graphics.getDPIScale()
|
||||
if d and d > 1e-6 then dpiX, dpiY = d, d end
|
||||
end
|
||||
if not dpi or dpi < 1e-6 then dpi = 1 end
|
||||
return ww, wh, pw, ph, dpi
|
||||
if dpiX < 1e-6 then dpiX = 1 end
|
||||
if dpiY < 1e-6 then dpiY = 1 end
|
||||
return ww, wh, pw, ph, dpiX, dpiY
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
||||
self.canvas:setFilter("nearest", "nearest")
|
||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||
-- reason -- worldViewSize() already works in drawable pixels.
|
||||
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
|
||||
self.worldCanvas = nil
|
||||
self.worldActive = false
|
||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||
@@ -93,21 +112,43 @@ end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
||||
-- units via / dpi when drawing.
|
||||
-- units via / dpiX and / dpiY when drawing.
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
end
|
||||
|
||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
||||
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
|
||||
-- the projected ground plane still covers the whole window with no
|
||||
-- background peeking at the receded top/bottom corners; flat mode returns
|
||||
-- exactly today's size (growth factor is 1 when tilt is inactive).
|
||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
||||
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
||||
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
|
||||
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
|
||||
function Renderer:drawScaleX()
|
||||
local _, _, _, _, dpiX = displayMetrics()
|
||||
return self:fitScale() / dpiX
|
||||
end
|
||||
|
||||
function Renderer:drawScaleY()
|
||||
local _, _, _, _, _, dpiY = displayMetrics()
|
||||
return self:fitScale() / dpiY
|
||||
end
|
||||
|
||||
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
|
||||
function Renderer:drawScale()
|
||||
return self:drawScaleX()
|
||||
end
|
||||
|
||||
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
|
||||
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
|
||||
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
|
||||
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
|
||||
-- the whole window with no background peeking at the receded top/bottom
|
||||
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||
-- tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local ww, wh, _, _, dpi = displayMetrics()
|
||||
local s = Zoom.scale(self:fitScale()) / dpi
|
||||
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
local sp = Zoom.scale(self:fitScale())
|
||||
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
||||
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||
@@ -145,21 +186,24 @@ end
|
||||
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
||||
-- into the surrounding window, matching BattleTransition wipe progress.
|
||||
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
||||
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
|
||||
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
||||
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
if not prog or prog <= 0 then return end
|
||||
local TILE = 8 * S
|
||||
if TILE < 1 then TILE = 1 end
|
||||
local cols = math.ceil(ww / TILE)
|
||||
local rows = math.ceil(wh / TILE)
|
||||
Sy = Sy or Sx
|
||||
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
|
||||
if TILE_W < 1 then TILE_W = 1 end
|
||||
if TILE_H < 1 then TILE_H = 1 end
|
||||
local cols = math.ceil(ww / TILE_W)
|
||||
local rows = math.ceil(wh / TILE_H)
|
||||
local cx, cy = ox + vpw / 2, oy + vph / 2
|
||||
local order = {}
|
||||
for row = 0, rows - 1 do
|
||||
for col = 0, cols - 1 do
|
||||
local x, y = col * TILE, row * TILE
|
||||
local x, y = col * TILE_W, row * TILE_H
|
||||
-- any tile with area outside the letterbox participates
|
||||
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
|
||||
local dist = math.max(math.abs(x + TILE / 2 - cx),
|
||||
math.abs(y + TILE / 2 - cy))
|
||||
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
|
||||
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
|
||||
math.abs(y + TILE_H / 2 - cy))
|
||||
order[#order + 1] = { x, y, dist }
|
||||
end
|
||||
end
|
||||
@@ -176,7 +220,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
for i = 1, n do
|
||||
local t = order[i]
|
||||
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
|
||||
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -190,8 +234,7 @@ function Renderer:beginWorldPass()
|
||||
-- the view size every frame, so without this the superseded canvases
|
||||
-- pile up in VRAM until a GC finalizer happens to run
|
||||
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
||||
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
||||
self.worldCanvas:setFilter("nearest", "nearest")
|
||||
self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
|
||||
end
|
||||
self.worldActive = true
|
||||
PaletteFX.setPass("world")
|
||||
@@ -219,8 +262,7 @@ function Renderer:beginUprightPass()
|
||||
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
||||
or self.uprightCanvas:getHeight() ~= ch then
|
||||
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
||||
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
||||
self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
|
||||
end
|
||||
self.uprightActive = true
|
||||
PaletteFX.setPass(nil)
|
||||
@@ -299,10 +341,11 @@ end
|
||||
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
||||
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
||||
-- back to the flat blit unchanged.
|
||||
function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
||||
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
local shader = self:tiltShader()
|
||||
local mesh = self:tiltMesh()
|
||||
if not (shader and mesh) then return false end
|
||||
sy = sy or sx
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
|
||||
@@ -313,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
||||
or self.tiltCanvas:getHeight() ~= wvh then
|
||||
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
||||
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
||||
self.tiltCanvas:setFilter("linear", "linear")
|
||||
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
|
||||
end
|
||||
|
||||
love.graphics.setCanvas(self.tiltCanvas)
|
||||
@@ -351,7 +393,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
||||
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
||||
love.graphics.push()
|
||||
love.graphics.translate(wox, woy)
|
||||
love.graphics.scale(s, s)
|
||||
love.graphics.scale(sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
love.graphics.draw(mesh)
|
||||
@@ -393,14 +435,15 @@ end
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas()
|
||||
local ww, wh, pw, ph, dpi = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
|
||||
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel;
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
local Sp = self:fitScale()
|
||||
local S = Sp / dpi
|
||||
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
||||
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -422,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
or self.presentCanvas:getHeight() ~= wh then
|
||||
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
|
||||
-- units and blitted back at unit scale 1 (and handed to mod present
|
||||
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
|
||||
-- texture to cover the framebuffer. Everything composited into it is
|
||||
-- already native-resolution now, so #208's fractional source is gone;
|
||||
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
|
||||
-- one seam across the window) that a single scalar dpiscale cannot
|
||||
-- express.
|
||||
self.presentCanvas = love.graphics.newCanvas(ww, wh)
|
||||
self.presentCanvas:setFilter("linear", "linear")
|
||||
end
|
||||
@@ -444,15 +495,27 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||
-- canvas so the playfield sits on top of the border.
|
||||
if Runtime.wantsHook("render.letterbox") then
|
||||
Runtime.call("render.letterbox", function() end, {
|
||||
ww = ww, wh = wh, pw = pw, ph = ph,
|
||||
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
worldActive = self.worldActive and true or false,
|
||||
})
|
||||
end
|
||||
|
||||
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
|
||||
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
||||
-- coords (canvas-space) into screen units.
|
||||
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||
-- convert zone coords (canvas-space) into screen units.
|
||||
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
bx, by, boxX, boxY, boxW, boxH)
|
||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if not shader then
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, scale, scale)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
love.graphics.setScissor()
|
||||
return
|
||||
end
|
||||
@@ -468,10 +531,10 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setShader(not plain and shader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
|
||||
z.w * zoneScale, z.h * zoneScale,
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH) then
|
||||
love.graphics.draw(canvas, bx, by, 0, scale, scale)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
@@ -486,7 +549,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- 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.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
||||
love.graphics.setScissor()
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
@@ -496,15 +559,15 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
end
|
||||
elseif self.worldActive then
|
||||
local sp = Zoom.scale(Sp)
|
||||
local s = sp / dpi
|
||||
local sx, sy = sp / dpiX, sp / dpiY
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = math.floor((pw - wvw * sp) / 2) / dpi
|
||||
local woy = math.floor((ph - wvh * sp) / 2) / dpi
|
||||
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
|
||||
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
@@ -512,12 +575,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
||||
-- identical to today.
|
||||
local projected =
|
||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present)
|
||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
||||
if not projected then
|
||||
if worldZones then
|
||||
blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh)
|
||||
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
|
||||
else
|
||||
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
|
||||
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
|
||||
end
|
||||
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
||||
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
||||
@@ -538,11 +601,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
||||
if r.quad then
|
||||
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
|
||||
0, s * r.sx, s)
|
||||
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
|
||||
0, sx * r.sx, sy)
|
||||
else
|
||||
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
|
||||
0, s * r.sx, s)
|
||||
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
|
||||
0, sx * r.sx, sy)
|
||||
end
|
||||
end
|
||||
if activeShader then love.graphics.setShader() end
|
||||
@@ -559,7 +622,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local M = self.UPRIGHT_MARGIN
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
||||
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
-- Screen-space warp fade (Transition) over the full world composite so
|
||||
@@ -575,11 +638,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- Battle transition: cascade black blocks into the area outside the
|
||||
-- classic 160x144 wipe square (world still shows through until filled).
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
end
|
||||
end
|
||||
-- UI stays in the classic centered GB letterbox
|
||||
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
||||
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
@@ -589,7 +652,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- 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
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
|
||||
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
|
||||
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")
|
||||
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
|
||||
-- returns colors + a version-distinct cache group so the two never
|
||||
-- collide in obpCache) -- see issue #155
|
||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
end
|
||||
return self.image
|
||||
end
|
||||
@@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
image = getObpImage(self.def.image, colors, group)
|
||||
end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
|
||||
-- object palette. The red BG zone shader still runs over the world
|
||||
-- canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
|
||||
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
|
||||
-- palette -- green over Red's red background, pink over Blue's blue
|
||||
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
|
||||
-- the world canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
|
||||
-- top.
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
redraw = true
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
|
||||
@@ -542,7 +542,10 @@ end
|
||||
local function bakeBorderFill(self, block)
|
||||
local border = self.map.tileset.blocks[block + 1]
|
||||
if not border then return end
|
||||
local canvas = love.graphics.newCanvas(32, 32)
|
||||
-- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a
|
||||
-- fractional texel size and the repeat-wrapped image would then tile at
|
||||
-- non-square pixels (#208, see src/render/PixelCanvas.lua)
|
||||
local canvas = require("src.render.PixelCanvas").new(32, 32)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
|
||||
@@ -4,19 +4,38 @@
|
||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||
-- Spec: docs/new-features.md (survey zoom)
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Zoom = {}
|
||||
|
||||
Zoom.offset = 0
|
||||
|
||||
-- effective integer scale s' in [1, 2*S]
|
||||
function Zoom.scale(S)
|
||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
||||
end
|
||||
|
||||
-- legal offset range for a given fit scale
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
function Zoom.offsetRange(S)
|
||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||
return 1 - S, S
|
||||
local lo, hi = 1 - S, S
|
||||
if Runtime.wantsHook("zoom.range") then
|
||||
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
|
||||
lo = math.floor(tonumber(lo) or (1 - S))
|
||||
hi = math.floor(tonumber(hi) or S)
|
||||
if lo > hi then lo, hi = hi, lo end
|
||||
end
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
|
||||
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
|
||||
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
|
||||
function Zoom.scale(S)
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local s = S + Zoom.offset
|
||||
local minScale = S + lo
|
||||
local maxScale = math.max(minScale, S + hi)
|
||||
if s < minScale then s = minScale end
|
||||
if s > maxScale then s = maxScale end
|
||||
if s < 0.25 then s = 0.25 end
|
||||
return s
|
||||
end
|
||||
|
||||
function Zoom.clampOffset(offset, S)
|
||||
|
||||
@@ -214,11 +214,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
list:close()
|
||||
consume(game, id)
|
||||
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
||||
ow.player.surfing = false
|
||||
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
|
||||
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
|
||||
-- the last Pokémon Center town door like Fly (#196), via the shared
|
||||
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||
ow:beginTeleportOut()
|
||||
else
|
||||
showMessages(game, { "OAK: " .. game.save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" })
|
||||
@@ -303,7 +302,8 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
if not wantsMove then
|
||||
@@ -326,7 +326,17 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
})
|
||||
}
|
||||
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
||||
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
|
||||
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
|
||||
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
|
||||
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
|
||||
-- strictly on def.machine. #210
|
||||
if def and def.machine then
|
||||
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
|
||||
end
|
||||
require("src.ui.Screens").push(game, "PartyMenu", opts)
|
||||
end
|
||||
|
||||
local function useItem(game, battle, id, list)
|
||||
@@ -360,6 +370,7 @@ function BagMenu.new(game, opts)
|
||||
local battle = opts.battle
|
||||
local list
|
||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||
kind = "bag",
|
||||
footer = ("¥%d"):format(game.save.money),
|
||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||
onSelectKey = function(item, l)
|
||||
|
||||
@@ -140,8 +140,9 @@ function Credits:enter()
|
||||
end
|
||||
|
||||
function Credits:monSprite(species)
|
||||
local def = self.game.data.pokemon and self.game.data.pokemon[species]
|
||||
return silhouette(def and def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "credits" })
|
||||
return silhouette(path)
|
||||
end
|
||||
|
||||
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
|
||||
|
||||
@@ -34,7 +34,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
self.def = game.data.pokemon[species]
|
||||
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ kind = "dex" })
|
||||
local ok, img = path and pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- flashes back and forth with the evolved form, speeding up, then the
|
||||
-- new form appears with its cry and the congratulations text.
|
||||
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
|
||||
-- has no cancel; the flash always completes).
|
||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
|
||||
-- the flash: holding B aborts the evolution -- the mon keeps its species
|
||||
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
|
||||
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
|
||||
-- which skip that poll and cannot be cancelled (#213).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
@@ -14,7 +17,10 @@ EvolutionState.isOpaque = true
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function EvolutionState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local species = self.done and self.newSpecies or self.mon.species
|
||||
-- a cancelled evolution keeps the old species (never applied), so only
|
||||
-- colorize with the new form once it has actually evolved
|
||||
local species = (self.done and not self.canceled) and self.newSpecies
|
||||
or self.mon.species
|
||||
local c = P.monPal(game.data, species)
|
||||
if c then return { P.whole(c) } end
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
@@ -22,24 +28,30 @@ end
|
||||
|
||||
local FLASH_FRAMES = 220
|
||||
|
||||
local function frontSprite(game, species)
|
||||
local def = game.data.pokemon[species]
|
||||
if not (def and def.spriteFront) then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
||||
local function frontSprite(game, species, mon)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ mon = mon, kind = "evolution" })
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
function EvolutionState.new(game, mon, newSpecies, onDone)
|
||||
function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
local self = setmetatable({}, EvolutionState)
|
||||
self.game = game
|
||||
self.mon = mon
|
||||
self.newSpecies = newSpecies
|
||||
self.onDone = onDone
|
||||
self.via = via
|
||||
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
|
||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
||||
self.cancelable = (via ~= "TRADE")
|
||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
self.oldSprite = frontSprite(game, mon.species)
|
||||
self.newSprite = frontSprite(game, newSpecies)
|
||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
self.canceled = false
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
return self
|
||||
end
|
||||
@@ -47,11 +59,28 @@ end
|
||||
function EvolutionState:update(dt)
|
||||
self.t = self.t + 1
|
||||
if self.done then return end
|
||||
local game = self.game
|
||||
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
|
||||
-- a cancelable evolution, aborts when B is held -- the mon keeps its
|
||||
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
|
||||
if self.cancelable and game.input:isDown("b") then
|
||||
self.done = true
|
||||
self.canceled = true
|
||||
local TextBox = require("src.render.TextBox")
|
||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Huh? %s\nstopped evolving!"):format(self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
if self.onDone then self.onDone() end
|
||||
end))
|
||||
return
|
||||
end
|
||||
if self.t >= FLASH_FRAMES then
|
||||
self.done = true
|
||||
local game = self.game
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
Evolution.apply(game, self.mon, self.newSpecies)
|
||||
Evolution.apply(game, self.mon, self.newSpecies, self.via)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
@@ -61,7 +90,12 @@ function EvolutionState:update(dt)
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
if self.onDone then self.onDone() end
|
||||
-- Gen1 re-runs the level-up learn check on the evolved species
|
||||
-- after the "evolved into" text (evos_moves.asm EvolveMon ->
|
||||
-- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen
|
||||
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||
-- overworld / battle-return, not this state.
|
||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||
end))
|
||||
end
|
||||
end
|
||||
@@ -73,7 +107,8 @@ function EvolutionState:draw()
|
||||
-- accelerating flash between the two forms
|
||||
local sprite
|
||||
if self.done then
|
||||
sprite = self.newSprite
|
||||
-- a cancelled evolution settles back on the original form
|
||||
sprite = self.canceled and self.oldSprite or self.newSprite
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
local showNew = math.floor(self.t / period) % 2 == 1
|
||||
|
||||
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
|
||||
local visited = game.save.visited or {}
|
||||
local seen = {}
|
||||
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
|
||||
-- towns only (dungeon escape spots share the table), each listed once
|
||||
-- towns only (dungeon escape spots share the table), each listed once.
|
||||
-- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow
|
||||
-- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape
|
||||
-- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203).
|
||||
local def = game.data.maps[mapId]
|
||||
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
|
||||
if visited[mapId] and def and not seen[mapId]
|
||||
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
table.insert(items, {
|
||||
value = mapId,
|
||||
|
||||
@@ -118,8 +118,9 @@ end
|
||||
function HallOfFame:spriteFor(species)
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local def = self.game.data.pokemon[species]
|
||||
cached = tryImage(def and def.spriteFront) or false
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "hof" })
|
||||
cached = tryImage(path) or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
-- box and the Pokédex.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local ListMenu = {}
|
||||
@@ -15,9 +16,37 @@ function ListMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- frames to wait before key-repeat kicks in, then between repeats
|
||||
local REPEAT_DELAY = 16
|
||||
local REPEAT_RATE = 4
|
||||
|
||||
-- ui.list_menu identity: unhooked opts pass through unchanged
|
||||
local function sameOpts(opts) return opts end
|
||||
|
||||
function ListMenu.new(game, title, items, opts)
|
||||
opts = opts or {}
|
||||
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
|
||||
if Runtime.wantsHook("ui.list_menu") then
|
||||
local hooked = Runtime.call("ui.list_menu", sameOpts, {
|
||||
wrap = opts.wrap,
|
||||
pageJump = opts.pageJump,
|
||||
keyRepeat = opts.keyRepeat,
|
||||
repeatDelay = opts.repeatDelay,
|
||||
repeatRate = opts.repeatRate,
|
||||
}, {
|
||||
game = game,
|
||||
title = title,
|
||||
kind = opts.kind or title,
|
||||
itemCount = items and #items or 0,
|
||||
})
|
||||
if type(hooked) == "table" then
|
||||
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
|
||||
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
|
||||
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
|
||||
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
|
||||
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
|
||||
end
|
||||
end
|
||||
local self = setmetatable({}, ListMenu)
|
||||
self.game = game
|
||||
self.title = title
|
||||
@@ -28,6 +57,12 @@ function ListMenu.new(game, title, items, opts)
|
||||
self.onCancel = opts.onCancel
|
||||
self.footer = opts.footer
|
||||
self.pageJump = opts.pageJump -- Left/Right move a page at a time
|
||||
self.wrap = opts.wrap -- Up on first / Down on last wraps
|
||||
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
|
||||
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
|
||||
self.repeatRate = opts.repeatRate or REPEAT_RATE
|
||||
self.holdDir = nil
|
||||
self.holdFrames = 0
|
||||
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
|
||||
-- scripted mode (the old man tutorial): update() runs the script
|
||||
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
|
||||
@@ -46,6 +81,42 @@ function ListMenu.new(game, title, items, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
local function moveIndex(self, delta)
|
||||
local n = #self.items
|
||||
if n == 0 then return end
|
||||
local next = self.index + delta
|
||||
if self.wrap then
|
||||
next = ((next - 1) % n) + 1
|
||||
else
|
||||
next = math.max(1, math.min(n, next))
|
||||
end
|
||||
self.index = next
|
||||
end
|
||||
|
||||
local function syncScroll(self)
|
||||
if self.index - self.scroll > self.rows then
|
||||
self.scroll = self.index - self.rows
|
||||
end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- edge press or key-repeat tick for a held direction
|
||||
local function navPressed(self, dir)
|
||||
if dir == "up" then
|
||||
moveIndex(self, -1)
|
||||
elseif dir == "down" then
|
||||
moveIndex(self, 1)
|
||||
elseif dir == "left" and self.pageJump then
|
||||
moveIndex(self, -self.rows)
|
||||
elseif dir == "right" and self.pageJump then
|
||||
moveIndex(self, self.rows)
|
||||
else
|
||||
return false
|
||||
end
|
||||
syncScroll(self)
|
||||
return true
|
||||
end
|
||||
|
||||
function ListMenu:update(dt)
|
||||
if self.script then
|
||||
self.script(self)
|
||||
@@ -59,14 +130,20 @@ function ListMenu:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local moved = false
|
||||
if input:wasPressed("up") then
|
||||
self.index = math.max(1, self.index - 1)
|
||||
moved = navPressed(self, "up")
|
||||
self.holdDir, self.holdFrames = "up", 0
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = math.min(#self.items, self.index + 1)
|
||||
moved = navPressed(self, "down")
|
||||
self.holdDir, self.holdFrames = "down", 0
|
||||
elseif self.pageJump and input:wasPressed("left") then
|
||||
self.index = math.max(1, self.index - self.rows)
|
||||
moved = navPressed(self, "left")
|
||||
self.holdDir, self.holdFrames = "left", 0
|
||||
elseif self.pageJump and input:wasPressed("right") then
|
||||
self.index = math.min(#self.items, self.index + self.rows)
|
||||
moved = navPressed(self, "right")
|
||||
self.holdDir, self.holdFrames = "right", 0
|
||||
elseif self.onSelectKey and input:wasPressed("select") then
|
||||
self.onSelectKey(self.items[self.index], self)
|
||||
elseif input:wasPressed("b") then
|
||||
@@ -80,10 +157,22 @@ function ListMenu:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.index - self.scroll > self.rows then
|
||||
self.scroll = self.index - self.rows
|
||||
|
||||
-- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
|
||||
if self.keyRepeat then
|
||||
local dir = self.holdDir
|
||||
if dir and input:isDown(dir) then
|
||||
self.holdFrames = self.holdFrames + 1
|
||||
local afterDelay = self.holdFrames - self.repeatDelay
|
||||
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
|
||||
navPressed(self, dir)
|
||||
end
|
||||
else
|
||||
self.holdDir, self.holdFrames = nil, 0
|
||||
end
|
||||
end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
|
||||
if not moved then syncScroll(self) end
|
||||
end
|
||||
|
||||
-- remove current item (e.g. consumed); keeps cursor valid
|
||||
|
||||
@@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label)
|
||||
return items
|
||||
end
|
||||
|
||||
-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on
|
||||
-- stable step.id values the same way insertBefore anchors on labels.
|
||||
local function stepIndex(steps, id)
|
||||
for i, step in ipairs(steps) do
|
||||
if step.id == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function ModUI.insertStepBefore(steps, anchorId, step)
|
||||
local i = stepIndex(steps, anchorId)
|
||||
table.insert(steps, i or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function ModUI.insertStepAfter(steps, anchorId, step)
|
||||
local i = stepIndex(steps, anchorId)
|
||||
table.insert(steps, i and (i + 1) or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function ModUI.removeStep(steps, id)
|
||||
for i = #steps, 1, -1 do
|
||||
if steps[i].id == id then table.remove(steps, i) end
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
return ModUI
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
-- given, a "NEW NAME" + presets menu is shown first
|
||||
-- (engine/menus/main_menu.asm name lists).
|
||||
-- Pops itself from the stack, then calls opts.onDone(name).
|
||||
--
|
||||
-- ui.naming.grid may replace either page; keep an "ED" cell and a
|
||||
-- single-cell case-switch row so confirm / case-flip keep working.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
@@ -37,8 +41,24 @@ local GRID_LOWER = {
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "UPPER CASE" },
|
||||
}
|
||||
local CASE_ROW = 6
|
||||
local ED_ROW, ED_COL = 5, 9
|
||||
|
||||
-- locate the ED confirm cell and the case-switch row on a (possibly
|
||||
-- modded) grid; falls back to vanilla coordinates
|
||||
local function findMeta(grid)
|
||||
local caseRow, edRow, edCol = #grid, 5, 9
|
||||
for r, row in ipairs(grid) do
|
||||
if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE"
|
||||
or row[1] == "lower" or row[1] == "UPPER") then
|
||||
caseRow = r
|
||||
end
|
||||
for c, cell in ipairs(row) do
|
||||
if cell == "ED" then edRow, edCol = r, c end
|
||||
end
|
||||
end
|
||||
return caseRow, edRow, edCol
|
||||
end
|
||||
|
||||
local function sameGrid(grid) return grid end
|
||||
|
||||
function NamingScreen.new(game, opts)
|
||||
opts = opts or {}
|
||||
@@ -86,16 +106,27 @@ function NamingScreen:confirm()
|
||||
end
|
||||
|
||||
function NamingScreen:grid()
|
||||
return self.lower and GRID_LOWER or GRID_UPPER
|
||||
local base = self.lower and GRID_LOWER or GRID_UPPER
|
||||
if not Runtime.wantsHook("ui.naming.grid") then return base end
|
||||
local hooked = Runtime.call("ui.naming.grid", sameGrid, base, {
|
||||
lower = self.lower and true or false,
|
||||
title = self.title,
|
||||
maxLen = self.maxLen,
|
||||
game = self.game,
|
||||
})
|
||||
if type(hooked) ~= "table" or #hooked == 0 then return base end
|
||||
return hooked
|
||||
end
|
||||
|
||||
-- Gen 1 jumps the cursor to ED once the name is full.
|
||||
function NamingScreen:jumpToEnd()
|
||||
self.row, self.col = ED_ROW, ED_COL
|
||||
local _, edRow, edCol = findMeta(self:grid())
|
||||
self.row, self.col = edRow, edCol
|
||||
end
|
||||
|
||||
function NamingScreen:update(dt)
|
||||
local GRID = self:grid()
|
||||
local caseRow, edRow, edCol = findMeta(GRID)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("start") then
|
||||
self:confirm()
|
||||
@@ -107,28 +138,28 @@ function NamingScreen:update(dt)
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
-- wrapping up from the top row lands on the case-switch cell
|
||||
self.row = self.row > 1 and self.row - 1 or CASE_ROW
|
||||
self.row = self.row > 1 and self.row - 1 or caseRow
|
||||
self.col = math.min(self.col, #GRID[self.row])
|
||||
elseif input:wasPressed("down") then
|
||||
self.row = self.row < #GRID and self.row + 1 or 1
|
||||
self.col = math.min(self.col, #GRID[self.row])
|
||||
elseif input:wasPressed("left") then
|
||||
-- no horizontal movement on the case-switch row
|
||||
if self.row ~= CASE_ROW then
|
||||
if self.row ~= caseRow then
|
||||
self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
|
||||
end
|
||||
elseif input:wasPressed("right") then
|
||||
if self.row ~= CASE_ROW then
|
||||
if self.row ~= caseRow then
|
||||
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
table.remove(self.glyphs)
|
||||
elseif input:wasPressed("a") then
|
||||
if self.row == ED_ROW and self.col == ED_COL then
|
||||
if self.row == edRow and self.col == edCol then
|
||||
self:confirm()
|
||||
return
|
||||
end
|
||||
if self.row == CASE_ROW then
|
||||
if self.row == caseRow then
|
||||
self.lower = not self.lower
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
|
||||
-- welcome, the NIDORINO show-off, player and rival naming, and the
|
||||
-- closing "legend is about to unfold" text followed by the shrink-away:
|
||||
-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the
|
||||
-- overworld walking sprite before the fade to white. Uses the real
|
||||
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText,
|
||||
-- _IntroduceRivalText) with literal fallbacks.
|
||||
-- closing "legend is about to unfold" text followed by the shrink-away.
|
||||
--
|
||||
-- Steps are a data table so mods can reshape the whole speech through
|
||||
-- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a
|
||||
-- mod can insertBefore("name_player", ...) without counting indices.
|
||||
-- Calls onDone() after popping itself.
|
||||
|
||||
local Sound = require("src.core.Sound")
|
||||
local Music = require("src.core.Music")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
@@ -55,12 +57,154 @@ local function tryImage(path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
-- Resolve a pic descriptor to (image, flip).
|
||||
-- Descriptors:
|
||||
-- "oak" | "rival" | "player" shorthand
|
||||
-- { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
-- { type = "pokemon", id = "PIKACHU", flip = true }
|
||||
-- { type = "player", path = "..." } optional override path
|
||||
-- { type = "image", path = "..." }
|
||||
-- { type = "sprite", id = "SPRITE_RED" }
|
||||
function OakSpeech.resolvePic(game, desc, speech)
|
||||
if desc == nil then return nil, false end
|
||||
if type(desc) == "string" then
|
||||
if desc == "oak" then
|
||||
desc = { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
elseif desc == "rival" then
|
||||
desc = { type = "trainer", id = "OPP_RIVAL1" }
|
||||
elseif desc == "player" then
|
||||
desc = { type = "player" }
|
||||
else
|
||||
-- bare species id
|
||||
desc = { type = "pokemon", id = desc }
|
||||
end
|
||||
end
|
||||
local t = desc.type
|
||||
if t == "trainer" then
|
||||
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
|
||||
return speech.oakPic, false
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), false
|
||||
elseif t == "pokemon" then
|
||||
if speech and desc.id == speech.demoSpecies and speech.demoPic then
|
||||
return speech.demoPic, desc.flip and true or false
|
||||
end
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, desc.id, "front", { kind = "oak" })
|
||||
return tryImage(path), desc.flip and true or false
|
||||
elseif t == "player" then
|
||||
if speech and speech.playerPic and not desc.path then
|
||||
return speech.playerPic, false
|
||||
end
|
||||
return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false
|
||||
elseif t == "image" then
|
||||
return tryImage(desc.path), desc.flip and true or false
|
||||
elseif t == "sprite" then
|
||||
local sp = game.data.sprites and game.data.sprites[desc.id]
|
||||
return tryImage(sp and sp.image), desc.flip and true or false
|
||||
end
|
||||
return nil, false
|
||||
end
|
||||
|
||||
-- Vanilla step list. Ids are the stable anchors mods insert around.
|
||||
function OakSpeech.defaultSteps(speech)
|
||||
return {
|
||||
{
|
||||
id = "oak_welcome",
|
||||
kind = "say",
|
||||
textKey = "_OakSpeechText1",
|
||||
pic = "oak",
|
||||
reveal = "fade",
|
||||
},
|
||||
{
|
||||
id = "demo_mon",
|
||||
kind = "demo",
|
||||
},
|
||||
{
|
||||
id = "world_spiel",
|
||||
kind = "say",
|
||||
textKey = "_OakSpeechText2B",
|
||||
},
|
||||
{
|
||||
id = "ask_player_name",
|
||||
kind = "say",
|
||||
textKey = "_IntroducePlayerText",
|
||||
pic = "player",
|
||||
},
|
||||
{
|
||||
id = "name_player",
|
||||
kind = "name",
|
||||
who = "player",
|
||||
title = "YOUR NAME?",
|
||||
presetsWho = "player",
|
||||
presetsFallback = { "RED", "ASH", "JACK" },
|
||||
},
|
||||
{
|
||||
id = "ask_rival_name",
|
||||
kind = "say",
|
||||
textKey = "_IntroduceRivalText",
|
||||
pic = "rival",
|
||||
},
|
||||
{
|
||||
id = "name_rival",
|
||||
kind = "name",
|
||||
who = "rival",
|
||||
title = "HIS NAME?",
|
||||
presetsWho = "rival",
|
||||
presetsFallback = { "BLUE", "GARY", "JOHN" },
|
||||
},
|
||||
{
|
||||
id = "legend",
|
||||
kind = "say",
|
||||
textKey = "_OakSpeechText3",
|
||||
pic = "player",
|
||||
},
|
||||
{
|
||||
id = "shrink",
|
||||
kind = "shrink",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- list helpers for intro.oak_speech.build wrappers (also on ModUI)
|
||||
local function indexOfId(steps, id)
|
||||
for i, step in ipairs(steps) do
|
||||
if step.id == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function OakSpeech.insertBefore(steps, anchorId, step)
|
||||
local i = indexOfId(steps, anchorId)
|
||||
table.insert(steps, i or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function OakSpeech.insertAfter(steps, anchorId, step)
|
||||
local i = indexOfId(steps, anchorId)
|
||||
table.insert(steps, i and (i + 1) or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function OakSpeech.removeId(steps, id)
|
||||
for i = #steps, 1, -1 do
|
||||
if steps[i].id == id then table.remove(steps, i) end
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
function OakSpeech.new(game, onDone)
|
||||
local self = setmetatable({}, OakSpeech)
|
||||
self.game = game
|
||||
self.onDone = onDone
|
||||
self.step = 0
|
||||
self.pic = nil
|
||||
self.answers = {}
|
||||
local trainers = game.data.trainers or {}
|
||||
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
|
||||
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
|
||||
@@ -69,8 +213,9 @@ function OakSpeech.new(game, onDone)
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
-- literals stay as the fallbacks
|
||||
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
||||
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies]
|
||||
self.demoPic = tryImage(demo and demo.spriteFront)
|
||||
local demoPath = require("src.pokemon.Sprites").path(
|
||||
game.data, self.demoSpecies, "front", { kind = "oak" })
|
||||
self.demoPic = tryImage(demoPath)
|
||||
local constants = game.data.constants or {}
|
||||
self.nameLen = constants.playerNameLength or 7
|
||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||
@@ -87,9 +232,26 @@ function OakSpeech.new(game, onDone)
|
||||
return self
|
||||
end
|
||||
|
||||
function OakSpeech:buildSteps()
|
||||
local steps = OakSpeech.defaultSteps(self)
|
||||
local hooked = Runtime.call("intro.oak_speech.build",
|
||||
function(s) return s end, steps, self)
|
||||
if type(hooked) ~= "table" then
|
||||
Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps",
|
||||
type(hooked))
|
||||
return steps
|
||||
end
|
||||
return hooked
|
||||
end
|
||||
|
||||
function OakSpeech:enter()
|
||||
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
|
||||
Music.play(self.game.data, self.cfg.music or "Music_Routes2")
|
||||
self.answers = {}
|
||||
self.steps = self:buildSteps()
|
||||
if Runtime.wants("intro.oak_speech.started") then
|
||||
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
|
||||
end
|
||||
self:advance()
|
||||
end
|
||||
|
||||
@@ -97,80 +259,187 @@ function OakSpeech:say(key, next)
|
||||
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
|
||||
end
|
||||
|
||||
local STEPS = {
|
||||
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
|
||||
function(self)
|
||||
self.pic = self.oakPic
|
||||
self:revealPic("fade", function()
|
||||
self:say("_OakSpeechText1", function() self:advance() end)
|
||||
function OakSpeech:sayText(text, next, opts)
|
||||
self.game.stack:push(TextBox.new(self.game, text, next, opts))
|
||||
end
|
||||
|
||||
function OakSpeech:stepText(step)
|
||||
if step.text then return step.text end
|
||||
if step.textKey then return textOr(self.game, step.textKey) end
|
||||
return ""
|
||||
end
|
||||
|
||||
function OakSpeech:applyPic(step)
|
||||
if step.pic == nil then return end
|
||||
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
if img then
|
||||
self.pic = img
|
||||
self.picFlip = flip or false
|
||||
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
|
||||
-- mirror the old fallback: player pic missing → oak
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self.picFlip = false
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:recordAnswer(step, index, label, value)
|
||||
if value == nil then value = label end
|
||||
if step.saveKey then
|
||||
self.answers[step.saveKey] = value
|
||||
end
|
||||
if Runtime.wants("intro.oak_speech.answered") then
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
speech = self,
|
||||
step = step,
|
||||
index = index,
|
||||
label = label,
|
||||
value = value,
|
||||
saveKey = step.saveKey,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:afterReveal(step, fn)
|
||||
if step.reveal then
|
||||
self:revealPic(step.reveal, fn)
|
||||
else
|
||||
fn()
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:runCry(step)
|
||||
local cry = step.cry
|
||||
if not cry then return end
|
||||
if cry == true then
|
||||
if type(step.pic) == "string" and step.pic ~= "oak"
|
||||
and step.pic ~= "rival" and step.pic ~= "player" then
|
||||
cry = step.pic
|
||||
elseif type(step.pic) == "table" and step.pic.type == "pokemon" then
|
||||
cry = step.pic.id
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
Sound.playCry(self.game.data, cry)
|
||||
end
|
||||
|
||||
function OakSpeech:runStep(step)
|
||||
local kind = step.kind or "say"
|
||||
if kind == "say" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
self:sayText(self:stepText(step), function() self:advance() end)
|
||||
end)
|
||||
end,
|
||||
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
|
||||
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
|
||||
-- (MovePicLeft), then its cry sounds and the text prints
|
||||
function(self)
|
||||
elseif kind == "demo" then
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
self.pic = self.demoPic
|
||||
self.picFlip = true
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
||||
end)
|
||||
end,
|
||||
-- 3. the rest of the world-of-POKéMON spiel
|
||||
function(self)
|
||||
self:say("_OakSpeechText2B", function() self:advance() end)
|
||||
end,
|
||||
-- 4. "First, what is your name?" over the player's own pic
|
||||
-- (RedPicFront, oak_speech.asm:86-91) then the naming screen
|
||||
function(self)
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self:say("_IntroducePlayerText", function() self:advance() end)
|
||||
end,
|
||||
function(self)
|
||||
elseif kind == "name" then
|
||||
local who = step.who or "player"
|
||||
local presets = step.presets
|
||||
or namePresets(self.game, step.presetsWho or who,
|
||||
step.presetsFallback or { "RED" })
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = "YOUR NAME?",
|
||||
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }),
|
||||
maxLen = self.nameLen,
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"),
|
||||
presets = presets,
|
||||
maxLen = step.maxLen or self.nameLen,
|
||||
onDone = function(name)
|
||||
self.game.save.player.name = name
|
||||
if who == "rival" then
|
||||
self.game.save.player.rival = name
|
||||
else
|
||||
self.game.save.player.name = name
|
||||
end
|
||||
self:recordAnswer(step, 1, name, name)
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
-- 6. the rival introduction and naming
|
||||
function(self)
|
||||
self.pic = self.rivalPic
|
||||
self:say("_IntroduceRivalText", function() self:advance() end)
|
||||
end,
|
||||
function(self)
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = "HIS NAME?",
|
||||
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }),
|
||||
maxLen = self.nameLen,
|
||||
onDone = function(name)
|
||||
self.game.save.player.rival = name
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
-- 8. "your very own POKéMON legend is about to unfold!" over the
|
||||
-- player pic again (oak_speech.asm:105-113)
|
||||
function(self)
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self:say("_OakSpeechText3", function() self:advance() end)
|
||||
end,
|
||||
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames
|
||||
-- into the walking sprite, then fades to white (oak_speech.asm
|
||||
-- .next, lines 115-166). Not skippable, like the DelayFrames
|
||||
-- chain it ports.
|
||||
function(self)
|
||||
elseif kind == "choice" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
local function openMenu()
|
||||
local Menu = require("src.ui.Menu")
|
||||
local items = {}
|
||||
for i, label in ipairs(step.choices or {}) do
|
||||
items[i] = {
|
||||
label = label,
|
||||
onSelect = function()
|
||||
local value = label
|
||||
if step.values and step.values[i] ~= nil then
|
||||
value = step.values[i]
|
||||
end
|
||||
self:recordAnswer(step, i, label, value)
|
||||
self:advance()
|
||||
end,
|
||||
}
|
||||
end
|
||||
self.game.stack:push(Menu.new(self.game, items, {
|
||||
cancelable = step.cancelable == true,
|
||||
tx = step.tx or 4,
|
||||
ty = step.ty or 0,
|
||||
tw = step.tw or 12,
|
||||
th = step.th,
|
||||
}))
|
||||
end
|
||||
local text = self:stepText(step)
|
||||
if text ~= "" then
|
||||
self:sayText(text, openMenu)
|
||||
else
|
||||
openMenu()
|
||||
end
|
||||
end)
|
||||
elseif kind == "yesno" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
self:sayText(self:stepText(step), nil, {
|
||||
defaultNo = step.defaultNo,
|
||||
choice = function(yes)
|
||||
local label = yes and "YES" or "NO"
|
||||
local value = yes
|
||||
if step.values then
|
||||
if yes then
|
||||
value = step.values[1]
|
||||
else
|
||||
value = step.values[2]
|
||||
end
|
||||
end
|
||||
self:recordAnswer(step, yes and 1 or 2, label, value)
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
end)
|
||||
elseif kind == "pic" then
|
||||
-- show a sprite with an optional reveal, no text
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
self:advance()
|
||||
end)
|
||||
elseif kind == "shrink" then
|
||||
Sound.play(self.game.data, "Shrink")
|
||||
-- the OakSpeechText3 box holds its last page on screen through the
|
||||
-- shrink (pokered text boxes persist until overwritten)
|
||||
self.shrinkText = self:lastPageLines("_OakSpeechText3")
|
||||
-- prefer an explicit shrink text key; fall back to the legend beat
|
||||
local key = step.textKey or "_OakSpeechText3"
|
||||
self.shrinkText = self:lastPageLines(key)
|
||||
self.shrink = { frame = 0 }
|
||||
end,
|
||||
}
|
||||
elseif kind == "fn" then
|
||||
-- full escape hatch: step.run(speech, done)
|
||||
if type(step.run) == "function" then
|
||||
step.run(self, function() self:advance() end)
|
||||
else
|
||||
self:advance()
|
||||
end
|
||||
else
|
||||
Logger.warn("oak speech unknown step kind %s (id=%s); skipping",
|
||||
tostring(kind), tostring(step.id))
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
|
||||
-- the last two visible lines of a text's final page, pre-encoded
|
||||
function OakSpeech:lastPageLines(key)
|
||||
@@ -202,15 +471,31 @@ end
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
self.picFlip = false
|
||||
local fn = STEPS[self.step]
|
||||
if fn then
|
||||
fn(self)
|
||||
local steps = self.steps
|
||||
if not steps then
|
||||
-- enter() builds steps; keep a path for callers that advance early
|
||||
steps = self:buildSteps()
|
||||
self.steps = steps
|
||||
end
|
||||
local step = steps[self.step]
|
||||
if step then
|
||||
if Runtime.wants("intro.oak_speech.step") then
|
||||
Runtime.emit("intro.oak_speech.step", {
|
||||
speech = self, step = step, index = self.step,
|
||||
})
|
||||
end
|
||||
self:runStep(step)
|
||||
else
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:finish()
|
||||
if Runtime.wants("intro.oak_speech.finished") then
|
||||
Runtime.emit("intro.oak_speech.finished", {
|
||||
speech = self, answers = self.answers,
|
||||
})
|
||||
end
|
||||
-- the map theme starts with the overworld beneath (the original's
|
||||
-- special warp into Pallet Town)
|
||||
local ow = self.game.overworld
|
||||
@@ -267,8 +552,8 @@ function OakSpeech:draw()
|
||||
if self.pic then
|
||||
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
|
||||
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
|
||||
-- it like the sprite buffer does ((8 - w) >> 1 tiles across,
|
||||
-- bottom-aligned)
|
||||
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
||||
-- bottom-aligned
|
||||
local w, h = self.pic:getDimensions()
|
||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
||||
local y = 32 + (7 - h / 8) * 8
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
-- opts.onCancel: fired when the menu closes without a pick (B)
|
||||
-- Pops itself on B.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
@@ -94,9 +95,13 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||
path = name and icons.icons and icons.icons[name]
|
||||
end
|
||||
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
||||
if not path then return end
|
||||
if iconImages[path] == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
||||
-- picked up the same way battle sprites are
|
||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
iconImages[path] = ok and img or false
|
||||
end
|
||||
local img = iconImages[path]
|
||||
@@ -128,6 +133,10 @@ function PartyMenu.new(game, opts)
|
||||
self.onSwitch = opts.onSwitch
|
||||
self.onCancel = opts.onCancel
|
||||
self.pickOnly = opts.pickOnly
|
||||
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
|
||||
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
|
||||
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
||||
self.tmhm = opts.tmhm
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = opts.party -- link battles pass their clamped copies
|
||||
@@ -174,8 +183,15 @@ function PartyMenu:update(dt)
|
||||
elseif action == "switch" then
|
||||
self.swapFrom = self.index
|
||||
elseif action == "fly" then
|
||||
-- FLY opens the TOWN MAP with a cursor over the visited fly towns,
|
||||
-- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly).
|
||||
-- flyTo (OverworldController) validates the fly-warp + runs the
|
||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||
local ow = self.game.overworld
|
||||
self.game.stack:pop() -- close the party menu
|
||||
Screens.push(self.game, "FlyMenu")
|
||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||
if ow then ow:flyTo(mapId) end
|
||||
end })
|
||||
return
|
||||
elseif action == "flash" then -- FLASH lights dark tunnels
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
|
||||
@@ -300,21 +316,15 @@ function PartyMenu:update(dt)
|
||||
-- 1/5 of the user's max HP to a chosen teammate
|
||||
self.softboiledFrom = self.index
|
||||
elseif action == "escape" then
|
||||
-- DIG / TELEPORT both warp to the last Pokémon Center town
|
||||
-- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport
|
||||
-- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap
|
||||
-- DIG / TELEPORT warp to the last Pokémon Center TOWN (wLastBlackoutMap,
|
||||
-- special_warps.asm escape warp). pokered's .dig/.teleport spin the
|
||||
-- player up (LeaveMapAnim), white/fade out, then land it; this port
|
||||
-- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut
|
||||
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
|
||||
-- the exact departure; the fade + warp fire when the spin ends.
|
||||
local ow = self.game.overworld
|
||||
local heal = self.game.save.lastHeal
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.stack:pop()
|
||||
if ow and heal then
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
|
||||
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
|
||||
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
end))
|
||||
end
|
||||
if ow then ow:beginTeleportOut() end
|
||||
return
|
||||
end
|
||||
self.submenu = nil
|
||||
@@ -432,6 +442,38 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- The bottom-of-screen context message for the current menu state
|
||||
-- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_):
|
||||
-- the party menu always prints a message in the bottom text box. With the
|
||||
-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?")
|
||||
-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap /
|
||||
-- item / TM-HM ids print their own strings, which draw() handles inline.
|
||||
-- Pure (no side effects) so drivers can assert it. #147
|
||||
function PartyMenu:bottomMessage()
|
||||
if self.swapFrom then
|
||||
return "Move to where?"
|
||||
elseif self.softboiledFrom or self.pickOnly then
|
||||
return "Use on which one?"
|
||||
elseif self.tmhm then
|
||||
return self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
elseif self.battle then
|
||||
return self.game.data.text._PartyMenuBattleText
|
||||
or "Bring out which\nPOKéMON?"
|
||||
else
|
||||
return self.game.data.text._PartyMenuNormalText
|
||||
or "Choose a POKéMON."
|
||||
end
|
||||
end
|
||||
|
||||
-- Name-row pixel Y for party slot i (1-based).
|
||||
-- pokered party_menu.asm RedrawPartyMenu_: hlcoord 3, 0, then each entry
|
||||
-- advances 2*SCREEN_WIDTH (16 px). The bottom message box sits at tile
|
||||
-- row 12 (y=96); slot 6's HP row is therefore at y=88. #262
|
||||
function PartyMenu.entryY(i)
|
||||
return (i - 1) * 16
|
||||
end
|
||||
|
||||
function PartyMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -443,9 +485,9 @@ function PartyMenu:draw()
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
for i, mon in ipairs(party) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local y = (i - 1) * 16 + 12
|
||||
local y = PartyMenu.entryY(i)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
drawIcon(self.game, mon, 8, y - 2, i == self.index, self.blink or 0)
|
||||
drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(mon.nickname or def.name, 24, y)
|
||||
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
|
||||
@@ -458,16 +500,34 @@ function PartyMenu:draw()
|
||||
-- PrintLevel overwrites the <LV> tile with the third digit
|
||||
Font.draw(tostring(mon.level), 104, y)
|
||||
end
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
if self.tmhm then
|
||||
-- TM/HM teaching menu (engine/menus/party_menu.asm PrintPartyMenu):
|
||||
-- the second row shows the inline "ABLE" / "NOT ABLE" learnability
|
||||
-- strings in place of the HP bar and status, decided by CanLearnTM.
|
||||
-- The learnset scan mirrors ItemEffects.use so the display can never
|
||||
-- disagree with the actual teach. #210
|
||||
local can = false
|
||||
for _, m in ipairs(def.tmhm or {}) do
|
||||
if m == self.tmhm.move then can = true break end
|
||||
end
|
||||
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
||||
if can then
|
||||
Font.draw("ABLE", 120, y + 8)
|
||||
else
|
||||
Font.draw("NOT ABLE", 88, y + 8)
|
||||
end
|
||||
else
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 0, y)
|
||||
end
|
||||
@@ -479,8 +539,34 @@ function PartyMenu:draw()
|
||||
Font.draw("Move to where?", 8, 136)
|
||||
elseif self.softboiledFrom then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
elseif self.tmhm then
|
||||
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
|
||||
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
|
||||
-- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prompt = self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
local ly = 112
|
||||
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
elseif self.pickOnly then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
else
|
||||
-- default field party menu (StartMenu) and the battle voluntary-switch
|
||||
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
|
||||
-- in the standard bottom text box (party_menu.asm PartyMenuMessage), not
|
||||
-- plain bottom-row text. Box + line geometry match the #210 TM/HM case and
|
||||
-- TextBox's default (rows 12-17, text on rows 14/16). #147
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local ly = 112
|
||||
for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
end
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
|
||||
@@ -106,8 +106,11 @@ local function sell(game)
|
||||
onChoose = function(item)
|
||||
local def = game.data.items[item.value]
|
||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
||||
-- IsItemHM); zero-price items like ETHER sell for ¥0
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
-- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id
|
||||
-- (nil def) has no price, so treat it as unsellable too rather than
|
||||
-- indexing nil below -- guards saves that already picked up a bogus
|
||||
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
|
||||
if not def or def.keyItem or item.value:find("^HM_") then
|
||||
list.footer = txt(game, "_PokemartUnsellableItemText",
|
||||
"I can't put a\nprice on that.")
|
||||
return
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
-- A on page 2) closes.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
-- status_screen.asm PrintMonType prints the type's DISPLAY name from the
|
||||
-- TypeNames table, not the constant: species types are stored as pokered
|
||||
-- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it
|
||||
-- won't collide with the PSYCHIC move), which would overflow the TYPE field.
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
@@ -23,9 +30,11 @@ end
|
||||
|
||||
function SummaryMenu.new(game, mon)
|
||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
if def and def.spriteFront then
|
||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
end
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
@@ -99,10 +108,10 @@ function SummaryMenu:draw()
|
||||
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
|
||||
drawLineBox(19, 9, 8, 6)
|
||||
Font.draw("TYPE1/", 80, 72)
|
||||
Font.draw(def.types[1] or "", 88, 80)
|
||||
Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
|
||||
if def.types[2] then
|
||||
Font.draw("TYPE2/", 80, 88)
|
||||
Font.draw(def.types[2], 88, 96)
|
||||
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
|
||||
end
|
||||
Font.draw("IDNo/", 80, 104)
|
||||
-- the trainer ID is rolled at new game (SaveData.newGame) and
|
||||
|
||||
@@ -117,8 +117,9 @@ function TitleState:currentSprite()
|
||||
local species = self.cycleSpecies[self.cycleIndex]
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local def = self.game.data.pokemon[species]
|
||||
cached = tryImage(def and def.spriteFront) or false
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "title" })
|
||||
cached = tryImage(path) or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
-- the selected name in a banner up top, and the player's current
|
||||
-- location blinking. List mode (townMap data missing): up/down through
|
||||
-- an ordered list of fly towns instead. B closes.
|
||||
--
|
||||
-- Fly mode (opts.fly + opts.onFly, LoadTownMap_Fly): the same Kanto map,
|
||||
-- but the cursor cycles ONLY the visited fly destinations (Up/Down, in fly
|
||||
-- order), the banner reads "To <NAME>", and A calls onFly(mapId) to depart.
|
||||
-- This is what the party-menu FLY field move opens (#195).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -80,7 +85,10 @@ local function buildLocations(game)
|
||||
local seen = {}
|
||||
for _, mapId in ipairs(field.flyOrder or {}) do
|
||||
local def = game.data.maps and game.data.maps[mapId]
|
||||
if not seen[mapId] and def and Map.isOutdoor(def) then
|
||||
-- accept the PLATEAU tileset too so Indigo Plateau shows on the
|
||||
-- stale-asset list fallback, matching the fly-list filter (#203)
|
||||
if not seen[mapId] and def
|
||||
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
local loc = { name = mapId:gsub("_", " ") }
|
||||
table.insert(locs, loc)
|
||||
@@ -119,6 +127,42 @@ local function markerXY(loc)
|
||||
return loc.x * 8 + 16, loc.y * 8 + 8
|
||||
end
|
||||
|
||||
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
|
||||
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
|
||||
function TownMap:bannerText(loc)
|
||||
return (self.fly and "To " or "") .. loc.name
|
||||
end
|
||||
|
||||
-- Fly mode selection set (engine/menus/town_map.asm LoadTownMap_Fly): the
|
||||
-- cursor cycles ONLY the visited fly destinations, in fly order, each landing
|
||||
-- on its town square. Built from field.flyOrder filtered to visited outdoor
|
||||
-- towns that have a fly-warp spot, deduped, reusing the grid loc so the cursor
|
||||
-- lands on the town and its name shows in the banner.
|
||||
local function buildFlyList(game, byMap)
|
||||
local field = game.data.field or {}
|
||||
local visited = game.save.visited or {}
|
||||
local flyWarps = field.flyWarps or {}
|
||||
local Map = require("src.world.Map")
|
||||
local flyLocs, flyMapIds, seen = {}, {}, {}
|
||||
for _, mapId in ipairs(field.flyOrder or {}) do
|
||||
local def = game.data.maps and game.data.maps[mapId]
|
||||
-- INDIGO_PLATEAU is a normal Fly spot (engine/menus/town_map.asm
|
||||
-- LoadTownMap_Fly cycles it like any town), but its map uses tileset
|
||||
-- "PLATEAU" not OVERWORLD, so Map.isOutdoor() alone dropped it from the
|
||||
-- cursor even though it is visited and has a fly warp. Allow PLATEAU here
|
||||
-- while the CAVERN/FACILITY dungeon escape spots that share flyOrder still
|
||||
-- fail the gate and stay out (#203).
|
||||
if not seen[mapId] and visited[mapId] and flyWarps[mapId]
|
||||
and def and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
local loc = byMap[mapId] or { name = mapId:gsub("_", " ") }
|
||||
table.insert(flyLocs, loc)
|
||||
flyMapIds[#flyLocs] = mapId
|
||||
end
|
||||
end
|
||||
return flyLocs, flyMapIds
|
||||
end
|
||||
|
||||
-- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) --
|
||||
-- blink a nest icon on every map whose wild slots hold the species
|
||||
function TownMap.new(game, opts)
|
||||
@@ -152,6 +196,26 @@ function TownMap.new(game, opts)
|
||||
or "assets/generated/townmap/nest.png")
|
||||
self.nestIcon = ok and img or nil
|
||||
end
|
||||
if opts.fly then
|
||||
-- FLY picker (LoadTownMap_Fly): restrict the selectable set to the
|
||||
-- visited fly towns so Up/Down cycle only those and A knows the mapId.
|
||||
local flyLocs, flyMapIds = buildFlyList(game, self.byMap)
|
||||
if #flyLocs > 0 then
|
||||
self.fly = true
|
||||
self.onFly = opts.onFly
|
||||
self.locs = flyLocs
|
||||
self.flyMapIds = flyMapIds
|
||||
-- grid rendering needs coords on every entry; without them fall back to
|
||||
-- the name list so the fly screen still works on stale asset builds
|
||||
if self.mode == "grid" then
|
||||
for _, loc in ipairs(flyLocs) do
|
||||
if not (loc.x and loc.y) then self.mode = "list" break end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- with nothing visited yet there is nowhere to fly: leave self.fly unset
|
||||
-- so the screen degrades to a plain viewer (B closes)
|
||||
end
|
||||
-- the player's current location (guard: overworld may not be running)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
@@ -199,7 +263,19 @@ function TownMap:update(dt)
|
||||
self.game.stack:pop()
|
||||
return
|
||||
end
|
||||
if self.nestSpecies then
|
||||
if self.fly then
|
||||
-- LoadTownMap_Fly: Up/Down cycle the visited destinations, A flies there,
|
||||
-- B cancels (handled above). moveList walks self.locs, now the fly list.
|
||||
if input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
local mapId = self.flyMapIds[self.sel]
|
||||
self.game.stack:pop()
|
||||
if mapId and self.onFly then self.onFly(mapId) end
|
||||
return
|
||||
elseif input:wasPressed("up") then self:moveList(-1)
|
||||
elseif input:wasPressed("down") then self:moveList(1)
|
||||
end
|
||||
elseif self.nestSpecies then
|
||||
if input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
@@ -260,18 +336,28 @@ function TownMap:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- the player's current location blinks (slow phase)
|
||||
-- the player's current location blinks (slow phase). Paint it with a
|
||||
-- palette-safe DARK shade (red 0), not red: this screen composites through
|
||||
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
|
||||
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
|
||||
-- {165,214,255}, the exact light-blue used for the water and the town-square
|
||||
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
|
||||
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
local x, y = markerXY(self.playerLoc)
|
||||
love.graphics.setColor(0.75, 0.1, 0.1, 1)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
-- blinking cursor on the selected location
|
||||
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
|
||||
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
|
||||
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
|
||||
-- draws the box cursor CENTERED on the selected location). Drawing it at
|
||||
-- the cell top-left put the square in the frame's top-left quadrant (#152).
|
||||
if selected and self.blink % 16 < 10 then
|
||||
local x, y = markerXY(selected)
|
||||
if self.bg.cursor then
|
||||
love.graphics.draw(self.bg.cursor, x, y)
|
||||
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
|
||||
@@ -281,7 +367,7 @@ function TownMap:draw()
|
||||
-- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if selected then Font.draw(selected.name, 8, 0) end
|
||||
if selected then Font.draw(self:bannerText(selected), 8, 0) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
@@ -294,7 +380,9 @@ function TownMap:draw()
|
||||
drawSquare(loc)
|
||||
end
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
love.graphics.setColor(0.75, 0.1, 0.1, 1)
|
||||
-- palette-safe dark, same red-channel shade-remap reason as the primary
|
||||
-- grid path above (#152); stale-asset builds hit this fallback square
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
end
|
||||
@@ -317,7 +405,10 @@ function TownMap:draw()
|
||||
end
|
||||
Font.draw(loc.name, 24, y)
|
||||
if loc == self.playerLoc and self.blink < 20 then
|
||||
-- blinking marker on the player's current town
|
||||
-- blinking marker on the player's current town; force the palette-safe
|
||||
-- dark shade explicitly so the red-channel shade-remap keeps it
|
||||
-- visible regardless of Font.draw's leftover color (#152)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
@@ -327,7 +418,7 @@ function TownMap:draw()
|
||||
-- name banner across the top
|
||||
Font.drawBox(0, 0, 20, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if selected then Font.draw(selected.name, 8, 8) end
|
||||
if selected then Font.draw(self:bannerText(selected), 8, 8) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -48,8 +48,9 @@ local function dexOf(game, mon)
|
||||
end
|
||||
|
||||
local function spriteOf(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return tryImage(def and def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "trade" })
|
||||
return tryImage(path)
|
||||
end
|
||||
|
||||
local function expand(game, key, subs)
|
||||
|
||||
@@ -204,6 +204,16 @@ function Map:isWalkableCell(cx, cy)
|
||||
end
|
||||
|
||||
function Map:isGrassCell(cx, cy)
|
||||
-- Off-map cells never count as tall grass (issue #217). cellTile
|
||||
-- border-extends out-of-bounds coordinates with the map's borderBlock,
|
||||
-- and some border blocks (e.g. ROUTE_1's block 11) have the grass tile
|
||||
-- ($52 = 82) in their bottom row -- filler scenery, never standable
|
||||
-- grass. During a map-connection seam step crossConnection parks the
|
||||
-- player one cell before the entry point (cellY = -1 crossing Viridian
|
||||
-- City -> Route 1), so without this guard the feet-overdraw painted an
|
||||
-- animated grass tuft over the player's head for the whole step. pokered
|
||||
-- only ever reads $52 from loaded map tiles, not the border filler.
|
||||
if not self:inBounds(cx, cy) then return false end
|
||||
local grass = self.tileset.grassTile
|
||||
return grass ~= nil and self:cellTile(cx, cy) == grass
|
||||
end
|
||||
|
||||
@@ -222,17 +222,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.map.renderer:rebuild()
|
||||
self.cutBlocks[mapId] = nil
|
||||
end
|
||||
-- Silph Co card key doors: the .blk layouts ship with the doorways
|
||||
-- open; each floor's map script stamps the closed door block on load
|
||||
-- until its unlock event is set (scripts/SilphCo2F.asm
|
||||
-- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20)
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet).
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = closedDoors and closedDoors[mapId]
|
||||
if floorDoors then
|
||||
local stamped = false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local want = Game.save.flags[door.event] and door.open or door.block
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
@@ -438,6 +452,35 @@ end
|
||||
|
||||
-- name -> name so the map.palette chain has a vanilla link to wrap
|
||||
local function samePalette(name) return name end
|
||||
local function sameTod(tod) return tod end
|
||||
|
||||
-- world.tod default: always DAY. A day/night mod returns "NIGHT",
|
||||
-- "MORNING", etc.; the result is cached on the overworld and handed to
|
||||
-- map.palette as ctx.tod so palette swaps can key off the period.
|
||||
function OverworldState:timeOfDay()
|
||||
local tod = self.tod or "DAY"
|
||||
if not Runtime.wantsHook("world.tod") then return tod end
|
||||
local map = self.map
|
||||
local nextTod = Runtime.call("world.tod", sameTod, tod, {
|
||||
map = map,
|
||||
mapId = map and map.id,
|
||||
x = self.player and self.player.cellX,
|
||||
y = self.player and self.player.cellY,
|
||||
steps = self.todSteps or 0,
|
||||
})
|
||||
if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end
|
||||
if nextTod ~= tod then
|
||||
self.tod = nextTod
|
||||
if Runtime.wants("world.tod_changed") then
|
||||
Runtime.emit("world.tod_changed", {
|
||||
tod = nextTod, previous = tod, mapId = map and map.id,
|
||||
})
|
||||
end
|
||||
else
|
||||
self.tod = nextTod
|
||||
end
|
||||
return self.tod
|
||||
end
|
||||
|
||||
function OverworldState:paletteNameFor(map)
|
||||
local palettes = FieldDefaults.field(Game.data, "palettes")
|
||||
@@ -456,8 +499,9 @@ function OverworldState:paletteNameFor(map)
|
||||
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
|
||||
or palettes.default
|
||||
end
|
||||
local tod = self:timeOfDay()
|
||||
if not Runtime.wantsHook("map.palette") then return name end
|
||||
return Runtime.call("map.palette", samePalette, name, map)
|
||||
return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
|
||||
end
|
||||
|
||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||
@@ -749,6 +793,27 @@ function OverworldState:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
|
||||
-- spins UP out of the map before the fade (player_animations.asm
|
||||
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
|
||||
-- mirror of Fly's flyAnim lead-in above. Only when the spin finishes does
|
||||
-- warpToHealPoint push the fade + warp, so the arrival spin-down lands the
|
||||
-- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames
|
||||
-- decrements in lockstep in Player:update, so the rising spin ends here too.
|
||||
if self.teleportOut then
|
||||
self.teleportOut.frames = self.teleportOut.frames - 1
|
||||
if self.teleportOut.frames <= 0 then
|
||||
local onDone = self.teleportOut.onDone
|
||||
self.teleportOut = nil
|
||||
self.player.spinning = false
|
||||
self.player.spinFrames = nil
|
||||
self.player.spinRise = nil
|
||||
self.player.inputLocked = false
|
||||
self:warpToHealPoint(onDone, { arrive = "teleport" })
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- delayed one-shot SFX (the teleport-in spin's second note)
|
||||
if self.delaySfx then
|
||||
self.delaySfx.frames = self.delaySfx.frames - 1
|
||||
@@ -787,7 +852,7 @@ function OverworldState:update(dt)
|
||||
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
|
||||
-- the player lands on desk Oak.
|
||||
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
if not scripted and not self.transitioning then
|
||||
self:checkTrainerSight()
|
||||
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
||||
@@ -795,7 +860,7 @@ function OverworldState:update(dt)
|
||||
-- direction handling (JoypadOverworld runs the map script first) --
|
||||
-- the player can never start another step after being spotted.
|
||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
end
|
||||
if not scripted and not self.transitioning then
|
||||
self:handleInput()
|
||||
@@ -854,6 +919,21 @@ function OverworldState:dirHeld()
|
||||
or input:isDown("left") or input:isDown("right")
|
||||
end
|
||||
|
||||
-- The warp cell the player WARPED IN on is inert until they physically step
|
||||
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
|
||||
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
|
||||
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
|
||||
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
|
||||
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
|
||||
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
|
||||
-- floors every input frame (issue #230).
|
||||
function OverworldState:onWarpArrivalCell()
|
||||
if self.justWarped then return true end
|
||||
local entry = self.warpEntryCell
|
||||
return entry ~= nil and self.player.cellX == entry.x
|
||||
and self.player.cellY == entry.y
|
||||
end
|
||||
|
||||
function OverworldState:handleInput()
|
||||
local input = Game.input
|
||||
|
||||
@@ -875,10 +955,11 @@ function OverworldState:handleInput()
|
||||
if self:checkBoulderPush(dir) then return end
|
||||
end
|
||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
||||
if result == "blocked" then
|
||||
-- a collision while standing on a warp square fires the warp
|
||||
-- when the extra check passes (CheckWarpsCollision: route-gate
|
||||
-- doorways, dock entrances, ...)
|
||||
-- a collision while standing on a warp square fires the warp when the
|
||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||
-- entrances, ...) -- but never on the inert cell we just warped in on
|
||||
-- (issue #230), which the completed-step path guards the same way.
|
||||
if result == "blocked" and not self:onWarpArrivalCell() then
|
||||
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
self.player.cellX, self.player.cellY, dir)
|
||||
if w then
|
||||
@@ -1009,6 +1090,11 @@ function OverworldState:checkEdgeExit(dir)
|
||||
|
||||
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
-- ...but not while still standing on the warp cell we just arrived on
|
||||
-- (issue #230): fall through so pushing into the edge bonks (SFX +
|
||||
-- walk-in-place) instead of instantly re-warping. A real step onto an
|
||||
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
|
||||
if self:onWarpArrivalCell() then return false end
|
||||
self:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
@@ -1256,6 +1342,37 @@ function OverworldState:flyTo(mapId)
|
||||
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
|
||||
end
|
||||
|
||||
-- Dig / Teleport / Escape Rope departure animation, then land OUTSIDE the
|
||||
-- last Pokemon Center door like Fly (#196). pokered's _LeaveMapAnim
|
||||
-- (engine/overworld/player_animations.asm) plays SFX_TELEPORT_EXIT_1 and
|
||||
-- spins the player while it rises up off the map (PlayerSpinWhileMovingUp)
|
||||
-- before the palettes fade; Fly's bird lead-in (flyTo/flyAnim) is the
|
||||
-- analogous departure this mirrors. When the spin finishes (the teleportOut
|
||||
-- countdown in OverworldState:update), warpToHealPoint pushes the fade + warp
|
||||
-- with arrive="teleport" so the sprite spins back DOWN in front of the town
|
||||
-- PC door. Shared by the party-menu DIG/TELEPORT action and BagMenu's
|
||||
-- ESCAPE ROPE so all three animate identically.
|
||||
function OverworldState:beginTeleportOut(onDone)
|
||||
if not Game.save.lastHeal then
|
||||
-- a save that has never visited a Pokemon Center has no heal point to
|
||||
-- warp to; skip the animation entirely (matches the old guard that did
|
||||
-- nothing when lastHeal was absent) instead of spinning into a nil warp
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
|
||||
self.player.surfing = false
|
||||
self.player.inputLocked = true
|
||||
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
|
||||
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
|
||||
self.player.spinning = true
|
||||
self.player.spinTimer = 0
|
||||
self.player.spinFrames = 48
|
||||
self.player.spinTotal = 48
|
||||
self.player.spinRise = true
|
||||
self.teleportOut = { frames = 48, onDone = onDone }
|
||||
end
|
||||
|
||||
function OverworldState:npcAtCell(cx, cy)
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
if (npc.cellX == cx and npc.cellY == cy) or
|
||||
@@ -1474,7 +1591,17 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
-- Pokémon Center PCs and other PC tiles
|
||||
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
|
||||
self:openPC()
|
||||
if self.map.id == "REDS_HOUSE_2F" then
|
||||
-- The player's bedroom PC is the one location in Red/Blue whose PC
|
||||
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
|
||||
-- which runs the PlayerPC predef directly -- item storage, no
|
||||
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
|
||||
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
|
||||
require("src.core.Sound").play(Game.data, "Turn_On_PC")
|
||||
Screens.push(Game, "PlayerPC")
|
||||
else
|
||||
self:openPC()
|
||||
end
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -2007,8 +2134,12 @@ function OverworldState:talkTo(npc)
|
||||
return
|
||||
end
|
||||
|
||||
-- item balls (object_event item argument)
|
||||
if d.item then
|
||||
-- item balls (object_event item argument). A payload id of "0" is
|
||||
-- pokered's ITEM_NONE sentinel: the ROM object sets the 0x80 "has item"
|
||||
-- bit but names item 0, so it is a plain text object, not an item ball
|
||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!"))
|
||||
return
|
||||
@@ -2680,11 +2811,18 @@ end
|
||||
|
||||
function OverworldState:onStepComplete()
|
||||
local p = self.player
|
||||
self.todSteps = (self.todSteps or 0) + 1
|
||||
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
|
||||
-- paletteNameFor reads self.tod on the next paint
|
||||
if Runtime.wantsHook("world.tod") then
|
||||
self:timeOfDay()
|
||||
end
|
||||
|
||||
-- hot path: the payload is only built when something is listening
|
||||
if Runtime.wants("world.stepped") then
|
||||
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
|
||||
tile = self.map:cellTile(p.cellX, p.cellY) })
|
||||
tile = self.map:cellTile(p.cellX, p.cellY),
|
||||
tod = self.tod })
|
||||
end
|
||||
|
||||
-- dismounting a surf: landing on a walkable cell ends it
|
||||
@@ -3156,7 +3294,9 @@ function OverworldState:afterBattle(result, battle)
|
||||
lead and lead.stats.hp or 0)
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local function evolutions()
|
||||
Evolution.checkParty(Game)
|
||||
-- Only mons that gained a level this battle (EXP.ALL included).
|
||||
-- Scanning the whole party re-offered B-cancelled evolutions forever (#213).
|
||||
Evolution.checkParty(Game, nil, battle and battle.leveledUp)
|
||||
end
|
||||
if result == "lose" then
|
||||
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
|
||||
@@ -3257,11 +3397,32 @@ function OverworldState:warpToHealPoint(onDone, opts)
|
||||
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
|
||||
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
|
||||
Game.save.forcedBike = nil
|
||||
if opts and opts.arrive == "teleport" then
|
||||
local map, x, y = heal.map, heal.x, heal.y
|
||||
local teleport = opts and opts.arrive == "teleport"
|
||||
if teleport then
|
||||
self.arriveWarp = "teleport"
|
||||
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
|
||||
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
|
||||
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
|
||||
-- (both appear inside in front of the nurse), but this port has decided
|
||||
-- the escape-warp destination is the town PC door. Prefer the canonical
|
||||
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
|
||||
-- the remembered outdoor door cell; fall back to the interior heal cell
|
||||
-- only for an old save with no recorded outdoor.
|
||||
local out = heal.outdoor
|
||||
if out then
|
||||
local fw = (Game.data.field.flyWarps or {})[out.id]
|
||||
map = out.id
|
||||
x = fw and fw.x or out.x
|
||||
y = fw and fw.y or out.y
|
||||
end
|
||||
end
|
||||
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
|
||||
if heal.outdoor then
|
||||
self:startWarpTo(map, x, y, "down", onDone)
|
||||
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
|
||||
-- the remembered town door. The teleport branch already lands ON that
|
||||
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
|
||||
-- here would wrongly steer exits away from where the player now stands.
|
||||
if heal.outdoor and not teleport then
|
||||
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
|
||||
end
|
||||
end
|
||||
@@ -3323,10 +3484,19 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
-- 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.
|
||||
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
|
||||
-- not a forced move, so it obeys collision: on a landing with a
|
||||
-- solid cell south of the door (the mansion stair landings back
|
||||
-- onto shelves) the step bumps and the player stays on the door,
|
||||
-- arrival disable intact, instead of clipping into the wall.
|
||||
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
||||
self.warpEntryCell = nil
|
||||
self.justWarped = false
|
||||
self:scriptMove(self.player, "down", 1)
|
||||
if Collision.canMove(self.map, self.entities, self.player, "down") then
|
||||
self.warpEntryCell = nil
|
||||
self.justWarped = false
|
||||
self:scriptMove(self.player, "down", 1)
|
||||
else
|
||||
self.player.facing = "down"
|
||||
end
|
||||
end
|
||||
end
|
||||
end, function()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local Player = {}
|
||||
@@ -61,21 +62,43 @@ function Player:tryMove(dir, map, entities)
|
||||
if self.facing ~= dir then
|
||||
self.facing = dir
|
||||
self.turnTimer = self.turnFrames or TURN_FRAMES
|
||||
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
|
||||
return "turned"
|
||||
end
|
||||
if self.turnTimer > 0 then return nil end
|
||||
local ok, why = Collision.canMove(map, entities, self, dir)
|
||||
if not ok then
|
||||
-- Gen1: a blocked step still animates the player walking in place --
|
||||
-- the collision path spends the step's worth of frames running
|
||||
-- UpdateSprites before returning control, so the legs cycle without
|
||||
-- the cell changing (home/overworld.asm collision handling; issue
|
||||
-- #230). Re-armed every frame the direction is held into the wall;
|
||||
-- Player:update ticks the walk clock while it counts down, so releasing
|
||||
-- returns to the standing pose within a step's length.
|
||||
self.bumpFrames = self.stepFrames or STEP_FRAMES
|
||||
return "blocked", why
|
||||
end
|
||||
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
|
||||
self.targetX, self.targetY = tx, ty
|
||||
self.moving = true
|
||||
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
||||
self.progress = 0
|
||||
-- the bicycle doubles walking speed (8 frames per step)
|
||||
local save = require("src.core.Game").save
|
||||
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
-- the bicycle doubles walking speed (8 frames per step); movement.speed
|
||||
-- lets a mod multiply or replace that (running shoes, dash, etc.)
|
||||
local Game = require("src.core.Game")
|
||||
local save = Game.save
|
||||
local frames = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
if Runtime.wantsHook("movement.speed") then
|
||||
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
|
||||
onBike = save and save.onBike or false,
|
||||
surfing = self.surfing and true or false,
|
||||
player = self,
|
||||
input = Game.input,
|
||||
save = save,
|
||||
})
|
||||
end
|
||||
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
|
||||
return "moved"
|
||||
end
|
||||
|
||||
@@ -97,9 +120,19 @@ function Player:update()
|
||||
if self.spinFrames <= 0 then
|
||||
self.spinFrames = nil
|
||||
self.spinDrop = nil
|
||||
self.spinRise = nil -- teleport-out departure lift (#196)
|
||||
self.spinning = false
|
||||
end
|
||||
end
|
||||
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
|
||||
-- collision path keeps the walk clock running without moving the cell,
|
||||
-- so the sprite animates against the wall. Guarded on not-moving so a
|
||||
-- real step (which clears bumpFrames and advances animClock itself
|
||||
-- below) can never double-tick the leg cadence.
|
||||
if not self.moving and self.bumpFrames and self.bumpFrames > 0 then
|
||||
self.bumpFrames = self.bumpFrames - 1
|
||||
self.animClock = (self.animClock or 0) + 1
|
||||
end
|
||||
if not self.moving then return false end
|
||||
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
|
||||
self.progress = self.progress + 1
|
||||
@@ -132,7 +165,12 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving and not self.stepLanded then return 0 end
|
||||
-- moving, the land-frame after a completed step, or an active wall-bonk
|
||||
-- (issue #230) animate; a standing sprite otherwise
|
||||
if not self.moving and not self.stepLanded
|
||||
and not (self.bumpFrames and self.bumpFrames > 0) then
|
||||
return 0
|
||||
end
|
||||
-- walk frame during the middle of each 16-frame animation cycle
|
||||
local p = (self.animClock or self.progress) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -183,6 +221,13 @@ function Player:pose()
|
||||
-- (EnterMapAnim PlayerSpinWhileMovingDown)
|
||||
if self.spinFrames and self.spinDrop then
|
||||
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
|
||||
elseif self.spinFrames and self.spinRise then
|
||||
-- Dig/Teleport/Escape-Rope departures spin the sprite UP out of the
|
||||
-- map before the fade (LeaveMapAnim PlayerSpinWhileMovingUp) -- the
|
||||
-- mirror of the arrival spin-down: the lift grows from 0 as spinFrames
|
||||
-- counts down to 0 (#196), opposite sign to spinDrop above.
|
||||
local total = self.spinTotal or 64
|
||||
py = py - math.floor((total - self.spinFrames) * 24 / total)
|
||||
end
|
||||
end
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Driver: reproduce #216 - "Boosted EXP text cut off".
|
||||
--
|
||||
-- A traded mon's EXP gain prints a 3-line message:
|
||||
-- "<mon> gained\na boosted\v<N> EXP. Points!"
|
||||
-- (engine/battle/experience.asm _GainedText/_BoostedText/_ExpPointsText;
|
||||
-- _BoostedText ends in the CONT code \v = char 11, "a boosted\011" in the
|
||||
-- extracted data/generated/text.lua). The in-battle message box only has
|
||||
-- room for two lines (rows y=112 and y=128), so before the fix the third
|
||||
-- line was drawn at y=144 -- one row past the 160x144 screen -- and was
|
||||
-- never seen: a single A press dismissed the whole message and the wild win
|
||||
-- popped straight back to the overworld, so the boosted amount was invisible.
|
||||
--
|
||||
-- Gen1-correct behavior (home/text.asm ContText): the box scrolls a 2-line
|
||||
-- window, waiting for A/B on the \v (drawing the blinking ▼ arrow), then
|
||||
-- scrolls "a boosted" up to the top row and types "<N> EXP. Points!" on the
|
||||
-- bottom row (y=128) -- a VISIBLE row.
|
||||
--
|
||||
-- The driver builds a deterministic traded RATTATA, KOs a weak wild PIDGEY
|
||||
-- with no level-up, then renders the battle into a clean offscreen canvas
|
||||
-- while capturing Font.drawCode, and asserts the encoded "EXP. Points!"
|
||||
-- glyph run lands on a visible row (y <= 130), not off-screen at y=144.
|
||||
-- Fails on the buggy build (amount only ever drawn at y=144); passes once
|
||||
-- the box scrolls.
|
||||
--
|
||||
-- Run:
|
||||
-- SHOT_DIR=/tmp/bug216 POKEPORT_IDENTITY=bug216 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_boosted_exp_bug216_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 Growth = require("src.pokemon.Growth")
|
||||
local Font = require("src.render.Font")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- deterministic traded RATTATA :L8; exp pinned to the L8 floor so a single
|
||||
-- weak wild KO (~22 boosted exp) stays below the L9 threshold (no level-up
|
||||
-- to muddy the message). DVs pinned to max so numbers are stable per run.
|
||||
local rattata = Pokemon.new(game.data, "RATTATA", 8, function(_, b) return b end)
|
||||
local def = game.data.pokemon.RATTATA
|
||||
rattata.exp = Growth.expForLevel(def.growthRate, 8, game.data.growth_rates)
|
||||
rattata.traded = true -- the flag real trades/link set (Commands.lua / Protocol.lua)
|
||||
game.save.party = { rattata }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
-- weak enemy: 1 HP so the first move KOs, speed 1 so RATTATA always moves
|
||||
-- first, rng pinned low so every accuracy roll hits (Damage.accuracyRoll:
|
||||
-- rng(0,255) < acc)
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 2)
|
||||
battle.onFinish = function() end
|
||||
battle.rng = function(a, _) return a end
|
||||
battle.enemy.mon.hp = 1
|
||||
battle.enemy.mon.stats.speed = 1
|
||||
ow:pushBattle(battle)
|
||||
|
||||
-- Render the battle into a clean offscreen 160x144 canvas while recording
|
||||
-- every Font.drawCode(code, x, y). Returns true iff `targetCodes` appear
|
||||
-- as a contiguous left-to-right run on a row at y <= maxY. The off-screen
|
||||
-- amount row (y=144) is excluded, so the buggy build never counts as
|
||||
-- "shown". BattleState:draw runs its own canvas pipeline internally and
|
||||
-- restores the caller's canvas (see battle_hpbar_gbc_bug229_test.lua).
|
||||
local canvas = love.graphics.newCanvas(160, 144)
|
||||
local function seqVisible(targetCodes, maxY)
|
||||
local rec = {}
|
||||
local orig = Font.drawCode
|
||||
Font.drawCode = function(code, x, y)
|
||||
rec[#rec + 1] = { code, x, y }
|
||||
return orig(code, x, y)
|
||||
end
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local okDraw = pcall(function() battle:draw() end)
|
||||
love.graphics.setCanvas()
|
||||
Font.drawCode = orig
|
||||
if not okDraw then return false end
|
||||
-- group glyphs by row, sort left-to-right, join codes into a delimited
|
||||
-- string, and search each visible row for the target subsequence
|
||||
local byY = {}
|
||||
for _, g in ipairs(rec) do
|
||||
if g[3] <= maxY then
|
||||
byY[g[3]] = byY[g[3]] or {}
|
||||
table.insert(byY[g[3]], g)
|
||||
end
|
||||
end
|
||||
local tparts = {}
|
||||
for _, c in ipairs(targetCodes) do tparts[#tparts + 1] = tostring(c) end
|
||||
local needle = "," .. table.concat(tparts, ",") .. ","
|
||||
for _, list in pairs(byY) do
|
||||
table.sort(list, function(a, b) return a[2] < b[2] end)
|
||||
local cs = {}
|
||||
for _, g in ipairs(list) do cs[#cs + 1] = tostring(g[1]) end
|
||||
if ("," .. table.concat(cs, ",") .. ","):find(needle, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local amountCodes = Font.encode("EXP. Points!")
|
||||
|
||||
-- mash through the intro to the FIGHT menu, then FIGHT -> move slot 1
|
||||
-- (TACKLE) -> KO (mirrors battle_levelup_hpbar_bug224_test.lua)
|
||||
for _ = 1, 300 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.phase ~= "menu" then error("bug216: never reached the FIGHT menu") end
|
||||
U.tap(game, "a") -- FIGHT
|
||||
for _ = 1, 60 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then error("bug216: never reached move select") end
|
||||
U.tap(game, "a") -- TACKLE -> KO
|
||||
|
||||
-- Phase A: advance to the boosted-EXP message (contains both "boosted"
|
||||
-- and "EXP. Points!"). Break the frame it becomes current, before any A
|
||||
-- press could scroll past it.
|
||||
local function isBoosted()
|
||||
local c = battle.current
|
||||
return c and c.text and c.text:find("EXP. Points!", 1, true)
|
||||
and c.text:find("boosted", 1, true)
|
||||
end
|
||||
local reached = false
|
||||
for _ = 1, 900 do
|
||||
if isBoosted() then reached = true break end
|
||||
if game.stack:top() ~= battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
if not reached then
|
||||
error("bug216: never reached the boosted-EXP message (battle ended early)")
|
||||
end
|
||||
U.log("boosted message: " ..
|
||||
(tostring(battle.current.text):gsub("[\n\v]", "|")))
|
||||
|
||||
-- let the first two lines type out, then capture the cut-off
|
||||
-- "gained / a boosted" box (both builds show this; the buggy build has no
|
||||
-- visible third row)
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/bug216_boosted.png")
|
||||
|
||||
-- Phase B: the amount must become visible on a real row. Each iteration
|
||||
-- renders offscreen and checks; then taps A -- which scrolls the CONT
|
||||
-- window on the fixed build, and (once fully typed) dismisses the message
|
||||
-- on the buggy build.
|
||||
local shown = false
|
||||
for _ = 1, 120 do
|
||||
if seqVisible(amountCodes, 130) then shown = true break end
|
||||
if game.stack:top() ~= battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
if not shown then
|
||||
error("bug216: 'EXP. Points!' amount never shown on a visible row " ..
|
||||
"(drawn off-screen at y=144); the box did not scroll to the amount")
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/bug216_amount.png")
|
||||
|
||||
-- no level-up muddied the message
|
||||
if battle.player.mon.level ~= 8 then
|
||||
error("bug216: expected level 8, got " .. tostring(battle.player.mon.level))
|
||||
end
|
||||
|
||||
U.log("bug216 OK: boosted EXP amount shown on a visible row, level still 8")
|
||||
U.wait(4)
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Regression test for issue #229 ("GSC palette black full health").
|
||||
--
|
||||
-- In RED++ ("Gen 2 / GSC-style") COLORS mode the full-health (green-band)
|
||||
-- in-battle HP bar rendered as a solid black rectangle. Root cause is in
|
||||
-- src/render/HudTiles.lua drawHPBar: it pre-tinted the fill with GREENBAR's
|
||||
-- fill color {0,189,0} (red channel 0), zeroing the red channel of every bar
|
||||
-- pixel; the battle zone shade-remap shader (PaletteFX.shader, keyed ONLY on
|
||||
-- the red channel) then mapped every zeroed-red pixel to color 3 = black.
|
||||
-- Red/orange bands survived because REDBAR {247,0,0} / YELLOWBAR {247,165,0}
|
||||
-- keep a nonzero red channel. SGB ('gbc') mode was unaffected because
|
||||
-- PaletteFX.pack({}) returns nil there, so the fill was already drawn gray.
|
||||
--
|
||||
-- Gen1-correct behavior: the DMG hardware bar is one gray shade recolored by
|
||||
-- the SGB region palette (home/pokemon.asm DrawHPBar + engine/gfx/palettes.asm
|
||||
-- SetPal_Battle + data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel
|
||||
-- repaint. This driver forces RED++, enters a full-HP wild battle, screenshots
|
||||
-- the intro + action menu, then renders the battle into a clean 160x144 canvas
|
||||
-- and asserts the player AND enemy HP-bar fill bands are green (not black).
|
||||
--
|
||||
-- Run:
|
||||
-- SHOT_DIR=/tmp/bug229 POKEPORT_IDENTITY=bug229 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_hpbar_gbc_bug229_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "."
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
|
||||
-- save.options.colors, so a bare setMode would get reverted to the default.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "redpp"
|
||||
PaletteFX.setMode("redpp")
|
||||
|
||||
-- BULBASAUR :L5 is 19/19 -> full HP -> green band (matches the issue shot).
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "RATTATA", 3) -- full HP -> green
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
U.wait(220)
|
||||
U.shot(game, DIR .. "/bug229_redpp_intro.png")
|
||||
|
||||
-- Advance the intro text to the action menu deterministically (phase flips
|
||||
-- to "menu" at BattleState:1111/1243); stop before a menu tap enters FIGHT.
|
||||
for _ = 1, 40 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/bug229_redpp_menu.png")
|
||||
|
||||
-- Programmatic assertion: render the battle into a clean offscreen 160x144
|
||||
-- canvas -- BattleState:draw runs its own SGB zone pass internally, so this
|
||||
-- is the real colorized output (verified pixel-identical to the on-screen
|
||||
-- capture). Sample the CENTER fill rows of each bar (an 8px bar tile is
|
||||
-- white frame rows / fill rows / white frame rows, so the middle rows are
|
||||
-- the fill). Before the fix the fill is ~ (0,0,0); after the fix it is
|
||||
-- ~ (0,0.74,0) (GREENBAR fill {0,189,0}/255).
|
||||
if love and love.graphics and love.graphics.newCanvas and battle.phase == "menu" then
|
||||
local canvas = love.graphics.newCanvas(160, 144)
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
battle:draw()
|
||||
love.graphics.setCanvas()
|
||||
local id = canvas:newImageData()
|
||||
do local dbg = id:encode("png"); local f = io.open(DIR .. "/bug229_offscreen.png", "wb"); if f then f:write(dbg:getString()); f:close() end end
|
||||
|
||||
local function green(r, g, b) return g > 0.4 and g > r and g > b end
|
||||
-- returns worst (lowest-green) fill pixel across the center rows sampled
|
||||
local function worstFill(x, ys)
|
||||
local wr, wg, wb = 1, 1, 1
|
||||
for _, y in ipairs(ys) do
|
||||
local r, g, b = id:getPixel(x, y)
|
||||
if g < wg then wr, wg, wb = r, g, b end
|
||||
end
|
||||
return wr, wg, wb
|
||||
end
|
||||
-- player bar: drawHPBar tile (10,9) -> fill x 96..143; fill rows y 74..77
|
||||
local pr, pg, pb = worstFill(120, { 75, 76 })
|
||||
-- enemy bar: drawHPBar tile (2,2) -> fill x 32..79; fill rows y 18..21
|
||||
local er, eg, eb = worstFill(56, { 19, 20 })
|
||||
U.log(string.format("player fill rgb = %.2f %.2f %.2f", pr, pg, pb))
|
||||
U.log(string.format("enemy fill rgb = %.2f %.2f %.2f", er, eg, eb))
|
||||
|
||||
if not (green(pr, pg, pb) and green(er, eg, eb)) then
|
||||
error(string.format(
|
||||
"issue #229: RED++ HP bar fill not green (player %.2f/%.2f/%.2f enemy %.2f/%.2f/%.2f)",
|
||||
pr, pg, pb, er, eg, eb))
|
||||
end
|
||||
U.log("issue #229 PASS: green HP bar fill in RED++")
|
||||
else
|
||||
error("issue #229 driver: never reached the battle action menu")
|
||||
end
|
||||
|
||||
U.wait(4)
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Driver: reproduce #224 - "Level up health bar inches down".
|
||||
--
|
||||
-- On a level-up during battle, Gen 1 (engine/battle/experience.asm) raises
|
||||
-- the mon's current HP by (newMaxHP - oldMaxHP) and redraws the active
|
||||
-- battler's HP bar UP to reflect the higher current HP. Our data layer is
|
||||
-- correct (Experience.lua:84 applies the current-HP delta), but the on-screen
|
||||
-- numerator - the battler's shownHP (the value the HUD bar/number use) - was
|
||||
-- never advanced on level-up, while the denominator (mon.stats.hp) jumped
|
||||
-- instantly. So the drawn fill FRACTION fell (e.g. 8/20 -> 8/22) instead of
|
||||
-- rising to 10/22.
|
||||
--
|
||||
-- Setup: a SQUIRTLE at L5 with 8/oldMax HP and exp one point below the level-6
|
||||
-- threshold; a single wild KO crosses it. The driver asserts the player HP
|
||||
-- bar's shownHP rises to the new current HP DURING the level-up messages
|
||||
-- (before the menu-phase safety net at BattleState.lua:1099 would mask it).
|
||||
-- Fails on the buggy build (shownHP stuck at 8); passes once fixed.
|
||||
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 BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- deterministic mon: pin DVs to the max so the HP numbers are identical
|
||||
-- across the before/after runs (Stats.randomDVs consumes rng(0,15))
|
||||
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 5, function(_, b) return b end)
|
||||
local def = game.data.pokemon.SQUIRTLE
|
||||
local oldHP = 8
|
||||
squirtle.hp = oldHP -- partly depleted so the bar is well under full
|
||||
-- one point below the level-6 exp threshold: a single kill levels up once
|
||||
squirtle.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1
|
||||
game.save.party = { squirtle }
|
||||
local oldMax = squirtle.stats.hp
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
-- weak enemy: 1 HP so the first TACKLE KOs; speed pinned to 1 so SQUIRTLE
|
||||
-- always moves first (no enemy turn to muddy the HP math); rng pinned to
|
||||
-- the low end so every roll hits (Damage.accuracyRoll: rng(0,255) < acc)
|
||||
local battle = BattleState.newWild(game, "SLOWPOKE", 2)
|
||||
battle.onFinish = function() end
|
||||
battle.rng = function(a, _) return a end
|
||||
battle.enemy.mon.hp = 1
|
||||
battle.enemy.mon.stats.speed = 1
|
||||
ow:pushBattle(battle)
|
||||
|
||||
-- mash through the intro to the FIGHT menu
|
||||
for _ = 1, 240 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.phase ~= "menu" then error("bug224: never reached the FIGHT menu") end
|
||||
|
||||
-- HP box at L5: the menu safety net snaps shownHP to mon.hp, so 8/oldMax
|
||||
U.shot(game, DIR .. "/bug224_menu.png")
|
||||
|
||||
-- FIGHT -> first move (TACKLE, slot 1) -> KO
|
||||
U.tap(game, "a") -- FIGHT
|
||||
for _ = 1, 60 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then error("bug224: never reached move select") end
|
||||
U.tap(game, "a") -- TACKLE
|
||||
|
||||
-- Monitor the level-up messages. The bar must animate from oldHP toward
|
||||
-- the new current HP while phase == 'messages' (before finish/menu-snap).
|
||||
local maxShown = oldHP
|
||||
local caughtUp = false
|
||||
local shotGrew, shotRisen = false, false
|
||||
for _ = 1, 600 do
|
||||
U.wait(1)
|
||||
U.tap(game, "a") -- advance text / dismiss the stat box (never skips the
|
||||
-- time-based drain), so we reach the HP-bar redraw
|
||||
if battle.phase == "messages" and battle.player.mon.level >= 6 then
|
||||
local sh = battle.player.shownHP or battle.player.mon.hp
|
||||
maxShown = math.max(maxShown, sh)
|
||||
if not shotGrew then
|
||||
-- first L6 messages frame: buggy build already shows the shrunk bar
|
||||
-- (8/newMax) here, and it never recovers
|
||||
U.shot(game, DIR .. "/bug224_grew.png")
|
||||
shotGrew = true
|
||||
end
|
||||
if not shotRisen and sh >= oldHP + 1.0 then
|
||||
-- the bar has climbed at least a full HP: capture it mid-rise
|
||||
U.shot(game, DIR .. "/bug224_hpbar.png")
|
||||
shotRisen = true
|
||||
end
|
||||
if sh >= squirtle.hp - 0.5 then
|
||||
caughtUp = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if game.stack:top() ~= battle then break end -- battle finished/popped
|
||||
end
|
||||
|
||||
-- data-layer sanity (Experience.lua): one level gained, max HP grew, and
|
||||
-- current HP rose by the max-HP delta
|
||||
if battle.player.mon.level ~= 6 then
|
||||
error("bug224: expected level 6, got " .. tostring(battle.player.mon.level))
|
||||
end
|
||||
local newMax = squirtle.stats.hp
|
||||
if not (newMax > oldMax) then
|
||||
error("bug224: max HP did not grow (oldMax=" .. tostring(oldMax) ..
|
||||
", newMax=" .. tostring(newMax) .. "); repro is meaningless")
|
||||
end
|
||||
local expectHP = math.min(newMax, oldHP + (newMax - oldMax))
|
||||
if squirtle.hp ~= expectHP then
|
||||
error("bug224: current HP wrong: got " .. tostring(squirtle.hp) ..
|
||||
", expected " .. tostring(expectHP))
|
||||
end
|
||||
|
||||
-- THE BUG: the on-screen bar must have risen to the new current HP during
|
||||
-- the messages phase. On the buggy build shownHP stays stuck at oldHP.
|
||||
if not caughtUp then
|
||||
error("bug224: HP bar did not rise on level-up - shownHP stuck at " ..
|
||||
tostring(maxShown) .. " (oldHP=" .. tostring(oldHP) ..
|
||||
"), mon.hp=" .. tostring(squirtle.hp) .. "/" .. tostring(newMax))
|
||||
end
|
||||
|
||||
U.log("bug224 OK: L6, HP " .. tostring(squirtle.hp) .. "/" .. tostring(newMax) ..
|
||||
", bar rose from " .. tostring(oldHP) .. " to " .. tostring(maxShown))
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Regression test for issue #207 ("Back of sprite only showing outline").
|
||||
--
|
||||
-- In the forced-mono display modes (OG / OG INV / CLASSIC) a warm-palette
|
||||
-- mon's battle pic (e.g. CHARMANDER, SGB palette REDMON) rendered as a bare
|
||||
-- black outline on white, its two mid shades gone, while cool-palette mons
|
||||
-- (SQUIRTLE, CYANMON) kept full shading. Root cause is a double shade-remap:
|
||||
-- BattleState bakes each pic ONCE with the species' SGB colors, then draws it
|
||||
-- onto the UI canvas; in these modes BattleState exposes no SGB zones, so
|
||||
-- Renderer:endFrame invents a whole-screen GRAYS zone (PaletteFX.ensureZones)
|
||||
-- and runs the ENTIRE colored battle frame through PaletteFX.shader() a SECOND
|
||||
-- time. That shader keys the DMG shade off the red channel
|
||||
-- (r>0.83?c0:r>0.5?c1:r>0.17?c2:c3); REDMON's two mid shades have red 1.0 and
|
||||
-- 0.839 -- BOTH > 0.83 -- so they collapse into shade 0 (the white paper),
|
||||
-- leaving only the near-black outline as shade 3. CYANMON's reds (0.678,
|
||||
-- 0.451) land in the c1/c2 buckets, which is why blue mons were unaffected.
|
||||
--
|
||||
-- Gen1-correct behavior: mon pics are 2bpp 4-shade tiles (gfx/pokemon/back,
|
||||
-- gfx/pokemon/front); all four shades must be visible, and the same grayscale
|
||||
-- palette that renders SQUIRTLE renders CHARMANDER. The fix draws the pics as
|
||||
-- raw DMG grays in these modes so the whole-screen remap recolors 255->c0,
|
||||
-- 170->c1, 85->c2, 0->c3 -- all four shades survive.
|
||||
--
|
||||
-- This driver forces OG, gives the player a CHARMANDER (the failing warm
|
||||
-- palette), enters a wild SQUIRTLE battle, and screenshots the exact action
|
||||
-- menu the reporter shows. The assertion replays the on-screen two-stage
|
||||
-- pipeline into a clean 160x144 canvas (BattleState:draw, then the whole-screen
|
||||
-- GRAYS remap) and asserts the player CHARMANDER back-pic interior contains
|
||||
-- mid-gray shades (not outline-only), with the enemy SQUIRTLE front interior as
|
||||
-- an always-passing control.
|
||||
--
|
||||
-- Run:
|
||||
-- SHOT_DIR=/tmp/bug207 POKEPORT_IDENTITY=bug207 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_mono_sprite_bug207_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "."
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
|
||||
-- save.options.colors, so a bare setMode would get reverted to the default.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "og"
|
||||
PaletteFX.setMode("og")
|
||||
|
||||
-- CHARMANDER :L5 -- SGB palette REDMON, the warm palette that collapsed.
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 5) }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "SQUIRTLE", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
U.wait(220)
|
||||
U.shot(game, DIR .. "/bug207_og_intro.png")
|
||||
|
||||
-- Advance the intro text to the action menu deterministically (phase flips
|
||||
-- to "menu" once "Go! CHARMANDER!" has swapped in the species back pic).
|
||||
for _ = 1, 40 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
U.wait(4)
|
||||
-- The reporter's exact screen: the FIGHT/PKMN/ITEM/RUN action menu with the
|
||||
-- player's CHARMANDER back pic fully visible bottom-left.
|
||||
U.shot(game, DIR .. "/bug207_og_menu.png")
|
||||
|
||||
if not (love and love.graphics and love.graphics.newCanvas
|
||||
and battle.phase == "menu") then
|
||||
error("issue #207 driver: never reached the battle action menu")
|
||||
end
|
||||
|
||||
-- Replay the on-screen forced-mono pipeline into an offscreen 160x144 canvas.
|
||||
-- Stage 1 is BattleState's own colorized frame (its internal SGB zone pass
|
||||
-- plus the mon pics drawn via picImage). Stage 2 is Renderer:endFrame's
|
||||
-- whole-screen remap for a state with no SGB zones: ensureZones -> whole(GRAYS),
|
||||
-- then blit sends GRAYS through the shade shader over the FULL frame. This is
|
||||
-- pixel-faithful to the presented window (the U.shot captures above), but in
|
||||
-- clean 160x144 canvas space so the sample boxes are resolution-independent.
|
||||
local g = love.graphics
|
||||
local shader = PaletteFX.shader()
|
||||
local prev = g.getCanvas()
|
||||
local a = g.newCanvas(160, 144)
|
||||
local b = g.newCanvas(160, 144)
|
||||
g.setCanvas(a)
|
||||
g.clear(1, 1, 1, 1) -- battle letterbox is white (letterboxWhite)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
battle:draw()
|
||||
g.setCanvas(b)
|
||||
g.clear(0, 0, 0, 1)
|
||||
g.setShader(shader)
|
||||
PaletteFX.sendColors(shader, PaletteFX.GRAYS)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(a, 0, 0)
|
||||
g.setShader()
|
||||
g.setCanvas(prev)
|
||||
local id = b:newImageData()
|
||||
do
|
||||
local png = id:encode("png")
|
||||
local f = io.open(DIR .. "/bug207_og_offscreen.png", "wb")
|
||||
if f then f:write(png:getString()); f:close() end
|
||||
end
|
||||
|
||||
-- Count mid-gray pixels in a box. The forced-mono frame is neutral gray:
|
||||
-- shade 0 = white (~1.0), shade 3 = black (~0.0), and the two MID shades are
|
||||
-- ltgray (170/255 = 0.667) and dkgray (85/255 = 0.333). A mid shade exists
|
||||
-- only when all four DMG shades survived the remap; an outline-only pic has
|
||||
-- pure white + pure black and zero mid pixels.
|
||||
local function midCount(x0, y0, x1, y1)
|
||||
local n = 0
|
||||
for y = y0, y1 do
|
||||
for x = x0, x1 do
|
||||
local r = id:getPixel(x, y)
|
||||
if r > 0.18 and r < 0.82 then n = n + 1 end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- Player CHARMANDER back pic: hlcoord 1,5 (x=8), feet at y=96 -- interior
|
||||
-- box well inside the body, clear of the white matte and the near-black
|
||||
-- outline, and clear of the bottom-right HUD/HP bar.
|
||||
local backMid = midCount(12, 50, 52, 92)
|
||||
-- Enemy SQUIRTLE front pic: 7x7 slot at hlcoord 12,0 (x~96) -- control that
|
||||
-- always keeps its mid shades (CYANMON reds land in the c1/c2 buckets).
|
||||
local enemyMid = midCount(104, 8, 148, 48)
|
||||
U.log(string.format("player back mid-gray px = %d ; enemy front mid-gray px = %d",
|
||||
backMid, enemyMid))
|
||||
|
||||
if enemyMid <= 20 then
|
||||
error(string.format(
|
||||
"issue #207 driver: control failed -- enemy SQUIRTLE front has no "
|
||||
.. "mid-gray (%d); sample box or pipeline is wrong", enemyMid))
|
||||
end
|
||||
if backMid <= 20 then
|
||||
error(string.format(
|
||||
"issue #207: OG-mode CHARMANDER back pic is outline-only -- interior "
|
||||
.. "has %d mid-gray px (expected the full 4-shade grayscale, like the "
|
||||
.. "enemy front's %d)", backMid, enemyMid))
|
||||
end
|
||||
U.log(string.format(
|
||||
"issue #207 PASS: OG-mode CHARMANDER back keeps its mid shades "
|
||||
.. "(%d mid-gray px, control enemy %d)", backMid, enemyMid))
|
||||
|
||||
U.wait(4)
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
-- Driver: issue #11 -- Blue's House wall Town Map phantom pickup + the
|
||||
-- sell crash it causes.
|
||||
--
|
||||
-- In pokered the framed Town Map on the wall of Blue's House
|
||||
-- (data/maps/objects/BluesHouse.asm BLUESHOUSE_TOWN_MAP) is a plain
|
||||
-- text object -- talking to it prints _BluesHouseTownMapText
|
||||
-- ("It's a big map! This is useful!") and nothing enters the bag.
|
||||
-- The ROM object carries the 0x80 "has item payload" bit with a payload
|
||||
-- id of 0 (ITEM_NONE), which our extractor copies through as item="0".
|
||||
-- The engine's item-ball branch treated the truthy string "0" as a real
|
||||
-- item, so pressing A picked up a bogus item "0" -- and selling that
|
||||
-- unknown id later hard-crashed ShopMenu.sell (nil item def).
|
||||
--
|
||||
-- This driver reproduces both halves and asserts the correct Gen1
|
||||
-- behavior, so it fails while the bug exists and passes once fixed.
|
||||
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 Screens = require("src.ui.Screens")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(ok, label)
|
||||
if ok then pass = pass + 1; U.log("PASS", label)
|
||||
else fail = fail + 1; U.log("FAIL", label) end
|
||||
end
|
||||
|
||||
-- defensive: keep the pre-fix "<name> found 0!" box from erroring on a
|
||||
-- nil player name (the box never appears after the fix)
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
-- ---- Part 1: talking to the wall Town Map must not pick anything up ----
|
||||
U.teleport(game, "BLUES_HOUSE", 2, 6, "up")
|
||||
U.wait(5)
|
||||
local wallmap
|
||||
for _, n in ipairs(game.overworld.npcs) do
|
||||
if n.def and n.def.name == "BLUESHOUSE_TOWN_MAP" then wallmap = n end
|
||||
end
|
||||
check(wallmap ~= nil, "wall Town Map object present")
|
||||
if wallmap then
|
||||
game.overworld:talkTo(wallmap)
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/bh_1_wallmap.png")
|
||||
-- correct Gen1 behavior: nothing enters the bag
|
||||
check(game.save.inventory["0"] == nil, "no phantom item '0' in bag")
|
||||
check((game.save.inventory["0"] == nil) and (game.save.bagOrder == nil
|
||||
or not (function()
|
||||
for _, id in ipairs(game.save.bagOrder) do
|
||||
if id == "0" then return true end
|
||||
end
|
||||
return false
|
||||
end)()), "bag order has no '0' entry")
|
||||
-- dismiss whatever box is up
|
||||
for _ = 1, 4 do U.tap(game, "b"); U.wait(3) end
|
||||
end
|
||||
|
||||
-- ---- Part 2: selling an unknown id must not crash ----------------------
|
||||
-- Seed a corrupted bag (a save that already picked up "0" before the fix,
|
||||
-- or any legacy/unknown id) and drive the mart SELL path over it.
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
U.teleport(game, "PEWTER_MART", 2, 5, "left")
|
||||
U.wait(5)
|
||||
game.save.money = 3000
|
||||
-- known bag state regardless of whether part 1 leaked a phantom "0"
|
||||
game.save.inventory = {}
|
||||
game.save.bagOrder = nil
|
||||
Bag.add(game.save, "0", 1)
|
||||
check(game.save.inventory["0"] == 1, "seeded unknown item '0' in bag")
|
||||
|
||||
-- open the clerk's BUY/SELL/QUIT menu exactly as the mart interaction does
|
||||
Screens.push(game, "ShopMenu", {})
|
||||
U.wait(4)
|
||||
U.tap(game, "down") -- BUY -> SELL
|
||||
U.wait(4)
|
||||
U.tap(game, "a") -- open the SELL list
|
||||
U.wait(6)
|
||||
local sellList = game.stack:top()
|
||||
check(sellList and sellList.items ~= nil, "SELL list opened")
|
||||
U.shot(game, DIR .. "/bh_2_sell_list.png")
|
||||
|
||||
if sellList and sellList.items then
|
||||
-- find the bogus "0" row
|
||||
local item, idx
|
||||
for i, it in ipairs(sellList.items) do
|
||||
if it.value == "0" then item, idx = it, i end
|
||||
end
|
||||
check(item ~= nil, "bogus '0' row present in sell list")
|
||||
if item then
|
||||
sellList.index = idx
|
||||
local footerBefore = sellList.footer
|
||||
-- Invoke the real onChoose the ListMenu would call on A. Before the
|
||||
-- fix this throws at math.floor(def.price/2) with def=nil; we catch
|
||||
-- it so the run reports the crash instead of hanging on love's error
|
||||
-- screen.
|
||||
local ok, err = pcall(sellList.onChoose, item, sellList)
|
||||
if not ok then
|
||||
fail = fail + 1
|
||||
U.log("FAIL", "selling '0' CRASHED: " .. tostring(err))
|
||||
else
|
||||
check(true, "selling '0' did not crash")
|
||||
-- nothing may be sold: the guard returns before any QuantityBox
|
||||
check(game.save.inventory["0"] == 1, "unknown item still in bag (not sold)")
|
||||
check(sellList.footer ~= footerBefore
|
||||
and tostring(sellList.footer):find("price") ~= nil,
|
||||
"sell footer shows the unsellable message")
|
||||
end
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/bh_3_sell_choose.png")
|
||||
end
|
||||
end
|
||||
|
||||
U.log("RESULT", ("pass=%d fail=%d"):format(pass, fail))
|
||||
if fail == 0 then U.log("RESULT", "ALL PASS") else U.log("RESULT", "HAS FAILURES") end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Driver: Celadon Mansion back-stair landings. The 2F/3F/1F west and
|
||||
-- middle stair landings are door tiles with a solid shelf cell directly
|
||||
-- south; the door walk-out step must bump there (simulated d-pad press),
|
||||
-- not force the player through the wall (the Eevee-house stairwell
|
||||
-- stuck-in-shelves bug). East stairs keep the normal walk-out.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ow
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
local function settle(mapId)
|
||||
for _ = 1, 300 do
|
||||
ow = game.overworld
|
||||
if ow and ow.map.id == mapId and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 and not ow.player.moving then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
ow = game.overworld
|
||||
end
|
||||
|
||||
-- climb the west back stairs: 1F (2,1) -> 2F landing (2,1), whose south
|
||||
-- cell is the solid shelf row
|
||||
U.teleport(game, "CELADON_MANSION_1F", 4, 1, "left")
|
||||
U.hold(game, "left", 70)
|
||||
settle("CELADON_MANSION_2F")
|
||||
expect(ow.map.id == "CELADON_MANSION_2F", "west stairs arrive 2F, map:", ow.map.id)
|
||||
expect(ow.player.cellX == 2 and ow.player.cellY == 1,
|
||||
"player stays on landing (2,1), got:", ow.player.cellX, ow.player.cellY)
|
||||
expect(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
"standing cell walkable, tile:",
|
||||
string.format("%02x", ow.map:cellTile(ow.player.cellX, ow.player.cellY)))
|
||||
|
||||
-- the landing is not a trap: one step east onto the walkway works
|
||||
-- (short tap: (4,1) beyond it is the middle-stairs warp)
|
||||
U.hold(game, "right", 4)
|
||||
U.wait(24)
|
||||
ow = game.overworld
|
||||
expect(ow.map.id == "CELADON_MANSION_2F" and ow.player.cellX == 3,
|
||||
"stepped east off the landing, at:", ow.map.id,
|
||||
ow.player.cellX, ow.player.cellY)
|
||||
|
||||
-- stepping back onto the stairs still takes them down to 1F
|
||||
U.hold(game, "left", 4)
|
||||
settle("CELADON_MANSION_1F")
|
||||
expect(ow.map.id == "CELADON_MANSION_1F", "stairs back down, map:", ow.map.id)
|
||||
expect(ow.player.cellX == 2 and ow.player.cellY == 1,
|
||||
"1F landing holds too (2,1), got:", ow.player.cellX, ow.player.cellY)
|
||||
|
||||
-- east stairs land on (7,1) with open floor south: the vanilla
|
||||
-- walk-out step must still fire
|
||||
U.teleport(game, "CELADON_MANSION_1F", 7, 3, "up")
|
||||
U.hold(game, "up", 70)
|
||||
settle("CELADON_MANSION_2F")
|
||||
expect(ow.map.id == "CELADON_MANSION_2F", "east stairs arrive 2F, map:", ow.map.id)
|
||||
expect(ow.player.cellX == 7 and ow.player.cellY == 2,
|
||||
"walk-out stepped south to (7,2), got:", ow.player.cellX, ow.player.cellY)
|
||||
|
||||
-- a save already stuck inside the shelf (pre-fix) escapes north onto
|
||||
-- the stairs, which warp back down
|
||||
U.teleport(game, "CELADON_MANSION_2F", 2, 2, "up")
|
||||
U.hold(game, "up", 24)
|
||||
settle("CELADON_MANSION_1F")
|
||||
expect(ow.map.id == "CELADON_MANSION_1F", "stuck save escapes via stairs, map:",
|
||||
ow.map.id)
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed") end
|
||||
U.log("all checks passed")
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
-- Driver: forced-step shoves use the wrong primitive (issue #151).
|
||||
--
|
||||
-- Two related defects, both a scripted "push the player back one step" that
|
||||
-- reaches for the wrong movement primitive:
|
||||
--
|
||||
-- A MUSEUM_1F ticket rope (data/scripts/story2.lua museumClerk onDecline):
|
||||
-- declining the Y50 ticket must shove the player one cell SOUTH off the
|
||||
-- exhibit rope (scripts/Museum1F.asm) -- the player crossed the rope
|
||||
-- heading NORTH, so the shove is south. The bug shoved "right" onto the
|
||||
-- counter tile (11,4)=tile 23, non-walkable, matching the report's "moved
|
||||
-- onto the table". Correct landing is (10,5)=tile 1 (walkable floor).
|
||||
--
|
||||
-- B VIRIDIAN_CITY gym lock (data/scripts/story5.lua stepGate/viridianGym-
|
||||
-- Lock): the tile below the Gym door (32,8)=tile 44 is a DOWN-ledge
|
||||
-- (44 -> 55, data/tilesets/ledge_tiles.asm); Gen1 shoves the player with a
|
||||
-- SIMULATED JOYPAD down-press that runs the normal step pipeline including
|
||||
-- HandleLedges (engine/overworld/ledges.asm), so the shove HOPS the ledge
|
||||
-- and lands on (32,10)=tile 57. The bug used a raw scriptMove that
|
||||
-- ignores ledges, planting the player standing on the ledge tile (32,9).
|
||||
--
|
||||
-- Both cases assert the CORRECT Gen1 outcome, so this FAILS on the bug and
|
||||
-- PASSES once the shove primitives are fixed.
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/decline_push_bug151_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug151 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
|
||||
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
-- a party + starter flag so the overworld is fully usable
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
if #game.save.party == 0 then
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
||||
end
|
||||
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
|
||||
-- Advance dialog until control returns to the overworld: tap A through
|
||||
-- TextBoxes, tap B through a ChoiceBox (B always declines -- ChoiceBox:update
|
||||
-- calls onChoose(false) on B). Bounded so a stuck box can't hang the run.
|
||||
local function driveDialog(maxIters)
|
||||
for _ = 1, (maxIters or 240) do
|
||||
local top = game.stack:top()
|
||||
if top == game.overworld then return true end
|
||||
if getmetatable(top) == ChoiceBox then
|
||||
U.tap(game, "b")
|
||||
elseif getmetatable(top) == TextBox then
|
||||
U.tap(game, "a")
|
||||
else
|
||||
-- unknown state: nudge with A so we never wedge
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
return game.stack:top() == game.overworld
|
||||
end
|
||||
|
||||
-- Drain queued scriptMoves + any in-flight step, sampling p.hopFrames each
|
||||
-- frame (a hop arc is set only by checkLedgeHop). Returns whether a hop was
|
||||
-- ever seen while draining.
|
||||
local function drainMoves(p, maxFrames)
|
||||
local hopSeen = (p.hopFrames or 0) > 0
|
||||
for _ = 1, (maxFrames or 180) do
|
||||
local ow = game.overworld
|
||||
local pending = ow.scriptMoves and #ow.scriptMoves > 0
|
||||
if not pending and not p.moving and (p.hopFrames or 0) == 0 then break end
|
||||
if (p.hopFrames or 0) > 0 then hopSeen = true end
|
||||
U.wait(1)
|
||||
end
|
||||
return hopSeen
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Case A: MUSEUM_1F ticket-rope decline must shove SOUTH, not RIGHT.
|
||||
-- ------------------------------------------------------------------
|
||||
do
|
||||
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = false
|
||||
game.save.money = 1000 -- enough to buy, so declining is a real NO choice
|
||||
U.teleport(game, "MUSEUM_1F", 10, 5, "up")
|
||||
U.wait(6)
|
||||
local p = game.overworld.player
|
||||
shot("museum_decline_before.png")
|
||||
-- step north onto the rope cell (10,4); the clerk stops us there
|
||||
U.hold(game, "up", 24)
|
||||
-- clerk dialog: advance the pitch, decline the YES/NO, clear "Come again!"
|
||||
driveDialog(240)
|
||||
drainMoves(p, 120)
|
||||
shot("museum_decline_after.png")
|
||||
expect(p.cellX == 10 and p.cellY == 5,
|
||||
"A: declining shoves the player SOUTH to (10,5), got:", p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Case B: VIRIDIAN_CITY gym lock shove must HOP the down-ledge.
|
||||
-- ------------------------------------------------------------------
|
||||
do
|
||||
-- fresh badge state: no non-Earth badges, so the gym stays locked
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
|
||||
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE",
|
||||
"VOLCANOBADGE" }) do
|
||||
game.save.inventory[b] = nil
|
||||
end
|
||||
U.teleport(game, "VIRIDIAN_CITY", 31, 8, "right")
|
||||
U.wait(6)
|
||||
local p = game.overworld.player
|
||||
shot("viridian_gymlock_before.png")
|
||||
-- step east onto (32,8) directly below the locked Gym door
|
||||
U.hold(game, "right", 24)
|
||||
-- the "GYM's doors are locked..." box appears; clearing it fires the shove
|
||||
local ow = game.overworld
|
||||
local hopSeen = false
|
||||
for _ = 1, 240 do
|
||||
local top = game.stack:top()
|
||||
if top == game.overworld then break end
|
||||
if getmetatable(top) == TextBox then U.tap(game, "a") else U.tap(game, "a") end
|
||||
if (p.hopFrames or 0) > 0 then hopSeen = true end
|
||||
U.wait(2)
|
||||
if (p.hopFrames or 0) > 0 then hopSeen = true end
|
||||
end
|
||||
if drainMoves(p, 180) then hopSeen = true end
|
||||
shot("viridian_gymlock_after.png")
|
||||
expect(hopSeen, "B: the gym-lock shove HOPS the ledge (hop arc seen)")
|
||||
expect(p.cellX == 32 and p.cellY == 10,
|
||||
"B: landed south of the ledge at (32,10), got:", p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed") end
|
||||
U.log("all checks passed -- #151 shoves are Gen1-correct "
|
||||
.. "(museum decline goes south, Viridian gym lock hops the ledge)")
|
||||
end
|
||||
@@ -0,0 +1,193 @@
|
||||
-- Driver: regression coverage for #196 "Dig not working exactly as intended".
|
||||
--
|
||||
-- Report (v0.1.23): using DIG, the character appears SPINNING INSIDE the
|
||||
-- Pokemon Center in front of the nurse, and there is no spin-before-fade on
|
||||
-- departure. Expected (reporter + triage cluster E + the file's own code
|
||||
-- comments): the sprite begins to spin, the screen fades, and the player
|
||||
-- fades in OUTSIDE the nearest town's Pokemon Center door -- exactly like Fly.
|
||||
--
|
||||
-- Gen1/pokered references this exercises:
|
||||
-- engine/overworld/player_animations.asm _LeaveMapAnim (SFX_TELEPORT_EXIT_1
|
||||
-- + PlayerSpinWhileMovingUp) for the departure spin-up;
|
||||
-- EnterMapAnim (PlayerSpinWhileMovingDown) for the arrival spin-down;
|
||||
-- engine/items/item_effects.asm ItemUseEscapeRope (Dig/Teleport share it).
|
||||
--
|
||||
-- Two defects, two assertions:
|
||||
-- FIX A: a DEPARTURE spin must appear on the origin (cave) map BEFORE the
|
||||
-- warp -- ow.teleportOut set / player spinning while still in the cave.
|
||||
-- FIX B: the landing map must be the town (VIRIDIAN_CITY) at the in-front-of
|
||||
-- -door fly spot (23,26), NOT the interior VIRIDIAN_POKECENTER.
|
||||
-- Pre-fix this driver documents the bug (lands in VIRIDIAN_POKECENTER, no
|
||||
-- departure spin); post-fix it passes.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local failures = {}
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
U.log("PASS:", msg)
|
||||
else
|
||||
U.log("FAIL:", msg)
|
||||
table.insert(failures, msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- set up a heal record with a remembered Viridian town door ----
|
||||
-- Simulate having healed at the Viridian Pokemon Center: lastHeal points
|
||||
-- at the INTERIOR (the buggy landing), but carries the outdoor town door
|
||||
-- so the fix can relocate the escape-warp outside like Fly does.
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "bryan"
|
||||
game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 }
|
||||
game.save.lastHeal = {
|
||||
map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
|
||||
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 },
|
||||
}
|
||||
-- field.flyWarps.VIRIDIAN_CITY = {23,26} is the canonical in-front-of-door
|
||||
-- fly landing (one tile south of the PC door warp at 23,25).
|
||||
local fw = game.data.field.flyWarps.VIRIDIAN_CITY
|
||||
U.log("viridian flyWarp:", tostring(fw and fw.x), tostring(fw and fw.y))
|
||||
|
||||
-- ======================= LEG 1: DIG via the party menu ==============
|
||||
-- A DIG-knowing party mon. Pokemon.movesAtLevel never grants DIG, so
|
||||
-- inject the move directly (DIG needs no badge and works in CAVERN maps).
|
||||
local digger = Pokemon.new(game.data, "NIDOKING", 40)
|
||||
digger.moves = { { id = "DIG", pp = 10 } }
|
||||
game.save.party = { digger }
|
||||
|
||||
-- MT_MOON_1F tileset is CAVERN (in DIG_TILESETS); a walkable cave cell.
|
||||
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
|
||||
local ow = game.overworld
|
||||
U.log("start map:", tostring(ow and ow.map and ow.map.id),
|
||||
"tileset:", tostring(ow and ow.map and ow.map.def and ow.map.def.tileset))
|
||||
U.shot(game, DIR .. "/dig_00_cave.png")
|
||||
U.wait(4)
|
||||
|
||||
-- open the party menu and pick DIG on slot 1
|
||||
-- (submenu order for a DIG-only mon: STATS / SWITCH / DIG)
|
||||
Screens.push(game, "PartyMenu")
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- open the per-mon submenu
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- STATS -> SWITCH
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- SWITCH -> DIG
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- choose DIG
|
||||
U.wait(2)
|
||||
|
||||
-- ---- FIX A: watch for a DEPARTURE spin on the cave map before the warp ----
|
||||
-- A departure spin counts only if it happens while we are still in the cave
|
||||
-- (map.id == MT_MOON_1F). The pre-fix code only spins on arrival, inside the
|
||||
-- Center, so this stays false pre-fix.
|
||||
local sawDepartureSpin = false
|
||||
local spinShotTaken = false
|
||||
local leftCave = false
|
||||
for _ = 1, 240 do
|
||||
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
|
||||
-- departure-only markers: ow.teleportOut is the new pre-warp spin state,
|
||||
-- and player.spinRise is the rising spin (the arrival uses spinDrop), so
|
||||
-- neither can be confused with the interior arrival spin-down
|
||||
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
|
||||
sawDepartureSpin = true
|
||||
if not spinShotTaken then
|
||||
U.shot(game, DIR .. "/dig_01_spin.png")
|
||||
spinShotTaken = true
|
||||
end
|
||||
end
|
||||
if ow.map and ow.map.id ~= "MT_MOON_1F" then
|
||||
leftCave = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("sawDepartureSpin:", tostring(sawDepartureSpin),
|
||||
"leftCave:", tostring(leftCave))
|
||||
|
||||
-- ---- settle the arrival, then FIX B: where did we land? ----
|
||||
local landedMap
|
||||
for _ = 1, 240 do
|
||||
landedMap = ow.map and ow.map.id
|
||||
-- wait until the transition settles onto a stable map that isn't the cave
|
||||
if landedMap and landedMap ~= "MT_MOON_1F" and not ow.transitioning then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/dig_02_land.png")
|
||||
U.wait(4)
|
||||
landedMap = ow.map and ow.map.id
|
||||
U.log("DIG landed map:", tostring(landedMap),
|
||||
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
|
||||
|
||||
check(sawDepartureSpin,
|
||||
"DIG: departure spin appears in the cave before the fade (FIX A)")
|
||||
check(landedMap == "VIRIDIAN_CITY",
|
||||
"DIG: lands OUTSIDE at VIRIDIAN_CITY, not the interior (FIX B) -- got "
|
||||
.. tostring(landedMap))
|
||||
check(ow.player.cellX == 23 and ow.player.cellY == 26,
|
||||
"DIG: lands at the in-front-of-door fly spot 23,26 -- got "
|
||||
.. tostring(ow.player.cellX) .. "," .. tostring(ow.player.cellY))
|
||||
|
||||
-- ======================= LEG 2: ESCAPE ROPE via the bag =============
|
||||
-- Same shared departure path, but driven through BagMenu's escape_rope
|
||||
-- branch so src/ui/BagMenu.lua is exercised too.
|
||||
game.save.party = { Pokemon.new(game.data, "PIDGEY", 8) }
|
||||
game.save.bagOrder = nil
|
||||
game.save.inventory = { ESCAPE_ROPE = 1 }
|
||||
game.save.money = game.save.money or 3000
|
||||
|
||||
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
|
||||
ow = game.overworld
|
||||
U.shot(game, DIR .. "/dig_03_rope_cave.png")
|
||||
U.wait(4)
|
||||
|
||||
Screens.push(game, "BagMenu")
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- choose ESCAPE ROPE (only item) -> USE / TOSS menu
|
||||
U.wait(3)
|
||||
U.tap(game, "a") -- USE (first option) -> escape_rope branch
|
||||
U.wait(2)
|
||||
|
||||
local ropeSpin = false
|
||||
for _ = 1, 240 do
|
||||
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
|
||||
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
|
||||
ropeSpin = true
|
||||
end
|
||||
if ow.map and ow.map.id ~= "MT_MOON_1F" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local ropeLanded
|
||||
for _ = 1, 240 do
|
||||
ropeLanded = ow.map and ow.map.id
|
||||
if ropeLanded and ropeLanded ~= "MT_MOON_1F" and not ow.transitioning then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/dig_04_rope_land.png")
|
||||
U.wait(4)
|
||||
ropeLanded = ow.map and ow.map.id
|
||||
U.log("ESCAPE ROPE landed map:", tostring(ropeLanded),
|
||||
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
|
||||
|
||||
check(ropeSpin,
|
||||
"ESCAPE ROPE: departure spin appears in the cave before the fade (FIX A)")
|
||||
check(ropeLanded == "VIRIDIAN_CITY",
|
||||
"ESCAPE ROPE: lands OUTSIDE at VIRIDIAN_CITY (FIX B) -- got "
|
||||
.. tostring(ropeLanded))
|
||||
|
||||
if #failures == 0 then
|
||||
U.log("RESULT bug196 PASS")
|
||||
else
|
||||
U.log("RESULT bug196 FAIL (" .. #failures .. "):")
|
||||
for _, m in ipairs(failures) do U.log(" -", m) end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Driver: cancel an evolution with the B button (#213).
|
||||
--
|
||||
-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic
|
||||
-- flash: holding B aborts the evolution (the mon keeps its species and
|
||||
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
|
||||
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
||||
--
|
||||
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
|
||||
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
|
||||
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
|
||||
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
|
||||
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
|
||||
-- Case 2 (control): let the flash run to completion with no input and
|
||||
-- assert the mon becomes METAPOD with the "Congratulations!" text.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/evo213 POKEPORT_DRIVER=tests/drivers/evolution_cancel_bug213_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo213"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function evoTop()
|
||||
local t = top()
|
||||
return t and t.screenId == "EvolutionState"
|
||||
end
|
||||
local function waitFor(cond, max)
|
||||
for _ = 1, max or 600 do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- flatten a TextBox's paginated pages (list of line lists) to one string
|
||||
local function pagesText(st)
|
||||
if not st.pages then return nil end
|
||||
local parts = {}
|
||||
for _, page in ipairs(st.pages) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do
|
||||
if type(line) == "string" then parts[#parts + 1] = line end
|
||||
end
|
||||
elseif type(page) == "string" then
|
||||
parts[#parts + 1] = page
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
local function findText(needle)
|
||||
for _, st in ipairs(game.stack.states or {}) do
|
||||
local blob = pagesText(st)
|
||||
if blob and blob:find(needle, 1, true) then return st end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
-- mash A (one tap every few frames) until cond holds; a single tap only
|
||||
-- fast-forwards a still-typing TextBox, so mashing both finishes the
|
||||
-- typewriter and then presses the close button
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 200 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
|
||||
-- === Case 1: hold B during the flash -> evolution aborts ===
|
||||
local mon = Pokemon.new(game.data, "CATERPIE", 7)
|
||||
table.insert(game.save.party, 1, mon)
|
||||
local done1 = false
|
||||
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
U.wait(20) -- into the flash, well under FLASH_FRAMES=220
|
||||
U.log("case1 flash", "t=", top().t, "species=", mon.species)
|
||||
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
||||
|
||||
U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel
|
||||
|
||||
-- the flash aborts: EvolutionState is no longer the top (the stopped
|
||||
-- text overlays it and then pops it)
|
||||
if not waitFor(function() return not evoTop() end, 240) then
|
||||
error("evolution did not abort on B: still on EvolutionState, species="
|
||||
.. tostring(mon.species))
|
||||
end
|
||||
U.log("case1 aborted", "species=", mon.species)
|
||||
U.wait(40) -- let "Huh? MON stopped evolving!" finish typing before the shot
|
||||
U.shot(game, DIR .. "/evo213_2_stopped.png")
|
||||
|
||||
assert(mon.species == "CATERPIE",
|
||||
"B-cancel failed: mon evolved to " .. tostring(mon.species)
|
||||
.. " (expected CATERPIE)")
|
||||
assert(findText("stopped evolving"), "StoppedEvolvingText not shown")
|
||||
|
||||
mashUntil(function() return done1 end, 80) -- close the stopped-evolving text
|
||||
assert(done1, "cancel onDone never fired")
|
||||
assert(mon.species == "CATERPIE", "species changed after cancel tail")
|
||||
|
||||
-- === Case 1b: after B-cancel, checkParty without a level-up must not
|
||||
-- re-offer (regression: cancelled mons stuck at threshold tried again
|
||||
-- after every later battle, even non-participants). ===
|
||||
local nQuiet = Evolution.checkParty(game, nil, {})
|
||||
assert(nQuiet == 0, "checkParty with no level-ups re-offered after cancel")
|
||||
assert(not evoTop(), "EvolutionState opened after quiet afterBattle")
|
||||
assert(mon.species == "CATERPIE", "species changed on quiet checkParty")
|
||||
|
||||
local nLevel = Evolution.checkParty(game, nil, { [mon] = true })
|
||||
assert(nLevel == 1, "checkParty after a real level-up should offer once")
|
||||
if not waitFor(evoTop, 300) then
|
||||
error("EvolutionState never opened after level-up re-offer")
|
||||
end
|
||||
U.wait(20)
|
||||
U.hold(game, "b", 20) -- cancel so case 2 stays independent
|
||||
if not waitFor(function() return not evoTop() end, 240) then
|
||||
error("level-up re-offer did not abort on B")
|
||||
end
|
||||
mashUntil(function() return not findText("stopped evolving") end, 80)
|
||||
assert(mon.species == "CATERPIE", "species changed after re-offer cancel")
|
||||
|
||||
-- === Case 2 (control): no input -> evolution completes ===
|
||||
local mon2 = Pokemon.new(game.data, "CATERPIE", 7)
|
||||
table.insert(game.save.party, 1, mon2)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) without pressing B
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
U.wait(40) -- let the Congratulations text finish typing before the shot
|
||||
U.shot(game, DIR .. "/evo213_3_congrats.png")
|
||||
assert(mon2.species == "METAPOD",
|
||||
"control failed: mon2 stayed " .. tostring(mon2.species)
|
||||
.. " (expected METAPOD)")
|
||||
mashUntil(function() return done2 end, 80)
|
||||
|
||||
U.log("done", "case1=", mon.species, "case2=", mon2.species)
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Driver: evolution grants the new species' level-up move (#12).
|
||||
--
|
||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) re-runs the level-up
|
||||
-- learn check on the *evolved* species after the "evolved into" text, via
|
||||
-- the LearnMoveFromLevelUp predef (engine/pokemon/learn_move.asm). The
|
||||
-- check is EXACT level equality (learnset entry level == mon.level), so a
|
||||
-- mon evolving at exactly a learnset level gains that move.
|
||||
--
|
||||
-- Data pins: GYARADOS.learnset has { level = 20, move = "BITE" }
|
||||
-- (data/generated/pokemon.lua), so MAGIKARP->GYARADOS at level 20 must
|
||||
-- learn BITE, while an evolution at level 21 must NOT (nothing at 21).
|
||||
--
|
||||
-- Case 1 (repro/fix): Lv20 MAGIKARP knowing only SPLASH evolves; after the
|
||||
-- congratulations text the mon must be GYARADOS and know BITE, and the
|
||||
-- "GYARADOS learned BITE!" text must appear. This assertion FAILS on the
|
||||
-- pre-fix build (no learn step) and PASSES once the fix runs the check.
|
||||
-- Case 2 (control): Lv21 MAGIKARP evolves to GYARADOS and must NOT gain
|
||||
-- BITE (guards the exact-level == rule against an over-broad <= fix).
|
||||
--
|
||||
-- SHOT_DIR=/tmp/evo12 POKEPORT_DRIVER=tests/drivers/evolution_move_bug12_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug12 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo12"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function evoTop()
|
||||
local t = top()
|
||||
return t and t.screenId == "EvolutionState"
|
||||
end
|
||||
local function waitFor(cond, max)
|
||||
for _ = 1, max or 600 do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- flatten a TextBox's paginated pages (list of line lists) to one string
|
||||
local function pagesText(st)
|
||||
if not st.pages then return nil end
|
||||
local parts = {}
|
||||
for _, page in ipairs(st.pages) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do
|
||||
if type(line) == "string" then parts[#parts + 1] = line end
|
||||
end
|
||||
elseif type(page) == "string" then
|
||||
parts[#parts + 1] = page
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
local function findText(needle)
|
||||
for _, st in ipairs(game.stack.states or {}) do
|
||||
local blob = pagesText(st)
|
||||
if blob and blob:find(needle, 1, true) then return st end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 200 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
local function hasMove(mon, id)
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == id then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
|
||||
-- === Case 1: MAGIKARP @20 -> GYARADOS must learn BITE ===
|
||||
local mon = Pokemon.new(game.data, "MAGIKARP", 20)
|
||||
mon.moves = { { id = "SPLASH", pp = 40 } } -- deterministic single slot
|
||||
table.insert(game.save.party, 1, mon)
|
||||
local done1 = false
|
||||
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case1)")
|
||||
end
|
||||
U.wait(40) -- let the congrats text finish typing before the shot
|
||||
U.shot(game, DIR .. "/evo12_1_congrats.png")
|
||||
|
||||
-- advance past congrats into the level-up learn flow; the fix pushes
|
||||
-- "GYARADOS learned BITE!" here (pre-fix: onDone fires with no learn)
|
||||
local sawLearned = false
|
||||
mashUntil(function()
|
||||
if findText("learned") then sawLearned = true; return true end
|
||||
return done1
|
||||
end, 200)
|
||||
U.wait(30) -- let the "learned BITE!" text type out (fix path)
|
||||
U.shot(game, DIR .. "/evo12_2_learned.png")
|
||||
|
||||
mashUntil(function() return done1 end, 120)
|
||||
U.log("case1", "species=", mon.species, "hasBite=", hasMove(mon, "BITE"),
|
||||
"sawLearned=", sawLearned)
|
||||
|
||||
assert(mon.species == "GYARADOS",
|
||||
"case1: mon stayed " .. tostring(mon.species) .. " (expected GYARADOS)")
|
||||
assert(hasMove(mon, "BITE"),
|
||||
"case1: GYARADOS did not learn BITE on evolution at level 20 (bug #12)")
|
||||
assert(sawLearned, "case1: no 'learned' text shown for the evolution move")
|
||||
|
||||
-- === Case 2 (control): MAGIKARP @21 -> GYARADOS must NOT gain BITE ===
|
||||
local mon2 = Pokemon.new(game.data, "MAGIKARP", 21)
|
||||
mon2.moves = { { id = "SPLASH", pp = 40 } }
|
||||
table.insert(game.save.party, 1, mon2)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
mashUntil(function() return done2 end, 200)
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/evo12_3_lv21_no_bite.png")
|
||||
U.log("case2", "species=", mon2.species, "hasBite=", hasMove(mon2, "BITE"))
|
||||
|
||||
assert(mon2.species == "GYARADOS",
|
||||
"case2: mon2 stayed " .. tostring(mon2.species) .. " (expected GYARADOS)")
|
||||
assert(not hasMove(mon2, "BITE"),
|
||||
"case2: GYARADOS wrongly learned BITE at level 21 (over-grant; exact == broken)")
|
||||
|
||||
U.log("done", "case1=", mon.species, "case2=", mon2.species)
|
||||
love.event.quit()
|
||||
end
|
||||