Compare commits

...

25 Commits

Author SHA1 Message Date
bryanthaboi 943ae23e73 Update build.sh 2026-07-22 13:03:53 -04:00
bryanthaboi 839cf19088 bzinga 2026-07-22 12:28:51 -04:00
bryanthaboi 5019ba2caf Merge pull request #71 from bryanthaboi/bug-squashing
another attempt
2026-07-22 10:56:20 -04:00
bryanthaboi 0b267fc536 another attempt 2026-07-22 10:55:46 -04:00
bryanthaboi 290bde1c39 Merge pull request #70 from bryanthaboi/bug-squashing
android build fix
2026-07-22 10:51:02 -04:00
bryanthaboi 7e92e54669 android build fix 2026-07-22 10:50:23 -04:00
bryanthaboi b89b895962 squashing BUGS and adding COOL stuff
squashing BUGS and adding COOL stuff
2026-07-22 10:43:06 -04:00
bryanthaboi 4311955c43 input sticky bug 2026-07-22 10:38:37 -04:00
bryanthaboi 747d9acad1 linux and android builds 2026-07-22 10:08:20 -04:00
bryanthaboi 5041125751 more bugs squashed + portable mode
CLOSES #28: PC in the beginning of the game isn't interactable (The one in your house)
CLOSES #34: Bug when calculating exp after one or many fainted team members.
CLOSES #37: No Grass Cutting
CLOSES #38: Blind TMs
CLOSES #53: Portable Mode
CLOSES #55: changing palletes with hot key
CLOSES #62: Poison status damage issue.
2026-07-22 09:45:23 -04:00
bryanthaboi 15029d811f bug squashing
# Closed issues

CLOSES #17: Incorrect Character Visuals (Only with GBC filter)
CLOSES #23: Could you allow the player to change the order of the moves
CLOSES #24: Evolution music not playing during evolution
CLOSES #26: Standing on door glitch
CLOSES #27: Battle Intro text automatically continues
CLOSES #29: Visual bug when zoomed out
CLOSES #32: Team Rocket recruiter doesn't battle with you unless you speak with him first
CLOSES #33: Developer/Debug Console
CLOSES #35: Professor Oak's introduction Inaccuracies
CLOSES #36: Missing Pokemon Dex entries when picking starter + Rival Pathing issues
CLOSES #39: Guy who stops player from skipping brock doesn't bring you to brock's gym + doesn't leave once you've beaten brock
CLOSES #40: Bill cutscene is broken
CLOSES #41: Ticket guy failing to be a Ticket guy
CLOSES #42: S.S. anne odd behavior + Missing sailing away animation
CLOSES #43: Dig Attack animation appears to be glitched
CLOSES #44: Pokeball flashing doesn't appear to be accurate
CLOSES #45: Inaccurate Cut Animation
CLOSES #46: Dugtrio i caught in diglett cave has two of the same move
CLOSES #47: Rival ignores player in Lavender Tower
CLOSES #48: Healing pad in lavender tower does not function
CLOSES #49: Incorrect dialogue with parched security guard
CLOSES #50: (Game Breaking!) Rocket grunt guarding poster refuses to move
CLOSES #51: Badges showing up as items I can deposit in PC
CLOSES #52: Visual bug on Celadon Department Store roof (Red Filter)
CLOSES #54: Visual issue on route 15 + Fuchsia City
CLOSES #56: Running animation missing
CLOSES #57: Safari Zone does not display steps while you are inside of it
CLOSES #58: (Game Breaking!) Softlock at cycling road gate
CLOSES #59: Bike visual issues
CLOSES #60: Cycling road not forcing you to get on your bike
CLOSES #61: No keycard doors in Silph Co.
CLOSES #63: Missing teleporter animation
CLOSES #64: Inaccurate spinning
CLOSES #65: Reimplement unused Silph Co. Chief and Professor Oak trainer battles
2026-07-22 08:18:13 -04:00
bryanthaboi 2c2a5a4220 LOTS OF UPDATES
LOTS OF UPDATES - SEE ALL ISSUES THIS CLOSED
2026-07-21 14:57:32 -04:00
bryanthaboi f64666c6ce discord rich presence, and MORE COLORS, video options, shiddddd so much stuff 2026-07-21 14:52:45 -04:00
bryanthaboi 3f4aaccbf5 attacks should not animate if miss or fail 2026-07-21 13:52:21 -04:00
bryanthaboi 20ad4e6c41 fix first blue battle, fossil stuff, and another color palette option 2026-07-21 13:41:34 -04:00
bryanthaboi 60b3cebdce ok the auto driver works but needs work 2026-07-21 13:19:53 -04:00
bryanthaboi 9468ffccdf solving more things and making the auto-player work almost perfectly 2026-07-21 09:55:03 -04:00
bryanthaboi 948ae8abbd Merge pull request #16 from bryanthaboi/bryanthaboi-support-1
Add support section to README
2026-07-21 07:37:17 -04:00
bryanthaboi cb22684c2a Add support section to README
Added a section for supporting the project with a 'Buy Me a Coffee' link.
2026-07-21 07:36:43 -04:00
bryanthaboi 94be169e35 Merge pull request #13 from bryanthaboi/speedrun_conv_bug_fix
work in progress bug fix via speed run
2026-07-21 05:53:01 -04:00
bryanthaboi 36188ef18a work in progress 2026-07-21 05:50:40 -04:00
bryanthaboi 7a1b4e5c45 Merge pull request #10 from bryanthaboi/CI-update
CI Update
2026-07-20 09:22:48 -04:00
bryanthaboi eba6e8d752 CI Update 2026-07-20 09:20:56 -04:00
bryanthaboi ec5a72b365 Merge pull request #3 from bryanthaboi/underground-path-warps 2026-07-19 16:43:50 -04:00
bryanthaboi 9325d56046 Fix underground path route 5-8 exit warps 2026-07-19 16:42:30 -04:00
103 changed files with 21905 additions and 555 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Bug report
description: Something in the game is broken, wrong, or not behaving like the original.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
A screenshot is worth more than any description. If you can grab one, grab one.
If you genuinely can't, that's fine, but then the details below need to be thorough
enough that someone can find the bug without ever seeing your screen.
- type: input
id: summary
attributes:
label: One line summary
description: What's broken, in a sentence.
placeholder: Warp at the south end of the Route 5-8 underground path drops you in the wrong spot
validations:
required: true
- type: dropdown
id: os
attributes:
label: Which build are you running
options:
- macOS
- Windows
- Both
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: The release you downloaded. It's in the release title, like 0.1.4.
placeholder: 0.1.4
validations:
required: true
- type: input
id: location
attributes:
label: Where in the game
description: >
Be specific. Route, town, building, floor, menu, battle. If it's a map
transition, say which side you came in from.
placeholder: Underground path between Route 5 and Route 8, south entrance
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: Screenshot, or a detailed description if you couldn't take one
description: >
Drag the image straight into this box. If you couldn't get a screenshot,
write "no screenshot" and then describe exactly what was on screen: where
the player sprite was, what the tiles around it looked like, any text on
screen, whether anything was frozen or flickering. The more concrete, the better.
placeholder: |
Drop the image here.
Or, with no screenshot:
Screen faded out like a normal warp, then faded back in with the player
standing on the grass tile north of the Route 6 gate house instead of
inside the tunnel. Player was facing down. No text box. Music kept playing
the underground path track instead of switching to the Route 6 track.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to make it happen
description: Numbered steps, starting from a point someone else can get to.
placeholder: |
1. Start a new game, get to Cerulean
2. Go south to Route 5, enter the underground path building
3. Walk to the south end of the tunnel
4. Step onto the stairs
validations:
required: true
- type: textarea
id: expected
attributes:
label: What should have happened
placeholder: Should have come out inside the Route 8 side entrance building, facing up.
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Does it happen every time or only sometimes. Did it start after a specific
release. Anything you were doing right before it. Leave blank if nothing comes to mind.
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,88 @@
name: Feature request
description: Ask for something new, or a change to how something already works.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
"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.
- 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
validations:
required: true
- 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.
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.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing
description: >
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.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
The specifics. Which button, 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
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Does this change how the original game plays
description: >
Some requests are quality of life, some change the actual game. Both are fine,
it just helps to know which one you're asking for.
options:
- Quality of life, original game is untouched
- Changes how the game plays
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, how another game does it, related issues. Leave blank
if nothing comes to mind.
validations:
required: false
+58 -5
View File
@@ -1,7 +1,7 @@
name: Release
# Builds the macOS and Windows desktop apps on the self-hosted Mac runner
# and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -14,6 +14,12 @@ name: Release
on:
push:
branches: [main]
# CI/workflow and docs-only changes don't ship anything to users, so they
# don't earn a release. A push touching these *and* real source still
# releases; only pushes confined entirely to these paths are skipped.
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
inputs:
version:
@@ -23,6 +29,8 @@ on:
permissions:
contents: write
issues: read
pull-requests: read
concurrency:
group: release
@@ -144,14 +152,20 @@ jobs:
echo "Identities available to codesign:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows
- name: Build macOS + Windows + Linux
run: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a
# login-keychain profile.
# login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization.
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
- name: Build Android
run: |
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app
run: |
set -euo pipefail
@@ -196,6 +210,10 @@ jobs:
mkdir -p "$outdir"
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
cp "dist/linux/PokemonRed-linux.zip" "$outdir/PokemonRed-${v}-linux.zip"
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/PokemonRed-${v}-android.apk"
ls -lh "$outdir"
- name: Publish GitHub Release
@@ -206,14 +224,49 @@ jobs:
v="${{ steps.ver.outputs.version }}"
tag="${{ steps.ver.outputs.tag }}"
# Issues this release closes, per GitHub's own "closing issues" links
# (works for squash, rebase, and merge commits alike). Scans every
# commit since the previous tag so a skipped release run doesn't drop
# issues on the floor.
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
closed=""
for pr in $(git log --pretty=%H "$range" \
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
--jq '.[].number' 2>/dev/null \
| sort -un || true); do
closed+="$(gh api graphql \
-f owner="${GITHUB_REPOSITORY%/*}" \
-f name="${GITHUB_REPOSITORY#*/}" \
-F pr="$pr" \
-f query='
query($owner:String!, $name:String!, $pr:Int!) {
repository(owner:$owner, name:$name) {
pullRequest(number:$pr) {
closingIssuesReferences(first:50) { nodes { number title } }
}
}
}' \
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
done
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
notes="Download the correct version for your computer below."
if [ -n "$closed" ]; then
notes+=$'\n\n## Issues closed\n\n'"$closed"
fi
printf 'Release notes:\n%s\n' "$notes"
gh release create "$tag" \
--target "$GITHUB_SHA" \
--title "$v" \
--notes "$notes" \
"dist/release/PokemonRed-${v}-macos.zip" \
"dist/release/PokemonRed-${v}-windows.zip"
"dist/release/PokemonRed-${v}-windows.zip" \
"dist/release/PokemonRed-${v}-linux.zip" \
"dist/release/PokemonRed-${v}-android.apk"
echo "Published release $tag"
+40
View File
@@ -45,6 +45,27 @@ then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
## Portable Mode
By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the
executable (or next to `main.lua`/`conf.lua` when running from source), then
launch the game.
With `portable.txt` present:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory.
- After a ROM import, the generated `data/generated` and `assets/generated`
cache is copied into that folder too, so a later launch (even on a
different computer, as long as the same folder comes along) reuses it
without asking for the ROM again.
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## Modding
The game ships a native mod platform: content registries, events and hooks,
@@ -55,6 +76,21 @@ reference — lives on the
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
## Bugs and Ideas
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
that looks wrong, text in the wrong box, anything that does not match the
original game.
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
Attach a screenshot if you can. It saves a lot of back and forth, and if you
can't get one, the form asks you to describe what you saw instead.
Thought of a feature that could be good, or a way to improve one that already
exists?
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
Say what you want, why it is worth doing, and how you picture it working. A
request with real detail is one that can actually get built.
## More
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
@@ -69,3 +105,7 @@ Shipped example mods, one per kind of author, live in [`mods/`](mods/).
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
+9
View File
@@ -1,13 +1,16 @@
function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
for _, a in ipairs(arg) do
if a == "--editor" then editor = true end
if a == "--developer" then developer = true end
end
end
-- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer
if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
@@ -42,6 +45,12 @@ function love.conf(t)
t.window.height = 1920
t.window.fullscreen = true
t.window.highdpi = true
-- Android only (irrelevant on iOS): puts the save directory under the
-- app's external-files folder, which is readable/writable via USB or a
-- file manager with no runtime permission, so RomImporter can ask the
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
else
t.window.resizable = true
end
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
-- Celadon Chief House (scripts/CeladonChiefHouse.asm). The CHIEF is a
-- talk-only NPC in the original ROM, and ChiefData (data/trainers/
-- parties.asm) is an empty, unreferenced trainer entry. After the HALL
-- OF FAME (EVENT_BEAT_CHAMPION_RIVAL) the CHIEF puts up a fight, using a
-- reconstructed party for the otherwise-unused OPP_CHIEF class.
return {
talk = {
TEXT_CELADONCHIEFHOUSE_CHIEF = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "check_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 4
{ "jump_if_true", 12 }, -- 5
{ "show_text", "So you've come to\nshut down my\noperation?\f"
.. "TEAM ROCKET's\nCHIEF won't go\ndown so easy!" }, -- 6
{ "start_battle", "trainer", "OPP_CHIEF", 1 }, -- 7
{ "jump_if_false", "end" }, -- 8
{ "set_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 9
{ "show_text", "Gah! Even the\nCHIEF is no match\nfor you!\f"
.. "TEAM ROCKET is\nfinished for\ngood!" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_CeladonChiefHouseChiefText" }, -- 12
},
},
}
+5 -4
View File
@@ -5,10 +5,11 @@
-- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; that part is already covered on this map by
-- story5.lua's onStep gate (walks the player back a step at the
-- east exit before EVENT_BEAT_BROCK). Here we only port the real
-- YES/NO-branched flavor text these NPCs speak when talked to.
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
-- map by story5.lua's onStep gate at the east exit (before
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
-- Here we only port the real YES/NO-branched flavor text these NPCs
-- speak when talked to.
local M = {}
+10 -32
View File
@@ -1,22 +1,18 @@
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN.
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2 and GIRL.
--
-- Not ported here (already handled elsewhere / not talk-reachable):
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED --
-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript /
-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the
-- gates themselves are already implemented via story5.lua's onStep
-- chain (viridianOldManStep / viridianGymLock) for this map.
-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_
-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full
-- scripted-battle cutscene outside this task's Commands vocabulary
-- (no static_battle-style "battle a scripted old-man WEEDLE" command
-- exists); we port the real YES/NO branch text he speaks but the
-- "yes" branch here just shows the "I'll show you how" line rather
-- than actually starting the minigame, since that machinery isn't
-- ported to this map yet.
-- * TEXT_VIRIDIANCITY_OLD_MAN (the walking man at (17,5)) and
-- TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY (the sleeper at (18,9)) -- both
-- live in data/scripts/story.lua, which owns this map's onStep gate
-- and can reach the `old_man_demo` command for the real catch
-- tutorial. Keep them there: story.lua loads BEFORE this file, so a
-- duplicate here would silently win the merge.
-- * TEXT_VIRIDIANCITY_GYM_LOCKED -- a step-triggered blocking text
-- (ViridianCityCheckGymOpenScript), implemented by story5.lua's
-- onStep chain (viridianGymLock -> viridianOldManStep) for this map.
local M = {}
@@ -91,24 +87,6 @@ M.VIRIDIAN_CITY = {
end
end,
-- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had
-- his coffee, he asks (YES/NO) whether you want to learn how to
-- catch Pokemon. YES leads into the catch-training minigame
-- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported --
-- see file header); NO just brushes you off ("Time is money...").
TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityOldManHadMyCoffeeNowText
or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes)
if yes then
push(game, t._ViridianCityOldManKnowHowToCatchPokemonText
or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done)
else
push(game, t._ViridianCityOldManTimeIsMoneyText
or "Time is money...\nGo along then.", done)
end
end)
end,
},
}
+71 -56
View File
@@ -10,8 +10,8 @@
-- 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);
-- afterwards he gloats or sulks and marches out of the lab
-- (OaksLabRivalBattleEndScript).
-- afterwards HealParty + flag always, then he gloats or sulks and
-- marches out (OaksLabRivalEndBattleScript). A loss does not black out.
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
@@ -19,32 +19,35 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall)
return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 19 }, -- 2
{ "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 19 }, -- 4
{ "ask", askText }, -- 5
{ "jump_if_false", 20 }, -- 6
{ "give_pokemon", species, 5 }, -- 7
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8
{ "set_flag", choseFlag }, -- 9
{ "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text)
{ "push_screen", "DexEntryMenu", species }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "give_pokemon", species, 5 }, -- 8
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
{ "set_flag", choseFlag }, -- 10
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
{ "hide_object", "OAKS_LAB", ownBall }, -- 11
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12
{ "face_object", 1, "up" }, -- 13
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
{ "face_object", 1, "up" }, -- 14
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 16
{ "jump", 20 }, -- 17
{ "jump", 20 }, -- 18 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
}
end
@@ -58,33 +61,40 @@ return {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "jump_if_false", 14 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 12 }, -- 5
{ "jump_if_true", 14 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
{ "jump", 30 }, -- 11
{ "check_flag", "EVENT_GOT_STARTER" }, -- 12
{ "jump_if_false", 27 }, -- 13
{ "check_item", "POKE_BALL" }, -- 14
{ "jump_if_true", 25 }, -- 15
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16
{ "jump_if_false", 29 }, -- 17
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18
{ "jump_if_true", 25 }, -- 19
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "give_item", "POKE_BALL", 5, false }, -- 21
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23
{ "jump", 30 }, -- 24
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25
{ "jump", 30 }, -- 26
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27
{ "jump", 30 }, -- 28
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end)
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "jump", 32 }, -- 13
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "jump_if_false", 29 }, -- 15
{ "check_item", "POKE_BALL" }, -- 16
{ "jump_if_true", 27 }, -- 17
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "jump_if_false", 31 }, -- 19
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "jump_if_true", 27 }, -- 21
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "give_item", "POKE_BALL", 5, false }, -- 23
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "jump", 32 }, -- 26
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "jump", 32 }, -- 28
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "jump", 32 }, -- 30
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -100,9 +110,9 @@ return {
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 20 }, -- 3
{ "jump_if_false", 21 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 18 }, -- 5
{ "jump_if_true", 19 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
@@ -113,18 +123,20 @@ return {
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16
{ "jump", 21 }, -- 17
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18
{ "jump", 25 }, -- 19
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20
-- battle aftermath: on a win the rival sulks and marches out
-- (OaksLabRivalBattleEnd); on a loss the blackout already warped
-- us away, so the script just ends
{ "jump_if_false", 25 }, -- 21
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22
{ "move_npc_to", 1, 4, 11 }, -- 23
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end)
-- 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
},
},
@@ -171,9 +183,12 @@ return {
local party = flags.EVENT_CHOSE_BULBASAUR and 3
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- a loss blacks out to another map: end the script there
table.insert(rows, { "jump_if_false", base + 7 })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
-- already shown in-battle via Rival1WinText)
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
+30 -6
View File
@@ -10,15 +10,39 @@
-- The girl, fisher and the four signs resolve automatically through the
-- extracted text pointers (no script needed).
-- Once the HALL OF FAME has been reached (EVENT_BEAT_CHAMPION_RIVAL, set
-- alongside Commands.record_hall_of_fame) Oak offers the ProfOakData
-- battle (data/trainers/parties.asm) -- three teams picked by the
-- player's starter, mirroring the rival's type-advantage lineup, that go
-- unused in the original ROM.
return {
talk = {
TEXT_PALLETTOWN_OAK = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4
{ "jump", 7 }, -- 5
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 6
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_false", 20 }, -- 3
{ "check_flag", "EVENT_BEAT_PROF_OAK" }, -- 4
{ "jump_if_true", 20 }, -- 5
{ "show_text", "OAK: So you want\nto test your\nskills on me?\f"
.. "Very well! Let\nme show you what\na real trainer\ncan do!" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_PROF_OAK", 3 }, -- 9 CHARIZARD
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_PROF_OAK", 2 }, -- 13 VENUSAUR
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_PROF_OAK", 1 }, -- 15 BLASTOISE
{ "jump_if_false", "end" }, -- 16
{ "set_flag", "EVENT_BEAT_PROF_OAK" }, -- 17
{ "show_text", "OAK: Impressive!\nYou truly are a\nPOKéMON MASTER!" }, -- 18
{ "jump", "end" }, -- 19
{ "check_flag", "EVENT_GOT_STARTER" }, -- 20
{ "jump_if_true", 24 }, -- 21
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" }, -- 22
{ "jump", "end" }, -- 23
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 24
},
},
}
+267 -55
View File
@@ -10,6 +10,31 @@ local M = {}
-- -------------------------------------------------------------------
M.VIRIDIAN_MART = {
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
-- script, not a talk. Entering with a starter and no parcel runs
-- ViridianMartDefaultScript -- the clerk calls out, then
-- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it.
--
-- The talk branch below is kept as the fallback for a save that reaches
-- the counter without this having fired.
onEnter = function(game, ow)
local f = game.save.flags
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
if not f.EVENT_GOT_STARTER then return end
ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
})
end,
talk = {
TEXT_VIRIDIANMART_CLERK = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
M.VIRIDIAN_CITY = {
talk = {
-- the old man napping on the north path (scripts/ViridianCity.asm)
-- after his coffee, the old man offers the catch tutorial: not in a
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
-- only ever grumbles and shoves you back down -- he never wakes,
-- moves or hides. The coffee ask and the catch tutorial belong to
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
{ "jump_if_true", 5 }, -- 2
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
{ "jump", 14 }, -- 4
{ "face_player" }, -- 5
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
{ "jump_if_true", 12 }, -- 7 (in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
{ "old_man_demo" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
{ "move_player", "down", 1 }, -- 2
},
-- The walking old man at (17,5), shown once the Pokédex swaps him in
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
-- off, NO leads into the catch tutorial: he explains, demos a catch
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
TEXT_VIRIDIANCITY_OLD_MAN = {
{ "face_player" }, -- 1
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
{ "old_man_demo" }, -- 5
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
{ "jump", 9 }, -- 7
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
},
},
-- the sleeping old man lies across the Route 22 path (18,9): until
-- he moves you can't slip past on either side (scripts/ViridianCity
-- blocks the whole corridor, not just his tile)
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
-- and the girl (17,9). With the Pokédex the check returns immediately
-- and you simply walk past at x=19.
onStep = function(game, ow, x, y)
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
if gone then return false end
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
if y <= 8 and x >= 17 and x <= 19 then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end
return false
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
if x ~= 19 or y ~= 9 then return false end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end,
}
@@ -102,26 +133,107 @@ M.BLUES_HOUSE = {
M.BILLS_HOUSE = {
talk = {
TEXT_BILLSHOUSE_BILL_POKEMON = {
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
{ "jump_if_true", 11 }, -- 2
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
-- the cell-separator PC throws its switch
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH)
{ "play_sound", "Switch" }, -- 5
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6
-- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
-- (NO only adds "No, you gotta help!" before rejoining the YES
-- path), then the "get in the TELEPORTER" line and the monster
-- walking into the cell-separator machine
-- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
-- when they stand in the way facing down), where it is hidden and
-- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
-- OverworldState.billsHousePC for the separator itself.
TEXT_BILLSHOUSE_BILL_POKEMON = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local t = game.data.text
local function toMachine()
game.stack:push(TextBox.new(game,
t._BillsHouseBillUseSeparationSystemText
or "When I'm in the\nTELEPORTER, run\nthe Cell\nSeparation System!", function()
local function entered()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
done()
end
if ow.player.facing == "down" then
-- the player is standing on his straight path: walk around
-- (.PokemonWalkAroundPlayerMovement)
ow:scriptMove(npc, "right", 1, function()
ow:scriptMove(npc, "up", 2, function()
ow:scriptMove(npc, "left", 1, function()
ow:scriptMove(npc, "up", 1, entered)
end)
end)
end)
else
ow:scriptMove(npc, "up", 3, entered)
end
end))
end
game.stack:push(TextBox.new(game,
t._BillsHouseBillImNotAPokemonText or "Hey! I'm not a\nPOKéMON!",
nil, { choice = function(yes)
if yes then
toMachine()
else
game.stack:push(TextBox.new(game,
t._BillsHouseBillNoYouGottaHelpText
or "No! You gotta\nhelp me!", toMachine))
end
end }))
end,
-- BillsHouseBillSSTicketText (human Bill after the separation)
TEXT_BILLSHOUSE_BILL_SS_TICKET = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_BillsHouseBillThankYouText" }, -- 4
-- pokered gives first (GiveItem fills wStringBuffer), then prints
-- the received text that reads it (scripts/BillsHouse.asm; the
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the
-- sound_get_key_item jingle plays like BillsHouse.asm:196)
{ "give_item", "S_S_TICKET", 1, false }, -- 7
{ "show_text", "_SSTicketReceivedText" }, -- 8
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
{ "give_item", "S_S_TICKET", 1, false }, -- 5
{ "show_text", "_SSTicketReceivedText" }, -- 6
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
-- The two Cerulean guards are a SWAP PAIR, not scenery
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
-- house's south door at (27,11), and that house is one of the two
-- ways through the fence that splits Cerulean in half (the badge
-- house is the other). Leaving GUARD2 up forever severs the city --
-- the gym/mart half can never reach the Route 5 exit.
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
-- CeruleanHideRocket), so either route opens the path.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 12
},
TEXT_BILLSHOUSE_BILL_CHECK_OUT_MY_RARE_POKEMON = {
{ "face_player" }, -- 1
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 2
},
},
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
if game.save.flags.EVENT_GOT_SS_TICKET
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = true
end
end,
}
-- -------------------------------------------------------------------
@@ -142,17 +254,48 @@ M.VERMILION_CITY = {
game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2
end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp, so stepping onto it heading for the dock gets
-- ticket-checked (and turned back once the ship has sailed) without
-- the player ever pressing A. The sailor himself never disappears.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local f = game.save.flags
local t = game.data.text
local TextBox = require("src.render.TextBox")
if f.EVENT_SS_ANNE_LEFT then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
game.stack:push(TextBox.new(game,
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end,
talk = {
-- the sailor guarding the dock gangway
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
-- flashing the ticket just lets you through -- he never hides, and
-- once the ship has sailed he only reports it gone
TEXT_VERMILIONCITY_SAILOR1 = {
{ "face_player" }, -- 1
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2
{ "check_item", "S_S_TICKET" }, -- 3
{ "jump_if_false", 8 }, -- 4
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6
{ "jump", 9 }, -- 7
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
{ "jump", 12 }, -- 8
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
},
},
}
@@ -208,7 +351,17 @@ M.POKEMON_TOWER_7F = {
{ "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5
{ "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6
{ "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7
{ "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8
-- pokered warps to wDestinationWarpID $1 (0-based) -- the house's
-- SECOND warp, the door mat at (3,7) -- facing UP
-- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP +
-- hWarpDestinationMap MR_FUJIS_HOUSE). Landing (3,3) instead put
-- the player at the Pokédex table, and the route's first waypoint
-- (3,7) then stepped onto a LIVE door mat and exited the house
-- before ever talking to Fuji -- so the POKE_FLUTE was never
-- collected and the Route 16 SNORLAX sealed the map. The arrival
-- mat itself is inert until stepped off (warpEntryCell), which is
-- what makes the vanilla coordinates safe.
{ "warp", "MR_FUJIS_HOUSE", 3, 7, "up" }, -- 8
},
},
}
@@ -343,6 +496,50 @@ M.WARDENS_HOUSE = {
-- -------------------------------------------------------------------
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
-- .PlayerCoordsArray -- (6,13) and (7,12) -- every frame while
-- EVENT_BEAT_SILPH_CO_GIOVANNI is unset: standing there shows his text,
-- walks him three tiles down (.GiovanniMovement), and starts the fight.
-- He also has no trainer-header entry, so sight engagement never fires
-- either. Without this hook he was a talk-only statue four tiles away
-- from anything the route (or a vanilla-faithful player walking the same
-- line) would touch, and the whole Silph ending -- the flag, the Master
-- Ball, the Saffron streets clearing -- silently never happened.
--
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
-- sets nothing, so the trigger re-arms exactly as vanilla does.
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
local gio
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
end
if not gio or ow:trainerDefeated(gio) then return false end
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
end
end)
end)
return true
end,
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
end
end,
talk = {
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
{ "face_player" }, -- 1
@@ -567,6 +764,21 @@ M.POKEMON_TOWER_2F = {
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
},
},
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return false end
if not ((x == 15 and y == 5) or (x == 14 and y == 6)) then return false end
if ow.runner:isRunning() then return false end
local rival = ow:npcByIndex(1)
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
return true
end,
}
-- -------------------------------------------------------------------
+88 -15
View File
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
-- (engine/items/inventory.asm), which walks the same three item ids and
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
local function takeGuardDrink(game)
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
return true
end
end
return false
end
local function saffronGate(guardText, triggers, horizontal)
return {
talk = {
@@ -289,18 +306,15 @@ local function saffronGate(guardText, triggers, horizontal)
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
return
end
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done))
return
end
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"You can go on\nthrough!"), done))
end))
return
end
game.stack:push(TextBox.new(game,
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
@@ -317,6 +331,31 @@ local function saffronGate(guardText, triggers, horizontal)
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- Stepping on the trigger WITH a drink hands it over right here.
--
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
-- `farcall RemoveGuardDrink` before it decides anything: the coord
-- trigger itself takes the drink and sets
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
-- gets the thirsty line and the walk-back. We had the removal on the
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
-- bag was turned away and the four gates stayed shut unless you
-- happened to talk to him -- which vanilla never requires.
--
-- Saffron is the middle of the map, so this sealed it: every route
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
-- short way) was unreachable, and the bot could not get to Lavender
-- for the POKE_FLUTE at all.
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"You can go on\nthrough!")))
end))
return true
end
local back
if horizontal then
back = ow.player.facing == "left" and "right" or "left"
@@ -324,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
back = ow.player.facing == "up" and "down" or "up"
end
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!",
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
function()
ow:scriptMove(ow.player, back, 1)
end))
@@ -386,6 +425,26 @@ M.BIKE_SHOP = {
-- the Cinnabar lab (the wait is skipped).
-- -------------------------------------------------------------------
-- The Super Nerd (object index 1) claims both fossils and blocks the way
-- to them. In scripts/MtMoonB2F.asm he isn't a sight-line trainer: his
-- header carries no range, so MtMoonB2FDefaultScript force-triggers the
-- battle the instant the player steps onto (13,8) -- the chokepoint tile
-- to his right -- and reaching for a fossil intercepts you too.
local function superNerdBeaten(ow)
local nerd = ow:npcByIndex(1)
return not nerd or ow:trainerDefeated(nerd)
end
local function engageSuperNerd(game, ow, onDone)
local nerd = ow:npcByIndex(1)
if not nerd or ow:trainerDefeated(nerd) then
if onDone then onDone() end
return
end
nerd:facePlayer(ow.player)
ow:engageTrainer(nerd, onDone)
end
local function mtMoonFossil(itemId, otherName)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
@@ -394,7 +453,13 @@ local function mtMoonFossil(itemId, otherName)
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
return
end
game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function()
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
@@ -403,7 +468,6 @@ local function mtMoonFossil(itemId, otherName)
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
@@ -412,6 +476,15 @@ local function mtMoonFossil(itemId, otherName)
end
M.MT_MOON_B2F = {
-- MtMoonB2FDefaultScript forces the Super Nerd battle when the player
-- steps onto (13,8), the tile beside him guarding the fossils.
onStep = function(game, ow, x, y)
if x == 13 and y == 8 and not superNerdBeaten(ow) then
engageSuperNerd(game, ow, nil)
return true
end
return false
end,
talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
+97 -18
View File
@@ -63,7 +63,15 @@ M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
-- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until identified with the Silph Scope and defeated.
-- (10,16) until defeated.
--
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
-- balls dodged). An earlier version of this port turned the player back
-- without the scope and never opened the battle, which made 6F
-- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below).
-- -------------------------------------------------------------------
M.POKEMON_TOWER_6F = {
@@ -71,26 +79,32 @@ M.POKEMON_TOWER_6F = {
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
if x ~= 10 or y ~= 16 then return false end
local TextBox = require("src.render.TextBox")
if not game.save.inventory.SILPH_SCOPE then
game.stack:push(TextBox.new(game,
"A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!",
function()
local back = ow.player.facing == "up" and "down" or "up"
ow:scriptMove(ow.player, back, 1)
end))
return true
end
local t = game.data.text
game.stack:push(TextBox.new(game,
"The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function()
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "MAROWAK", 30)
if not game.save.inventory.SILPH_SCOPE then
battle:makeGhost()
end
battle.onFinish = function(result)
if result == "win" then
-- wBattleResult parity (PokemonTower6FMarowakBattleScript's
-- "and a / jr nz"): losing writes $1 and running writes $2, but
-- ItemUsePokeDoll ends the battle WITHOUT touching it, so the
-- script reads 0 -- defeated. That is the famous Poke Doll
-- trick, and the speedrun route this bot follows depends on it.
if result == "win" or battle.pokeDollEscape then
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
game.stack:push(TextBox.new(game,
"The restless soul\ncalmed down and\ndeparted!"))
t._PokemonTower6FSoulWasCalmedText
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
elseif result ~= "lose" then
-- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that
-- immediately re-fires.
ow:scriptMove(ow.player, "right", 1)
end
ow:afterBattle(result)
ow:afterBattle(result, battle)
end
game.stack:push(battle)
end))
@@ -303,6 +317,33 @@ M.GAME_CORNER = {
done()
end))
end,
-- the grunt guarding the poster (GameCornerRocketText /
-- GameCornerRocketBattleScript / GameCornerRocketExitScript): after
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
if ow:trainerDefeated(npc) then
done()
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done()
end))
end)
end,
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -411,18 +452,56 @@ M.GAME_CORNER_PRIZE_ROOM = {
-- and the player steps off the dock, the ship sets sail.
-- -------------------------------------------------------------------
-- the ship's hull/deck blocks (block cols 5-8, rows 1-2) and the water
-- that replaces them once she sails (the surrounding blocks of each row)
local DOCK_SHIP_BLOCKS = {
{ bx = 5, by = 1, water = 1 }, { bx = 6, by = 1, water = 1 },
{ bx = 7, by = 1, water = 1 }, { bx = 8, by = 1, water = 1 },
{ bx = 5, by = 2, water = 13 }, { bx = 6, by = 2, water = 13 },
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
M.VERMILION_DOCK = {
onEnter = function(game, ow)
if game.save.flags.EVENT_SS_ANNE_LEFT then
local f = game.save.flags
if f.EVENT_SS_ANNE_LEFT then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
ow.map:setBlock(b.bx, b.by, b.water)
end
ow.map.renderer:rebuild()
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 19, 30, "up")
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end))
elseif game.save.flags.EVENT_GOT_HM01 then
game.save.flags.EVENT_SS_ANNE_LEFT = true
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- the horn blows, smoke puffs drift off the funnel, the ship is
-- erased to open water, and the player is walked off the dock
-- into the city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
f.EVENT_SS_ANNE_LEFT = true
require("src.core.Music").stop()
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
end
end,
}
+111
View File
@@ -457,6 +457,25 @@ M.ROUTE_24 = {
battleOrDone()
end,
},
-- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the
-- player stands on (10,15) in front of the recruiter while
-- EVENT_GOT_NUGGET is unset (dbmapcoord 10,15), independent of facing
-- or input -- he has no trainer header, so sight never engages him.
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_GOT_NUGGET then return false end
if not (x == 10 and y == 15) then return false end
if ow.runner:isRunning() or ow.engaging then return false end
local recruiter
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "ROUTE24_COOLTRAINER_M1" then
recruiter = npc
break
end
end
if not recruiter then return false end
ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter)
return true
end,
}
-- -------------------------------------------------------------------
@@ -633,4 +652,96 @@ M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
-- rooms this one gates its ENTRANCE, not its exit: the .blk ships with
-- the arena doorway CLOSED (blocks $72/$73 at block (2,6)/(3,6), cells
-- (4-7,12-13)), and LanceShowOrHideEntranceBlocks OPENS it ($31/$32)
-- on every map load while EVENT_LANCES_ROOM_LOCK_DOOR is unset.
-- Without this script the doorway never opened, so the whole arena --
-- Lance AND both CHAMPIONS_ROOM warps at (5,0)/(6,0) -- was sealed off
-- from the entrance hall and the league dead-ended here ("goto (6,11)
-- unreachable on LANCES_ROOM").
--
-- LancesRoomDefaultScript's coordinate triggers, all inert once
-- EVENT_BEAT_LANCE is set:
-- (5,1)/(6,2) beside Lance -> his battle starts (a coordinate
-- trigger, not a talk; victories.lua OPP_LANCE#1 sets
-- EVENT_BEAT_LANCE on the win, so a loss re-arms)
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- -------------------------------------------------------------------
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
ow:replaceBlock(3, 6, locked and 0x73 or 0x32)
end
local function lanceLockDoor(game, ow)
if game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR then return end
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
require("src.core.Sound").play(game.data, "Go_Inside")
lanceEntranceBlocks(game, ow)
end
local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
end
M.LANCES_ROOM = {
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
-- fires for completed steps -- start the walk-in here, the same
-- way Lorelei's auto walk-in runs from its onEnter
if not game.save.flags.EVENT_BEAT_LANCE
and ow.player.cellX == 24 and ow.player.cellY == 16 then
lanceWalkIn(game, ow)
end
end,
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_LANCE then return false end
if (x == 5 and y == 1) or (x == 6 and y == 2) then
local lance
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
ow:engageTrainer(lance, function() end)
return true
end
if (x == 5 or x == 6) and y == 11 then
lanceLockDoor(game, ow)
return false
end
if x == 24 and y == 16 then
lanceWalkIn(game, ow)
return true
end
return false
end,
}
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
return M
+45 -6
View File
@@ -259,21 +259,39 @@ M.CINNABAR_ISLAND = {
end,
}
-- Pewter's youngster stops you leaving east before Brock is beaten
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the
-- original escorts you to the gym, we walk you back a step)
-- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- west along the road to the front of the PEWTER_GYM door at (16,17).
M.PEWTER_CITY = {
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false
end
local t = text(game)
-- walk up onto the road row (y=17), west to one tile east of the gym
-- door, then drop below the door and turn to face it
local steps = {}
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
steps[#steps + 1] = "down"
steps[#steps + 1] = "left"
local function walk(i)
if not steps[i] then
ow.player.facing = "up"
return
end
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
end
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function()
ow:scriptMove(ow.player, "left", 1)
walk(1)
end)
end)
return true
@@ -360,7 +378,7 @@ local rocketRows = {
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 16 }, -- 8
{ "jump_if_false", 18 }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
@@ -368,6 +386,15 @@ local rocketRows = {
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- disappears. GUARD2 stands on the only walkable neighbour of the
-- trashed house's south door, which is one of the two ways through the
-- fence dividing Cerulean, so this swap is what reconnects the city.
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
-- both are idempotent, so whichever the player reaches first opens it.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
}
M.CERULEAN_CITY = {
@@ -444,10 +471,22 @@ local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end
if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it
-- (Route16Gate1FDefaultScript's wCoordIndex-1). Forcing a fixed one
-- tile up from the counter-adjacent row shoved the player onto the
-- impassable desk and boxed them in.
local closestY = coords[1][2]
for _, c in ipairs(coords) do
if c[2] < closestY then closestY = c[2] end
end
local dist = y - closestY
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
ow:scriptMove(ow.player, "up", 1)
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
end
end)
end)
return true
+32 -9
View File
@@ -5,24 +5,46 @@
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
-- sets to retire unfought non-leader trainers (PewterGym.asm
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
local function range(prefix, first, last)
local t = {}
for i = first, last do
t[#t + 1] = prefix .. i
end
return t
end
return {
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE" },
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM" },
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT" },
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN" },
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC" },
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE" },
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST" },
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE" },
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
@@ -31,7 +53,8 @@ return {
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" },
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+34 -1
View File
@@ -6,4 +6,37 @@ work for true parity. Faithfully-ported behavior is documented in
docs/behavior-porting-notes.md; deliberate additions beyond the original
are in docs/new-features.md.
None currently.
## Reimplemented unused Prof. Oak and Rocket Chief battles
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
both battles are unreachable in the real game. This project makes them
fightable after the Hall of Fame:
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
is set, using `ProfOakData`'s three starter-matched teams (the team is
picked by the type that counters your starter, mirroring the rival).
- The Celadon Game Corner Chief battles you in his house post-game.
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
reconstructed party.
This is an intentional divergence: neither battle can be triggered in the
original game.
## Reimplemented unused Silph Co. card-key doors
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
door block at those coordinates, so the card key check is dead code in
the original game. This project stamps the closed door block (`$54`/`$5f`
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
map load, and swaps it for the open block once that door's
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
Rocket grunt, as in the original's unused design).
This is an intentional divergence: the doors are not visible or
functional in the original game. The door layout lives in
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
it straight through on ROM import.
+30
View File
@@ -17,3 +17,33 @@ Regenerate the reference straight into a wiki checkout:
```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
`--developer` on the command line:
```sh
love . --developer
```
While developer mode is active:
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
`data` and `mods` in scope. Press `` ` `` again to close it.
- `F5` hot-reloads mods and asset caches without restarting.
The console understands these verbs (anything else is evaluated as Lua):
- `warp MAP [x y]` — teleport to a map (default cell 5,5).
- `give ID [n|level]` — add an item (count) or a Pokémon (level).
- `flag NAME [on|off]` — read or set an event flag.
- `party` — dump the current party.
- `mods` — list loaded mods and their state.
- `reload` — hot-reload mods (same as `F5`).
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+2 -1
View File
@@ -132,6 +132,7 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (GBC / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
+74 -12
View File
@@ -13,6 +13,19 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- coroutine that receives `Game` and yields once per
-- frame; used headless (xvfb) for scripted screenshots
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- 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.
local function scriptedIterations()
if not (autopilot or driverCo) then return 1 end
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
local function bootGame()
Game = require("src.core.Game")
Game:load()
@@ -24,6 +37,10 @@ local function bootGame()
local fn = assert(loadfile(driverPath))()
driverCo = coroutine.create(fn)
end
-- After the two above are known: a scripted run drives the multiplier
-- from love.update's loop, so the in-engine one must stay at 1 or the
-- two would compound (10x10 = 100 steps per observation).
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
end
function love.load(args)
@@ -31,8 +48,12 @@ function love.load(args)
for i, a in ipairs(args or {}) do
if a == "--editor" then
editorMode = true
elseif a == "--developer" then
_G.POKEPORT_DEV_MODE = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then
speedOverride = tonumber(args[i + 1])
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
@@ -67,23 +88,36 @@ function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
-- once per Game:update, so they must keep a 1:1 relationship with the
-- logic step. Fast-forwarding them by scaling the step inside
-- Game:update would run N steps per observation: a held direction walks
-- through all N, the player slides past the waypoint, and the script
-- re-plans from an overshot cell. So iterate the whole act+step loop
-- instead -- same script, just more of it per rendered frame.
local iterations = scriptedIterations()
if autopilot then
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
for _ = 1, iterations do
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
end
return
end
if driverCo then
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
for _ = 1, iterations do
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
return
end
Game:update(dt)
@@ -139,6 +173,31 @@ function love.gamepadaxis(joystick, axis, value)
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickremoved(joystick)
if editorMode then return end
if Importer then return end
Game:joystickremoved(joystick)
end
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
-- direction's key-up can be delivered to the OS instead of the game while
-- unfocused, so reset input on either transition rather than trust it.
function love.focus(f)
if editorMode then return end
if Importer then
if Importer.focus then Importer:focus(f) end
return
end
Game:focus(f)
end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode then return end
if Importer then return end
Game:visible(v)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if Importer then return Importer:mousepressed(x, y, 1) end
@@ -191,6 +250,9 @@ function love.quit()
if editorMode and EditorApp.quit then
return EditorApp.quit() -- return true to abort quit
end
pcall(function()
require("src.core.DiscordPresence").shutdown()
end)
end
function love.filedropped(file)
+15 -19
View File
@@ -27,17 +27,17 @@ refresh is safe, just rebuild.
## Build
```bash
# Debug APK (default Android debug keystore)
# Build the APK
scripts/build_android.sh
# Release APK (signing is manual, see below)
scripts/build_android.sh --release
# Build the APK, setting app.version_name/app.version_code to match a release
scripts/build_android.sh --version 0.2.5
# Zip game.love + branding only (no Android SDK required)
scripts/build_android.sh --package-only
```
Or via `scripts/build.sh android`.
Or via `scripts/build.sh android [--version X.Y.Z]`.
The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
@@ -59,12 +59,10 @@ Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`.
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
Build task: `assembleEmbedNoRecordDebug`.
- Debug task: `assembleEmbedNoRecordDebug`
- Release task: `assembleEmbedNoRecordRelease`
APKs land under `app/build/outputs/apk/embedNoRecord/{debug,release}/`.
`scripts/build_android.sh` also copies the built APK(s) to `dist/android/{debug,release}/`.
The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
`scripts/build_android.sh` also copies it to `dist/android/debug/`.
### Payload path
@@ -79,17 +77,15 @@ scripts, tests, and mobile build sources are excluded.
| `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red |
| `app.orientation` | `portrait` |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
## Releases
`.github/workflows/release.yml` builds the APK with `--version` set to the
release version and publishes it alongside the macOS/Windows/Linux builds as
`PokemonRed-<version>-android.apk`.
## Signing
- **Debug**: default Android debug keystore (no setup).
- **Release**: out-of-band. Create a keystore yourself and wire it into
`app/build.gradle`, **do not commit keystores or passwords**.
Example (placeholder only):
```bash
keytool -genkey -v -keystore /path/to/pokemonred-release.jks \
-alias pokemonred -keyalg RSA -keysize 2048 -validity 10000
```
Signed with the default Android keystore (no setup required).
@@ -1,4 +1,8 @@
*.[oa]
# Prebuilt vendored static libs Android.mk links against directly (not
# built by the Gradle/NDK build itself) -- must stay tracked or a fresh
# checkout fails ndk-build with "LOCAL_SRC_FILES points to a missing file".
!android/*/libluajit.a
*.so
*.obj
*.lib
@@ -36,6 +36,7 @@ demos
*~
Makefile*
config*
!src/common/config.h
libtool
/platform/unix/ar-lib
/platform/unix/compile
@@ -183,6 +183,18 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}
bool showFilePicker()
{
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);
env->DeleteLocalRef(activity);
return result;
}
/*
* Helper functions for the filesystem module
*/
@@ -59,6 +59,14 @@ bool openURL(const std::string &url);
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.
**/
bool showFilePicker();
/*
* Helper functions for the filesystem module
*/
@@ -0,0 +1,178 @@
/**
* Copyright (c) 2006-2023 LOVE Development Team
*
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any damages
* arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose,
* including commercial applications, and to alter it and redistribute it
* freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not
* claim that you wrote the original software. If you use this software
* in a product, an acknowledgment in the product documentation would be
* appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and must not be
* misrepresented as being the original software.
* 3. This notice may not be removed or altered from any source distribution.
**/
#ifndef LOVE_CONFIG_H
#define LOVE_CONFIG_H
// Platform stuff.
#if defined(WIN32) || defined(_WIN32)
# define LOVE_WINDOWS 1
// If _USING_V110_SDK71_ is defined it means we are using the xp toolset.
# if defined(_MSC_VER) && (_MSC_VER >= 1700) && !_USING_V110_SDK71_
# include <winapifamily.h>
# if WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_APP) && !WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_DESKTOP)
# define LOVE_WINDOWS_UWP 1
# define LOVE_NO_MODPLUG 1
# define LOVE_NOMPG123 1
# endif
# endif
#endif
#if defined(linux) || defined(__linux) || defined(__linux__)
# define LOVE_LINUX 1
#endif
#if defined(__ANDROID__)
# define LOVE_ANDROID 1
#endif
#if defined(__APPLE__)
# include <TargetConditionals.h>
# if TARGET_OS_IPHONE
# define LOVE_IOS 1
# elif TARGET_OS_MAC
# define LOVE_MACOSX 1
# endif
#endif
#if defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
// I know it's not linux, but it seems most "linux-only" code is bsd-compatible
# define LOVE_LINUX 1
#endif
// Endianness.
#if defined(__ppc__) || defined(__ppc) || defined(__powerpc__) || defined(__powerpc)
# define LOVE_BIG_ENDIAN 1
#else
# define LOVE_LITTLE_ENDIAN 1
#endif
// SSE instructions.
#if defined(__SSE__)
# define LOVE_SIMD_SSE
#elif defined(_MSC_VER)
# if defined(_M_AMD64) || defined(_M_X64)
# define LOVE_SIMD_SSE
# elif _M_IX86_FP
# define LOVE_SIMD_SSE
# endif
#endif
// NEON instructions.
#if defined(__ARM_NEON)
# define LOVE_SIMD_NEON
#endif
// Warnings.
#ifndef _CRT_SECURE_NO_WARNINGS
# define _CRT_SECURE_NO_WARNINGS
#endif
// Preferably, and ironically, this macro should go unused.
#ifndef LOVE_UNUSED
# define LOVE_UNUSED(x) (void)sizeof(x)
#endif
// Warn on unused return values
#ifdef __GNUC__
# define LOVE_WARN_UNUSED __attribute__((warn_unused_result))
#elif _MSC_VER
# define LOVE_WARN_UNUSED _Check_return_
#else
# define LOVE_WARN_UNUSED
#endif
#ifndef LOVE_BUILD
# define LOVE_BUILD
# define LOVE_BUILD_STANDALONE
# define LOVE_BUILD_EXE
//# define LOVE_BUILD_DLL
#endif
// DLL-stuff.
#if defined(_MSC_VER) || defined(__MINGW32__)
# define LOVE_EXPORT __declspec(dllexport)
#elif defined(__GNUC__) || defined(__clang__)
# define LOVE_EXPORT __attribute__((visibility("default")))
#else
# define LOVE_EXPORT
#endif
#if defined(LOVE_WINDOWS)
#ifndef LOVE_WINDOWS_UWP
# define LOVE_LEGENDARY_CONSOLE_IO_HACK
#endif // LOVE_WINDOWS_UWP
#ifndef __MINGW32__
# define NOMINMAX
#endif
#endif
#if defined(LOVE_MACOSX) || defined(LOVE_IOS)
# define LOVE_LEGENDARY_APP_ARGV_HACK
#endif
#if defined(LOVE_ANDROID) || defined(LOVE_IOS)
# define LOVE_LEGENDARY_ACCELEROMETER_AS_JOYSTICK_HACK
#endif
// Autotools config.h
#ifdef HAVE_CONFIG_H
# include <../config.h>
# undef VERSION
# ifdef WORDS_BIGENDIAN
# undef LOVE_LITTLE_ENDIAN
# define LOVE_BIG_ENDIAN 1
# else
# undef LOVE_BIG_ENDIAN
# define LOVE_LITTLE_ENDIAN 1
# endif
#else
# define LOVE_ENABLE_LOVE
# define LOVE_ENABLE_AUDIO
# define LOVE_ENABLE_DATA
# define LOVE_ENABLE_EVENT
# define LOVE_ENABLE_FILESYSTEM
# define LOVE_ENABLE_FONT
# define LOVE_ENABLE_GRAPHICS
# define LOVE_ENABLE_IMAGE
# define LOVE_ENABLE_JOYSTICK
# define LOVE_ENABLE_KEYBOARD
# define LOVE_ENABLE_MATH
# define LOVE_ENABLE_MOUSE
# define LOVE_ENABLE_PHYSICS
# define LOVE_ENABLE_SOUND
# define LOVE_ENABLE_SYSTEM
# define LOVE_ENABLE_THREAD
# define LOVE_ENABLE_TIMER
# define LOVE_ENABLE_TOUCH
# define LOVE_ENABLE_VIDEO
# define LOVE_ENABLE_WINDOW
# define LOVE_ENABLE_ENET
# define LOVE_ENABLE_LUASOCKET
# define LOVE_ENABLE_LUA53
#endif
// Check we have a sane configuration
#if !defined(LOVE_WINDOWS) && !defined(LOVE_LINUX) && !defined(LOVE_IOS) && !defined(LOVE_MACOSX) && !defined(LOVE_ANDROID)
# error Could not detect target platform
#endif
#if !defined(LOVE_LITTLE_ENDIAN) && !defined(LOVE_BIG_ENDIAN)
# error Could not detect endianness
#endif
#endif // LOVE_CONFIG_H
@@ -186,6 +186,18 @@ bool Filesystem::setIdentity(const char *ident, bool appendToPath)
save_path_full = storage_path + std::string("/save/") + save_identity;
// love::android::mkdir is a single mkdir(), not mkdir -p: on a genuinely
// first-ever launch (nothing has touched this app's external-files dir
// before) save_directory doesn't exist yet either, so creating
// save_path_full in one step fails with ENOENT and PHYSFS_mount below
// silently never mounts anything for the rest of this process -- not
// just the save dir, but everything routed through it (save.lua/
// options.lua, the ROM-derived asset cache, RomImporter's Android
// folder scan). Ensure each level exists in order instead.
if (!love::android::directoryExists(save_directory.c_str()) &&
!love::android::mkdir(save_directory.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_directory.c_str());
if (!love::android::directoryExists(save_path_full.c_str()) &&
!love::android::mkdir(save_path_full.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str());
@@ -338,6 +350,26 @@ bool Filesystem::setupWriteDirectory()
std::string temp_writedir = getDriveRoot(save_path_full);
std::string temp_createdir = skipDriveRoot(save_path_full);
#ifdef LOVE_ANDROID
// getUserDirectory() falls back to $HOME/getpwuid() (physfs_platform_posix.c),
// which is meaningless on Android and unrelated to save_path_full (an
// SDL_AndroidGet*StoragePath() subdirectory -- see setIdentity above), so
// the generic check below never matches and falls through to setting the
// write dir to the drive root ("/"), which no Android app can write to.
// Anchor to the real Android storage root instead.
std::string androidStorageRoot = isAndroidSaveExternal()
? SDL_AndroidGetExternalStoragePath() : SDL_AndroidGetInternalStoragePath();
if (save_path_full.find(androidStorageRoot) == 0)
{
temp_writedir = androidStorageRoot;
temp_createdir = save_path_full.substr(androidStorageRoot.length());
size_t startpos = temp_createdir.find_first_not_of('/');
if (startpos != std::string::npos)
temp_createdir = temp_createdir.substr(startpos);
}
else
#endif
// On some sandboxed platforms, physfs will break when its write directory
// is the root of the drive and it tries to create a folder (even if the
// folder's path is in a writable location.) If the user's home folder is
@@ -180,6 +180,15 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -106,6 +106,15 @@ public:
*/
virtual void vibrate(double seconds) const;
/**
* Shows the platform's native "pick a file" UI, if one is available.
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -95,6 +95,12 @@ int w_vibrate(lua_State *L)
return 0;
}
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -110,6 +116,7 @@ static const luaL_Reg functions[] =
{ "getPowerInfo", w_getPowerInfo },
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -61,6 +61,13 @@ 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
// 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 boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity {
return openURL(url) == 0;
}
/**
* 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)
* arrives later in onActivityResult, not synchronously here.
*/
@Keep
public static boolean showRomFilePicker() {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
return false;
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
}
}
/**
* Copies a given file from the assets folder to the destination.
*
+96 -14
View File
@@ -1,15 +1,17 @@
#!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into distributable macOS and
# Windows builds. Runs entirely on macOS (no cross-compiling needed,
# the Windows build reuses LÖVE's prebuilt win64 binaries).
# Packages the LÖVE2D Pokémon Red port into distributable macOS, Windows,
# and Linux builds. Runs entirely on macOS (no cross-compiling needed,
# the Windows and Linux builds reuse LÖVE's prebuilt win64 / AppImage
# binaries, fusing our game.love onto them the same way love.exe does).
#
# Usage: scripts/build.sh [mac|win|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
# [--notary-profile NAME] [--no-notarize]
# [--release] # android/ios: release config instead of debug
# [--release] # ios only: release config instead of debug
#
# Output: dist/mac/PokemonRed-macos.zip
# dist/win/PokemonRed-win64.zip
# dist/android/{debug,release}/*.apk (full gradle output stays under
# dist/linux/PokemonRed-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays
# under mobile/ios/build/Build/Products/)
@@ -27,11 +29,11 @@ APP_NAME="PokemonRed"
BUNDLE_ID="com.theboisclub.pokemonred"
LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
VERSION_EXPLICIT=false
IDENTITY=""
TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
ANDROID_RELEASE=false
IOS_RELEASE=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -40,18 +42,18 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
mac|win|android|ios|all) TARGET="$1" ;;
--version) VERSION="$2"; shift ;;
mac|win|linux|android|ios|all) TARGET="$1" ;;
--version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
--identity) IDENTITY="$2"; shift ;;
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--release) ANDROID_RELEASE=true; IOS_RELEASE=true ;;
--release) IOS_RELEASE=true ;;
*) fail "unknown argument: $1" ;;
esac
shift
done
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win"
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# --------------------------------------------------------------- game.love
say "packing game.love"
@@ -139,11 +141,20 @@ build_win() {
say "building Windows (win64) app"
local zip_name="love-$LOVE_VERSION-win64.zip"
local love_zip="$CACHE/$zip_name"
# A cache hit only checks existence, not validity -- a prior run truncated
# by a network drop mid-download (curl still leaves the partial file if
# the exit code slips through) would otherwise be reused forever.
if [ -f "$love_zip" ] && ! unzip -tqq "$love_zip" >/dev/null 2>&1; then
warn "cached $zip_name is not a valid zip, removing and re-downloading"
rm -f "$love_zip"
fi
if [ ! -f "$love_zip" ]; then
say "downloading LÖVE $LOVE_VERSION win64 binaries"
curl -fL --progress-bar \
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \
-o "$love_zip" || fail "download failed, check LOVE_VERSION or your network"
unzip -tqq "$love_zip" >/dev/null 2>&1 \
|| fail "downloaded $zip_name is not a valid zip (truncated download?)"
fi
local extract_dir="$WORK/love-win64"
@@ -167,12 +178,82 @@ build_win() {
say "Windows build: $zip_out"
}
# --------------------------------------------------------------- Linux
build_linux() {
say "building Linux (x86_64 AppImage) app"
local appimage_name="love-$LOVE_VERSION-x86_64.AppImage"
local love_appimage="$CACHE/$appimage_name"
# Same cache-validity gap as the win64 zip above: an AppImage is just an
# ELF, so check the magic bytes before trusting a cached copy is complete.
if [ -f "$love_appimage" ] && [ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" != "7f454c46" ]; then
warn "cached $appimage_name is not a valid ELF binary, removing and re-downloading"
rm -f "$love_appimage"
fi
if [ ! -f "$love_appimage" ]; then
say "downloading LÖVE $LOVE_VERSION Linux AppImage"
curl -fL --progress-bar \
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$appimage_name" \
-o "$love_appimage" || fail "download failed, check LOVE_VERSION or your network"
[ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" = "7f454c46" ] \
|| fail "downloaded $appimage_name is not a valid ELF binary (truncated download?)"
fi
chmod +x "$love_appimage"
# The Windows-style `cat love.exe game.love` fusion does NOT work here:
# an AppImage is a small runtime ELF with a squashfs appended, and at
# launch the runtime mounts the squashfs and executes bin/love from
# *inside* it -- bytes appended to the outer file are never read, so
# users would just get vanilla LÖVE's no-game screen. Instead, unpack
# the squashfs, drop game.love in, point AppRun's FUSE_PATH hook at it
# (the hook ships commented-out in LÖVE's official AppImage), and glue
# runtime + repacked squashfs back together.
command -v unsquashfs >/dev/null && command -v mksquashfs >/dev/null \
|| fail "squashfs tools not found; install with: brew install squashfs"
# The squashfs starts right where the ELF ends:
# e_shoff + e_shnum * e_shentsize (all little-endian in the ELF64 header).
local e_shoff e_shentsize e_shnum sfs_offset
e_shoff=$(od -An -j40 -N8 -tu8 "$love_appimage" | tr -d ' ')
e_shentsize=$(od -An -j58 -N2 -tu2 "$love_appimage" | tr -d ' ')
e_shnum=$(od -An -j60 -N2 -tu2 "$love_appimage" | tr -d ' ')
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
[ "$(dd if="$love_appimage" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|| fail "no squashfs superblock at computed offset $sfs_offset (unexpected AppImage layout)"
local appdir="$WORK/linux-appdir"
rm -rf "$appdir"
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
local sfs_out="$WORK/game.squashfs"
rm -f "$sfs_out"
mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
rm -f "$out_bin"
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin"
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
rm -f "$zip_out"
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
say "Linux build: $zip_out"
}
# --------------------------------------------------------------- Android
build_android() {
say "building Android (delegating to scripts/build_android.sh)"
local args=()
if [ "$ANDROID_RELEASE" = true ]; then
args+=(--release)
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
}
@@ -190,9 +271,10 @@ build_ios() {
case "$TARGET" in
mac) build_mac ;;
win) build_win ;;
linux) build_linux ;;
android) build_android ;;
ios) build_ios ;;
all) build_mac; build_win ;;
all) build_mac; build_win; build_linux ;;
esac
case "$TARGET" in
+28 -30
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
#
# Usage: scripts/build_android.sh [--release] [--package-only]
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
#
# (default) assembleEmbedNoRecordDebug (debug keystore)
# --release assembleEmbedNoRecordRelease (requires out-of-band signing)
# --package-only zip game.love + apply branding; skip gradle
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
# --package-only zip game.love + apply branding; skip gradle
#
# Prerequisites:
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
@@ -13,9 +12,8 @@
# - JDK 17
#
# Output (after gradle):
# dist/android/{debug,release}/*.apk (convenience copy)
# dist/android/debug/*.apk (convenience copy)
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
# mobile/android/app/build/outputs/apk/embedNoRecord/release/*.apk
set -euo pipefail
@@ -29,7 +27,7 @@ APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653"
RELEASE=false
VERSION=""
PACKAGE_ONLY=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -38,17 +36,29 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--release) RELEASE=true ;;
--version) VERSION="$2"; shift ;;
--package-only) PACKAGE_ONLY=true ;;
-h|--help)
sed -n '2,20p' "$0"
exit 0
;;
*) fail "unknown argument: $1 (try --release or --package-only)" ;;
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
esac
shift
done
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
major="${VERSION%%.*}"
rest="${VERSION#*.}"
minor="${rest%%.*}"
patch="${rest##*.}"
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
fi
# --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
fail "love-android not found at mobile/android/.
@@ -74,10 +84,10 @@ apply_android_branding() {
say "applying Android branding (gradle.properties + permission trim)"
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" <<'PY'
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" "$VERSION" "$VERSION_CODE" <<'PY'
import pathlib, re, sys
path = pathlib.Path(sys.argv[1])
app_id, name = sys.argv[2], sys.argv[3]
app_id, name, version, version_code = sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
text = path.read_text()
def set_prop(text, key, value):
@@ -92,6 +102,9 @@ text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
text = set_prop(text, "app.name", name)
text = set_prop(text, "app.application_id", app_id)
text = set_prop(text, "app.orientation", "portrait")
if version:
text = set_prop(text, "app.version_name", version)
text = set_prop(text, "app.version_code", version_code)
path.write_text(text)
PY
@@ -178,16 +191,8 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task
if $RELEASE; then
task="assembleEmbedNoRecordRelease"
say "building release APK ($task)"
warn "release signing is out-of-band, see mobile/ANDROID.md (Signing)."
warn "without a signingConfig, assembleRelease may produce an unsigned APK or fail."
else
task="assembleEmbedNoRecordDebug"
say "building debug APK ($task), uses the default Android debug keystore"
fi
local task="assembleEmbedNoRecordDebug"
say "building APK ($task)"
if ! (
cd "$ANDROID_DIR"
@@ -199,19 +204,12 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
local out_dir
if $RELEASE; then
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/release"
else
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
local flavor="debug"
$RELEASE && flavor="release"
local dist_dir="$DIST/$flavor"
local dist_dir="$DIST/debug"
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
+11 -18
View File
@@ -78,23 +78,14 @@ run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
# ------- content tier: only meaningful with an imported ROM
# tests/run_tests.lua carries two pre-existing failures that are stale
# about the chip-audio architecture rather than real defects:
#
# Pikachu cry WAV exists nothing writes .wav any more -- cries are
# synthesized at play time from
# Data.audio.cries + programs.bin
# low-health alarm sfx extracted the importer deliberately does not
# extract it; Sound.startLoop falls back to
# ChipAudio.newLowHealthAlarm (Sound.lua:268)
#
# They are left in place (fixing them is a separate, reviewed change), so
# the tier passes on exactly this baseline and fails the moment a third
# failure appears or one of these two changes identity. Ignoring the exit
# code outright would hide every future content regression.
KNOWN_CONTENT_FAILURES=2
KNOWN_CONTENT_LINES="FAIL Pikachu cry WAV exists
FAIL low-health alarm sfx extracted"
# tests/run_tests.lua is expected to be clean. It used to carry two stale
# chip-audio assertions on the allowlist below (Pikachu cry WAV exists /
# low-health alarm sfx extracted); both have since been fixed, so the
# baseline is zero and any failure fails the tier. Keep the allowlist
# mechanism rather than ignoring the exit code -- that would hide every
# future content regression.
KNOWN_CONTENT_FAILURES=0
KNOWN_CONTENT_LINES=""
run_content_behavior() {
local out
@@ -107,7 +98,9 @@ run_content_behavior() {
if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \
&& [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then
printf '%s\n' "$out" | tail -3
echo "(the $KNOWN_CONTENT_FAILURES known stale audio assertions, unchanged)"
if [ "$KNOWN_CONTENT_FAILURES" -gt 0 ]; then
echo "(the $KNOWN_CONTENT_FAILURES known stale assertions, unchanged)"
fi
return 0
fi
+210 -32
View File
@@ -106,19 +106,25 @@ end
Assets.register(BattleState.invalidate)
-- the species' SGB palette (data/pokemon/palettes.asm), or nil
-- the species' SGB palette (active COLORS pack), or nil
local function monPalette(data, species)
local p = data.palettes
local name = p and p.pokemon[species]
local colors = name and p.palettes[name]
return colors and { name = name, colors = colors } or nil
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(data, species)
if not colors then return nil end
local name = PaletteFX.monPalName(data, species)
-- prefix so GBC vs RED++ cache keys don't collide on shared names
if PaletteFX.usesGbcPack() then name = "redpp:" .. name end
return { name = name, colors = colors }
end
-- a named palette from data/generated/palettes.lua as a getImage pal
-- a named palette from the active COLORS pack as a getImage pal
local function namedPalette(data, name)
local p = data.palettes
local colors = p and p.palettes[name]
return colors and { name = name, colors = colors } or nil
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.pal(data, name)
if not colors then return nil end
local key = name
if PaletteFX.usesGbcPack() then key = "redpp:" .. name end
return { name = key, colors = colors }
end
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
@@ -264,8 +270,10 @@ function BattleState:speciesSprite(species, isPlayerSide)
if not def then return nil end
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,
colors and { name = "GRAYMON", colors = colors } or nil,
colors and { name = name, colors = colors } or nil,
def.trueColor)
end
@@ -414,8 +422,17 @@ end
function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
-- instead of TrainerNames ("RIVAL1" etc.). Overlay so we don't mutate
-- the shared data table.
if oppClass == "OPP_RIVAL1" or oppClass == "OPP_RIVAL2"
or oppClass == "OPP_RIVAL3" then
local rivalName = (game.save.player and game.save.player.rival) or "BLUE"
self.trainer = setmetatable({ name = rivalName }, { __index = self.trainer })
end
self.enemyAIMods = self.trainer.aiMods
local partyDef = self.trainer.parties[partyIndex or 1]
assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex)))
@@ -618,7 +635,6 @@ function BattleState:startMessage(item)
self.total = self.total + #codes
end
self.charIndex = 0
self.holdTimer = nil
end
function BattleState:updateQueue()
@@ -769,9 +785,8 @@ function BattleState:updateQueue()
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
else
self.holdTimer = (self.holdTimer or 40) - 1
local input = self.game.input
if self.holdTimer <= 0 or input:wasPressed("a") or input:wasPressed("b") then
if input:wasPressed("a") or input:wasPressed("b") then
self.current = nil
end
end
@@ -951,6 +966,25 @@ function BattleState:playerHasPP()
return false
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
local a, b = moves[i], moves[j]
if not (a and b) then return end
moves[i], moves[j] = b, a
local stored = self.player.mon and self.player.mon.moves
if stored and stored ~= moves and stored[i] and stored[j] then
stored[i], stored[j] = stored[j], stored[i]
end
local disabled = self.player.disabledSlot
if disabled == i then
self.player.disabledSlot = j
elseif disabled == j then
self.player.disabledSlot = i
end
require("src.core.Sound").play(self.data, "Swap")
end
function BattleState:update(dt)
self.frame = self.frame + 1
self:updateFx()
@@ -1064,6 +1098,7 @@ function BattleState:update(dt)
end
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
@@ -1081,9 +1116,22 @@ function BattleState:update(dt)
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
elseif input:wasPressed("select") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
else
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
self:say("The move is\ndisabled!")
@@ -1484,10 +1532,16 @@ function BattleState:endOfTurn()
-- that sets the flag also zeroes the counter, so a stale value is
-- unobservable (a switch or cure downgrades Toxic to plain poison).
self.sideToxic = self.sideToxic or {}
for _, pair in ipairs({ { self.player, self.enemy, "player" },
{ self.enemy, self.player, "enemy" } }) do
local b, opp, side = pair[1], pair[2], pair[3]
if b.mon.hp > 0 then
-- a battler whose opponent was already knocked out by a move this turn
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
-- move faints the target); snapshot before residual so one side's
-- residual faint can't suppress the other's
local playerAlive = self.player.mon.hp > 0
local enemyAlive = self.enemy.mon.hp > 0
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
{ self.enemy, self.player, "enemy", playerAlive } }) do
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
if b.mon.hp > 0 and oppAlive then
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
@@ -1547,6 +1601,25 @@ function BattleState:animationsOn()
return not o or o.animations ~= false
end
-- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths.
function BattleState:cancelMoveAnim()
local row = self.moveAnimRow
if not row then return end
self.moveAnimRow = nil
for i, item in ipairs(self.queue) do
if item == row then
table.remove(self.queue, i)
if self.nextInsert and i <= self.nextInsert then
self.nextInsert = self.nextInsert - 1
end
return
end
end
end
-- ------------------------------------------------------------------
-- special-effect (SE_*) implementations. Palette effects are BGP
-- shade maps ({[i] = shade color index i displays as}); on the SGB the
@@ -2151,6 +2224,20 @@ end
-- Decomposed into a staged pipeline over the merged move_effects record:
-- announcement -> callsMove -> charge -> perform -> primary run -> the
-- damaging pipeline (EffectRegistry.runDamaging).
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
-- after they land; these failure texts print with no animation.
local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end
local m = msgs[1]
if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end
if m:find("is unaffected", 1, true) then return true end
if m:find("protected by MIST", 1, true) then return true end
if m:find("Already", 1, true) then return true end
return false
end
function BattleState:performMove(user, target, moveInst, isCalled)
local move = self:moveDef(moveInst)
if not move then
@@ -2198,6 +2285,11 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- already said its failure text
if record and record.callsMove then
local pick = record.callsMove(ctx)
-- Mirror Move never plays its own anim (MetronomePickMove does;
-- MirrorMoveCopyMove only reloads the copied move or prints fail)
if move.id == "MIRROR_MOVE" or not pick then
self:cancelMoveAnim()
end
if pick then
self:performMove(user, target, { id = pick, pp = 1 }, true)
end
@@ -2210,6 +2302,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then
self:cancelMoveAnim()
user.charging = moveInst
user.chargeReady = true
local invulnerable = move.semiInvulnerable
@@ -2219,6 +2312,15 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if invulnerable then
user.invulnerable = true
end
local chargeAnim = record.charge.anim
if move.id == "DIG" then
chargeAnim = "SLIDE_DOWN_ANIM"
elseif record.charge.enemyAnim and not user.isPlayer then
chargeAnim = record.charge.enemyAnim
end
if chargeAnim then
self:animNext(chargeAnim, user.isPlayer)
end
local chargeText = move.chargeText or CHARGE_TEXT[move.id]
or "%s\nis charging up!"
self:sayNext(chargeText:format(displayName(user)))
@@ -2240,10 +2342,19 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if record.accuracyChecked
and (target.invulnerable
or not self:accuracyRoll(move, user, target)) then
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
-- after the effect lands; a miss skips it
self:cancelMoveAnim()
self:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
for _, m in ipairs(record.run(ctx)) do
local msgs = record.run(ctx)
-- Gen 1 status/stat effects animate only when they take effect
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
if primaryEffectFailed(msgs) then
self:cancelMoveAnim()
end
for _, m in ipairs(msgs) do
self:sayNext(m)
end
self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar
@@ -2251,6 +2362,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end
if move.power == 0 and not (record and record.kind == "full") then
MoveEffects.warnUnknown(move.effect)
self:cancelMoveAnim()
self:sayNext("But, it failed!")
return
end
@@ -2290,6 +2402,7 @@ function BattleState:continueBide(user, target)
local dmg = (user.bideDamage or 0) * 2
user.bideTurns, user.bideDamage = nil, nil
if dmg <= 0 then
self:cancelMoveAnim()
self:sayNext("But, it failed!")
return
end
@@ -2335,6 +2448,9 @@ end
function BattleState:onFaint(battler)
if battler.faintQueued then return end
battler.faintQueued = true
if battler.isPlayer and self.participants then
self.participants[battler.mon] = nil
end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
-- the faint slide + cry ride the queue (after the move animation and
-- the HP-bar drain, pokered's order); the slide finishes before the
@@ -2371,9 +2487,9 @@ function BattleState:enemyMonFainted()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
-- the divisor counts EVERY participant, fainted ones included
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the
-- living ones are actually paid
-- a mon that fainted mid-fight has had its gain-exp flag cleared
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid
local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do
if self.participants and self.participants[mon] then
@@ -2558,16 +2674,53 @@ function BattleState:learnMove(mon, moveId)
end)
end
-- Map the battle was fought on (overworld wins; save.player.map is fallback).
function BattleState.currentMapId(self)
local game = self.game
local ow = game and game.overworld
if ow and ow.map then return ow.map.id end
local player = game and game.save and game.save.player
return player and player.map
end
-- pret HandlePlayerBlackOut: OPP_RIVAL1 in OAKS_LAB prints Rival1WinText
-- and returns without blacking out. OaksLabRivalEndBattleScript then heals.
function BattleState.isOaksLabStarterRival(self)
return self.oppClass == "OPP_RIVAL1"
and BattleState.currentMapId(self) == "OAKS_LAB"
end
function BattleState:playerMonFainted()
if self.result then return end -- double faint: the battle is decided
local nextMon = Party.firstHealthy(self.game.save.party)
if not nextMon then
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
-- Being out of useable POKéMON blacks you out even when the battle was
-- already decided in our favour. A double faint -- our last mon dying
-- to residual damage on the turn it lands the KO -- used to hit the
-- "battle is decided" guard below and return with result = "win", so
-- afterBattle never took the lose branch: no revive, no warp to the
-- heal point, and the player was left standing on the map with a party
-- at 0 HP. Nothing recovers from that state (every later encounter
-- aborts with "no healthy party"), and it is not reachable in pokered:
-- HandlePlayerMonFainted runs the player-side check on its own, so
-- losing your last mon always blacks you out whatever the enemy did.
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
if not nextMon and self.result ~= "lose" then
if self.oppClass == "OPP_RIVAL1" then
local TextBox = require("src.render.TextBox")
local raw = (self.data.text and self.data.text._Rival1WinText)
or "{RIVAL}: Yeah! Am\nI great or what?"
self:sayNext(TextBox.substitute(self.game, raw))
end
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
if not BattleState.isOaksLabStarterRival(self) then
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
end
self.result = "lose"
self.afterQueue = "finish"
return
end
if self.result then return end -- double faint: the battle is decided
-- DoUseNextMonDialogue (core.asm:1052-1078): only WILD battles ask
-- "Use next POKéMON?"; NO goes through the run check with party slot
-- 1's speed, and a failed run still forces the party menu. Trainer
@@ -2646,6 +2799,7 @@ function BattleState:safariAction(choice)
local playerName = self.game.save.player.name
if choice == "run" then
require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!")
self.result = "run"
self.afterQueue = "finish"
@@ -2727,6 +2881,12 @@ function BattleState:safariEnemyTurn()
end
if fled then
self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
self:actNext(function()
require("src.core.Sound").play(self.data, "Run")
startPicKind(self:picFxFor(self.enemy), "slideOff")
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 24 })
self.result = "run"
self.afterQueue = "finish"
end
@@ -3026,6 +3186,19 @@ function BattleState:finish()
self.phase = "messages"
return
end
-- Invariant: a battle can never hand the overworld a party with nothing
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
-- afterBattle only revives and warps to the heal point on a normal
-- "lose", so any other result here strands the player at 0 HP with no
-- way back -- an unrecoverable state, not merely a wrong one.
-- playerMonFainted is the path that should have caught this; if we land
-- here it did not, so say so rather than silently papering over it.
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
end
self.lockedBall = nil
-- pokered never writes Mimic's copy into the party struct; leaving
-- battle discards the battle copy, so the original ids come back
@@ -3343,9 +3516,10 @@ function BattleState:colorMode()
if self.colorFxReady == nil then
local ready = false
local g = love and love.graphics
local PaletteFX = require("src.render.PaletteFX")
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
and love.image and self.data.palettes
and require("src.render.PaletteFX").shader() then
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)
if ok1 and ok2 and bg and wv then
@@ -3364,9 +3538,10 @@ end
-- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its
-- trainer/back pic (the species bytes are 0 then).
function BattleState:sgbBattlePals()
local pals = self.data.palettes and self.data.palettes.palettes
if not pals then return nil end
local PaletteFX = require("src.render.PaletteFX")
local pack = PaletteFX.pack(self.data)
local pals = pack and pack.palettes
if not pals then return nil end
local function bar(b)
if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp
@@ -3374,8 +3549,7 @@ function BattleState:sgbBattlePals()
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
local name = self.data.palettes.pokemon[b.mon.species]
return pals[name] or pals.MEWMON
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
return {
[0] = bar(self.player),
@@ -3709,7 +3883,11 @@ function BattleState:drawTextArea()
for i, mv in ipairs(self.player.curMoves) do
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
end
Font.drawCode(0xED, 40, 96 + self.moveIndex * 8)
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
40, 96 + self.moveIndex * 8)
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
end
local sel = self.player.curMoves[self.moveIndex]
if sel then
if self.player.disabledSlot == self.moveIndex then
+10
View File
@@ -89,6 +89,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
-- SWIFT_EFFECT before the INVULNERABLE check)
if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
@@ -97,6 +99,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if record and record.gate then
local ok, failMsg = record.gate(ctx)
if not ok then
battle:cancelMoveAnim()
if failMsg then battle:sayNext(failMsg) end
return
end
@@ -108,6 +111,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not neverMiss then
if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
@@ -134,6 +139,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
end
if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
@@ -144,6 +150,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- failed with that text already chosen
local chosen, extra = record.chooseDamage(ctx)
if not chosen then
battle:cancelMoveAnim()
if extra then battle:sayNext(extra) end
return
end
@@ -154,12 +161,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
end
if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
+7 -2
View File
@@ -516,8 +516,8 @@ MoveEffects.full = {
-- charge moves: first turn just charges; Fly AND Dig go
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
CHARGE_EFFECT = { charge = {} },
FLY_EFFECT = { charge = { invulnerable = true } },
CHARGE_EFFECT = { charge = { anim = "XSTATITEM_ANIM", enemyAnim = "XSTATITEM_DUPLICATE_ANIM" } },
FLY_EFFECT = { charge = { invulnerable = true, anim = "TELEPORT" } },
TRAPPING_EFFECT = {
-- TrappingEffect runs BEFORE the hit test and clears the target's
@@ -616,6 +616,7 @@ MoveEffects.full = {
-- roll is below opponentLevel/4. Teleport's failure text is "But
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
-- Fail paths DelayFrames then print -- no PlayCurrentMoveAnimation.
perform = function(ctx)
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
if battle.kind == "wild" then
@@ -635,13 +636,17 @@ MoveEffects.full = {
battle.result = "run"
battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
else
battle:cancelMoveAnim()
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
end
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
else
battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
end
end,
+29 -1
View File
@@ -41,7 +41,11 @@ local CONSTANT_DEFAULTS = {
-- field.boot is the total-conversion override point for the new game; the
-- values match what SaveData.newGame and the Oak speech used to inline.
local BOOT_DEFAULTS = {
startMap = "PALLET_TOWN", startX = 5, startY = 6, startFacing = "down",
-- special_warps.asm NewGameWarp: REDS_HOUSE_2F, 3, 6 -- the bedroom, not
-- the tile outside the house. lastHeal is deliberately absent: SaveData
-- derives the vanilla blackout point, and seeding it here would leak into
-- total conversions that patch the spawn without naming a heal point.
startMap = "REDS_HOUSE_2F", startX = 3, startY = 6, startFacing = "down",
playerName = "RED", rivalName = "BLUE",
startMoney = 3000,
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
@@ -93,6 +97,30 @@ function Data:seedDefaults()
-- the overworld's Kanto literals, same fill-if-absent contract; required
-- here rather than at the top so core keeps out of src/world at load time
require("src.world.FieldDefaults").seed(self)
-- Cinnabar's quiz trainers are text_asm (no def_trainers), so the
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
local gym = {}
for i = 0, 6 do
local n = i + 1
-- object indices 2..8 are SUPER_NERD1..7; range 0 -- they only
-- engage via talk / wrong quiz answer, never sight lines
gym[i + 2] = {
event = "EVENT_BEAT_CINNABAR_GYM_TRAINER_" .. i,
range = 0,
battle = "_CinnabarGymSuperNerd" .. n .. "BattleText",
won = "_CinnabarGymSuperNerd" .. n .. "EndBattleText",
after = "_CinnabarGymSuperNerd" .. n .. "AfterBattleText",
}
end
headers.CinnabarGym = gym
end
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
+658
View File
@@ -0,0 +1,658 @@
-- Discord Rich Presence via the local Discord IPC pipe. Shows the player's
-- current map (and battle status) on their Discord profile. Desktop only;
-- no-ops on mobile, headless runs, or when Discord is not running.
--
-- Application ID is the only credential needed for presence. The Discord
-- Interactions public key is unused here (that is for verifying bot HTTP
-- payloads, not Rich Presence).
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local DiscordPresence = {
APP_ID = "1529183141267374262",
}
local OP_HANDSHAKE, OP_FRAME, OP_CLOSE = 0, 1, 2
local MIN_UPDATE_INTERVAL = 2.0
local RECONNECT_INTERVAL = 30.0
local state = {
enabled = false,
connected = false,
socket = nil, -- Windows: file handle; Unix: ffi sockfd
isWindows = false,
ffi = nil,
ffiReady = false,
startedAt = 0,
dirty = false,
lastSentKey = nil,
lastSendAt = -1e9,
nextReconnectAt = 0,
location = nil, -- display name
mapId = nil,
activity = "menu", -- menu | exploring | battle
battleLabel = nil,
unsubs = {},
pid = nil,
loggedAbsent = false, -- one quiet note when Discord isn't running
}
local function disabledByEnv()
return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
or os.getenv("POKEPORT_DRIVER") ~= nil
end
local function isDesktop()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "OS X" or osName == "Windows" or osName == "Linux"
end
local function packU32(n)
n = math.floor(n) % 0x100000000
local b1 = n % 256; n = math.floor(n / 256)
local b2 = n % 256; n = math.floor(n / 256)
local b3 = n % 256; n = math.floor(n / 256)
local b4 = n % 256
return string.char(b1, b2, b3, b4)
end
local function unpackU32(s)
local b1, b2, b3, b4 = s:byte(1, 4)
return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
end
local function nonce()
return string.format("%08x-%04x-4%03x-%04x-%04x%08x",
math.random(0, 0xffffffff),
math.random(0, 0xffff),
math.random(0, 0xfff),
math.random(0, 0x3fff) + 0x8000,
math.random(0, 0xffff),
math.random(0, 0xffffffff))
end
local function resolvePid()
if state.pid then return state.pid end
local ffi = state.ffi
if ffi then
if state.isWindows then
pcall(ffi.cdef, "unsigned long GetCurrentProcessId(void);")
local ok, p = pcall(function() return ffi.C.GetCurrentProcessId() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
else
pcall(ffi.cdef, "int getpid(void);")
local ok, p = pcall(function() return ffi.C.getpid() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
end
end
state.pid = 1000 + math.random(1, 8999)
return state.pid
end
local function ensureFfi()
if state.ffiReady then return state.ffi end
if state.ffi == false then return nil end
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then
state.ffi = false
return nil
end
state.ffi = ffi
if state.isWindows then
state.ffiReady = true
return ffi
end
-- Darwin sockaddr_un has sun_len + uint8_t family; Linux uses u16 family.
local osName = love.system.getOS()
local cdef
if osName == "OS X" then
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
uint8_t sun_len;
uint8_t sun_family;
char sun_path[104];
};
struct timeval { long tv_sec; int tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
else
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
unsigned short sun_family;
char sun_path[108];
};
struct timeval { long tv_sec; long tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
end
-- cdef may already be registered from a prior boot; either way we need
-- sockaddr_un usable before claiming readiness
pcall(ffi.cdef, cdef)
local ready = pcall(function() return ffi.new("struct sockaddr_un") end)
if not ready then
state.ffi = false
state.ffiReady = false
return nil
end
state.ffiReady = true
return ffi
end
local function closeSocket()
local sock = state.socket
state.socket = nil
state.connected = false
if not sock then return end
if state.isWindows then
pcall(function() sock:close() end)
elseif state.ffi then
pcall(state.ffi.C.close, sock)
end
end
local function writeRaw(bytes)
if not state.socket then return false end
if state.isWindows then
local ok = pcall(function()
state.socket:seek("end")
local _, writeErr = state.socket:write(bytes)
state.socket:flush()
if writeErr then error(writeErr) end
end)
return ok
end
local ffi = state.ffi
if not ffi then return false end
local ok, sent = pcall(ffi.C.send, state.socket, bytes, #bytes, 0)
return ok and sent == #bytes
end
local function sendFrame(opcode, payload)
local body = payload or ""
local ok, frame = pcall(function()
return packU32(opcode) .. packU32(#body) .. body
end)
if not ok or not frame then return false end
return writeRaw(frame)
end
local function readExact(n)
if not state.socket or n <= 0 then return nil end
if state.isWindows then
local ok, data = pcall(function() return state.socket:read(n) end)
if not ok or not data or #data < n then return nil end
return data
end
local ffi = state.ffi
if not ffi then return nil end
local ok, data = pcall(function()
local buf = ffi.new("char[?]", n)
local got = 0
while got < n do
local nread = ffi.C.recv(state.socket, buf + got, n - got, 0)
if not nread or nread <= 0 then return nil end
got = got + tonumber(nread)
end
return ffi.string(buf, n)
end)
if not ok then return nil end
return data
end
local function receiveFrame()
local header = readExact(8)
if not header then return nil end
local ok, opcode, length = pcall(function()
return unpackU32(header:sub(1, 4)), unpackU32(header:sub(5, 8))
end)
if not ok or not opcode or not length or length < 0 or length > 65536 then
return nil
end
local data = length > 0 and readExact(length) or ""
if data == nil then return nil end
return opcode, data
end
local function unixSocketPaths()
local paths = {}
local envs = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }
local bases = {}
for _, key in ipairs(envs) do
local v = os.getenv(key)
if v and v ~= "" then
if v:sub(-1) == "/" then v = v:sub(1, -2) end
bases[#bases + 1] = v
end
end
bases[#bases + 1] = "/tmp"
local suffixes = {
"",
"/app/com.discordapp.Discord",
"/app/com.discordapp.DiscordCanary",
"/app/com.discordapp.DiscordPTB",
"/snap.discord",
"/snap.discord-canary",
}
local seen = {}
for i = 0, 9 do
local name = "discord-ipc-" .. i
for _, base in ipairs(bases) do
for _, suffix in ipairs(suffixes) do
local path = base .. suffix .. "/" .. name
if not seen[path] then
seen[path] = true
paths[#paths + 1] = path
end
end
end
end
return paths
end
local function setSocketTimeout(fd, seconds)
local ffi = state.ffi
if not ffi or not fd then return end
pcall(function()
local tv = ffi.new("struct timeval")
tv.tv_sec = seconds
tv.tv_usec = 0
-- SOL_SOCKET / SO_RCVTIMEO / SO_SNDTIMEO differ by OS
local sol, rcv, snd
if love.system.getOS() == "OS X" then
sol, rcv, snd = 0xffff, 0x1006, 0x1005
else
sol, rcv, snd = 1, 20, 21
end
ffi.C.setsockopt(fd, sol, rcv, tv, ffi.sizeof(tv))
ffi.C.setsockopt(fd, sol, snd, tv, ffi.sizeof(tv))
end)
end
local function connectUnix()
local ffi = ensureFfi()
if not ffi then return false end
local darwin = love.system.getOS() == "OS X"
local maxPath = darwin and 103 or 107
-- AF_UNIX=1, SOCK_STREAM=1 on Linux/macOS
local pathsOk, paths = pcall(unixSocketPaths)
if not pathsOk or not paths then return false end
for _, path in ipairs(paths) do
if #path <= maxPath then
local ok, connected = pcall(function()
local fd = ffi.C.socket(1, 1, 0)
if not fd or fd < 0 then return false end
local addr = ffi.new("struct sockaddr_un")
addr.sun_family = 1
if darwin then
-- SUN_LEN: sizeof(sun_len+sun_family) + path + NUL
addr.sun_len = 2 + #path + 1
end
ffi.copy(addr.sun_path, path)
local rc = ffi.C.connect(fd, ffi.cast("void*", addr), ffi.sizeof(addr))
if rc == 0 then
setSocketTimeout(fd, 1) -- handshake must not freeze the game
state.socket = fd
return true
end
ffi.C.close(fd)
return false
end)
if ok and connected then return true end
end
end
return false
end
local function connectWindows()
for i = 0, 9 do
local path = "\\\\.\\pipe\\discord-ipc-" .. i
local ok, file = pcall(io.open, path, "r+")
if ok and file then
state.socket = file
return true
end
end
return false
end
local function handshake()
local ok, payload = pcall(Json.encode, { v = 1, client_id = DiscordPresence.APP_ID })
if not ok or not payload then return false end
if not sendFrame(OP_HANDSHAKE, payload) then return false end
-- Discord echoes READY as a FRAME. A timeout/missing client is a soft miss.
local opcode = receiveFrame()
if opcode == OP_FRAME then return true end
-- Windows named pipes have no easy read timeout: if the write succeeded
-- and Discord is present, proceed optimistically so boot never blocks.
if state.isWindows then return true end
return false
end
local function noteAbsent()
if state.loggedAbsent then return end
state.loggedAbsent = true
-- info, not warn: missing Discord is normal for many players
Logger.info("discord: rich presence unavailable (Discord not running)")
end
local function connect()
local ok, result = pcall(function()
closeSocket()
local linked
if state.isWindows then
linked = connectWindows()
else
linked = connectUnix()
end
if not linked then return false end
if not handshake() then
closeSocket()
return false
end
state.connected = true
state.dirty = true
state.lastSentKey = nil
state.loggedAbsent = false
Logger.info("discord: rich presence connected")
return true
end)
if not ok then
closeSocket()
return false
end
return result and true or false
end
local function locationName(game, mapId)
if not mapId then return nil end
local field = game and game.data and game.data.field
local townMap = field and field.townMap
local locations = townMap and (townMap.locations or townMap)
local entry = type(locations) == "table" and locations[mapId]
if type(entry) == "table" then
local name = entry.name or entry.label
if type(name) == "string" and name ~= "" then return name end
end
local def = game and game.data and game.data.maps and game.data.maps[mapId]
if def and type(def.label) == "string" and def.label ~= "" then
-- PalletTown -> Pallet Town
return def.label:gsub("(%l)(%u)", "%1 %2")
end
return tostring(mapId):gsub("_", " ")
end
local function speciesName(game, species)
if not species or not game or not game.data or not game.data.pokemon then
return nil
end
local def = game.data.pokemon[species]
return def and def.name or tostring(species):gsub("_", " ")
end
local function buildActivity()
local details, activityState
if state.activity == "battle" then
details = state.battleLabel or "In battle"
activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then
details = state.location
activityState = "Exploring the overworld"
else
details = state.location or "Kanto"
activityState = "At the title screen"
end
return {
details = details,
state = activityState,
timestamps = { start = state.startedAt },
assets = {
large_image = "logo",
large_text = "Pokemon Gen1Recomp",
},
}
end
local function activityKey(activity)
return (activity.details or "") .. "|" .. (activity.state or "")
end
local function flush(force)
if not state.enabled or not state.connected then return end
local ok = pcall(function()
local now = love.timer.getTime()
if not force and (now - state.lastSendAt) < MIN_UPDATE_INTERVAL then
state.dirty = true
return
end
local activity = buildActivity()
local key = activityKey(activity)
if not force and key == state.lastSentKey then
state.dirty = false
return
end
local payload = Json.encode({
cmd = "SET_ACTIVITY",
args = {
pid = resolvePid(),
activity = activity,
},
nonce = nonce(),
})
if not sendFrame(OP_FRAME, payload) then
closeSocket()
state.nextReconnectAt = now + RECONNECT_INTERVAL
return
end
state.lastSentKey = key
state.lastSendAt = now
state.dirty = false
end)
if not ok then
closeSocket()
local now = love and love.timer and love.timer.getTime and love.timer.getTime() or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end
local function setPresence(fields)
-- event listeners must never throw into the overworld/battle path
pcall(function()
if fields.location ~= nil then state.location = fields.location end
if fields.mapId ~= nil then state.mapId = fields.mapId end
if fields.activity ~= nil then state.activity = fields.activity end
if fields.clearBattle then
state.battleLabel = nil
elseif fields.battleLabel ~= nil then
state.battleLabel = fields.battleLabel
end
state.dirty = true
flush(false)
end)
end
local function subscribe(game)
local events = Runtime.events
if not events or not events.on then return end
local function track(name, fn)
state.unsubs[#state.unsubs + 1] = events:on(name, fn, 0, "discord")
end
track("map.entered", function(ev)
local mapId = ev and ev.mapId
setPresence({
mapId = mapId,
location = locationName(game, mapId),
activity = "exploring",
clearBattle = true,
})
end)
track("battle.started", function(ev)
local battle = ev and ev.battle
local label = "In battle"
if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species)
if name then
label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else
label = "Wild battle"
end
elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or "Trainer battle"
elseif ev and ev.kind == "link" then
label = "Link battle"
end
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = "battle",
battleLabel = label,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("battle.ended", function()
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = mapId and "exploring" or "menu",
clearBattle = true,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("screen.pushed", function(ev)
local s = ev and ev.state
local id = s and s.screenId
if not id and s and s.onNewGame then id = "TitleState" end
if id == "TitleState" or id == "IntroMovie" then
state.mapId = nil
setPresence({
activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
clearBattle = true,
})
end
end)
end
function DiscordPresence.init(game)
local ok, err = pcall(function()
DiscordPresence.shutdown()
if disabledByEnv() or not isDesktop() then return end
state.enabled = true
state.isWindows = love.system.getOS() == "Windows"
state.startedAt = os.time()
state.activity = "menu"
state.location = "Title screen"
state.mapId = nil
state.battleLabel = nil
state.dirty = true
state.nextReconnectAt = 0
state.loggedAbsent = false
if not state.isWindows and not ensureFfi() then
-- no FFI / broken cdef: stay disabled, never throw
state.enabled = false
return
end
subscribe(game)
if connect() then
flush(true)
else
noteAbsent()
local now = (love.timer and love.timer.getTime and love.timer.getTime()) or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end)
if not ok then
closeSocket()
state.enabled = false
-- still soft: a broken presence stack must not abort boot
Logger.info("discord: rich presence disabled (%s)", tostring(err))
end
end
function DiscordPresence.update(_dt)
if not state.enabled then return end
pcall(function()
local now = love.timer.getTime()
if not state.connected then
if now >= state.nextReconnectAt then
state.nextReconnectAt = now + RECONNECT_INTERVAL
if connect() then
flush(true)
else
noteAbsent()
end
end
return
end
if state.dirty then flush(false) end
end)
end
function DiscordPresence.shutdown()
pcall(function()
for _, unsub in ipairs(state.unsubs) do
pcall(unsub)
end
state.unsubs = {}
if state.connected then
pcall(function()
-- omit activity to clear the presence
sendFrame(OP_FRAME, Json.encode({
cmd = "SET_ACTIVITY",
args = { pid = resolvePid() },
nonce = nonce(),
}))
sendFrame(OP_CLOSE, "{}")
end)
end
closeSocket()
end)
state.enabled = false
state.dirty = false
state.lastSentKey = nil
state.connected = false
state.socket = nil
end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
return DiscordPresence
+8 -1
View File
@@ -12,8 +12,15 @@ function FixedStep:init(callback)
self.callback = callback
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
-- steps), which silently throttled the high fast-forward levels: at 100X
-- a 60fps frame wants ~100 steps. Game:update raises this to fit the
-- current speed target; a stall still cannot snowball past one frame's
-- intended budget.
FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt)
self.accum = math.min(self.accum + dt, MAX_ACCUM)
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
self.accum = self.accum - self.STEP
self.callback(self.STEP)
+79 -8
View File
@@ -17,7 +17,7 @@ local Game = {}
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
-- module unloaded, so a player boot never touches a byte of dev code
local devMode = os.getenv("POKEPORT_DEV") == "1"
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
-- the boot screen ids (field.boot.screens); a plain function so the
-- headless harness can borrow makeTitleState onto a stub game
@@ -68,6 +68,10 @@ function Game:load()
local OverworldState = require("src.world.OverworldController")
self.overworld = OverworldState
-- Discord Rich Presence: map name / battle status on the player's profile.
-- Soft-fail: missing Discord / IPC errors must never block boot.
pcall(function() require("src.core.DiscordPresence").init(self) end)
-- every service is up but nothing is on the stack yet; this payload is
-- the sanctioned way for a mod to obtain the Game object
ModRuntime.emit("game.ready", { game = self })
@@ -103,7 +107,7 @@ end
function Game:makeTitleState()
local OverworldState = require("src.world.OverworldController")
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
return factory.new(self, {
local title = factory.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
@@ -126,6 +130,8 @@ function Game:makeTitleState()
end
end,
})
title.screenId = title.screenId or "TitleState"
return title
end
-- QUIT from the START menu: back to the title like a power-cycle,
@@ -148,17 +154,46 @@ function Game:step(dt)
self.stack:update(dt)
-- play time for the trainer card / save screen
self.save.playTime = (self.save.playTime or 0) + dt
require("src.core.Music").update(Data)
-- Music.update is NOT serviced here: it decrements fade counters and
-- drives ChipAudio once per call, so running it inside the logic step
-- would pitch music and sfx up under fast-forward. Game:update advances
-- it on its own real-time 60Hz accumulator instead.
end
-- The logic multiplier for this frame. Read live rather than cached so the
-- Options row takes effect immediately; speedOverride is the --speed /
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
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)
FixedStep: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.
local speed = self:logicSpeed()
FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5)
FixedStep:update(dt * speed)
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
-- display refresh, so fades and chip synthesis keep their intended tempo
-- whether we are at 1X, 10X, or running with vsync disabled.
local step = FixedStep.STEP
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
while self.audioAccum >= step do
self.audioAccum = self.audioAccum - step
require("src.core.Music").update(Data)
end
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
end
-- render.zones' identity default: unhooked, the zone list reaches the blit
@@ -248,10 +283,22 @@ function Game:keypressed(key)
self:zoomStep(1)
return
elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on
local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode()
self:writeOptions()
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
-- hold it while a warp/transition or an on-screen scripted cutscene is
-- driving the overworld rather than tear the escort's NPCs out mid-move
local ow = self.overworld
local top = self.stack:top()
local busy = ow and (ow.transitioning
or (top == ow and (
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
or (ow.scriptMoves and #ow.scriptMoves > 0)
or ow.engaging or ow.emote)))
if not busy then
local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode()
self:writeOptions()
end
return
elseif key == "3" then
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
@@ -302,6 +349,28 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
-- Window focus/visibility flips: a release due while unfocused/hidden can
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
-- physically held key won't re-fire keypressed, so trusting leftover
-- state is worse than asking the player to re-press.
function Game:focus(f)
Input:reset()
TouchInput:reset()
end
function Game:visible(v)
Input:reset()
TouchInput:reset()
end
-- A disconnected/dropped controller can't send the button-up for whatever
-- it was holding, so drop all input state rather than try to guess which
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
TouchInput:reset()
end
function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y)
end
@@ -363,6 +432,8 @@ function Game:applyOptions(opts)
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
Input:applyBindings(opts.bindings)
end
function Game:restoreSave(loaded, recovered)
+54
View File
@@ -0,0 +1,54 @@
-- Fast-forward multiplier for game logic.
--
-- Speeding up means running the 1/60 fixed step N times per real frame
-- (Game:update), so everything driven by the step -- movement, text,
-- battle timing, scripts -- advances N times faster while staying
-- deterministic. Audio deliberately does NOT scale: Music.update drives
-- fade counters and ChipAudio synthesis off its own real-time 60Hz
-- accumulator in Game:update, so music and sfx play at normal pitch and
-- tempo at every speed.
--
-- Vsync still caps how much work a real frame can do, so 10X is a target
-- rather than a promise on a slow machine -- the logic simply runs as many
-- steps as the frame budget allows.
local GameSpeed = {}
-- 20X exists for the bot runs (tests/drivers/route.lua): a full-route
-- attempt is long enough that the iteration loop, not the engine, is the
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
-- multiplier is increasingly a ceiling rather than a rate.
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
GameSpeed.DEFAULT = 1
function GameSpeed.levelLabel(v)
v = tonumber(v) or GameSpeed.DEFAULT
if v == 1 then return "NORMAL" end
return tostring(v) .. "X"
end
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
-- a --speed argument), so a bad number degrades to something sane
function GameSpeed.clamp(v)
v = tonumber(v)
if not v then return GameSpeed.DEFAULT end
local best, bestDiff = GameSpeed.DEFAULT, math.huge
for _, level in ipairs(GameSpeed.LEVELS) do
local diff = math.abs(level - v)
if diff < bestDiff then best, bestDiff = level, diff end
end
return best
end
-- cycle to the next/previous level, wrapping (the options row idiom)
function GameSpeed.cycle(v, dir)
local levels = GameSpeed.LEVELS
local cur = 1
for i, level in ipairs(levels) do
if level == GameSpeed.clamp(v) then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #levels + 1
return levels[nextIdx]
end
return GameSpeed
+102 -16
View File
@@ -3,7 +3,7 @@
local Input = {}
local BINDINGS = {
local DEFAULT_BINDINGS = {
up = "up", w = "up",
down = "down", s = "down",
left = "left", a = "left",
@@ -18,8 +18,11 @@ local BINDINGS = {
-- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile.
local GAMEPAD_BINDINGS = {
-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
-- third-party pads report their own SDL mapping for a given physical
-- button (e.g. Select/Back/View on off-brand XInput pads), which is what
-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below.
local DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
@@ -32,48 +35,131 @@ local STICK_ON = 0.5
local STICK_OFF = 0.3
function Input:init()
self:applyBindings(nil)
self:reset()
end
-- Layers a player's rebind choices (save.options.bindings, written by
-- src/ui/BindingsMenu.lua) on top of the defaults above. A rebind adds an
-- extra way to trigger that action instead of replacing the default key,
-- so e.g. Z/Enter/Space all still press A even after binding a 4th key to
-- it. Call whenever options load or change (see Game:applyOptions and
-- BindingsMenu:storeBinding) -- without this the menu records a choice
-- that never actually reaches gameplay.
function Input:applyBindings(overlay)
local keys, pads = {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end
if binding.pad then pads[binding.pad] = actionId end
elseif type(binding) == "string" then
keys[binding] = actionId
end
end
self.keyBindings = keys
self.padBindings = pads
end
-- Purely event-driven state (press sets true, release sets false) has no
-- fallback if a release event never arrives -- focus loss, a minimized
-- window, or a disconnected gamepad can all swallow the key-up/button-up
-- that would have cleared a held direction. Called from Game on those
-- transitions so a stuck flag can't outlive them.
function Input:reset()
self.state = {}
self.pressQueue = {}
self.pressed = {}
self.sources = {}
self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil
end
function Input:keypressed(key)
local btn = BINDINGS[key]
if btn then
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
-- same GB button. Track them individually so releasing one doesn't clear
-- a hold another source still owns, and so a press+release that both land
-- before the next FixedStep can't be revived when step() drains the queue.
local function press(self, btn, source)
local sources = self.sources[btn]
if not sources then
sources = {}
self.sources[btn] = sources
end
if not sources[source] then
sources[source] = true
table.insert(self.pressQueue, btn)
end
self.state[btn] = true
end
function Input:keyreleased(key)
local btn = BINDINGS[key]
if btn then
local function release(self, btn, source)
local sources = self.sources[btn]
if sources then
sources[source] = nil
if next(sources) == nil then
-- Leave an empty table (not nil) so step() can tell a real
-- source was released before the queue drained, versus a
-- synthetic pressQueue inject that never had sources at all.
self.state[btn] = false
end
else
self.state[btn] = false
end
end
function Input:keypressed(key)
local btn = self.keyBindings[key]
if btn then
press(self, btn, "key:" .. key)
end
end
function Input:keyreleased(key)
local btn = self.keyBindings[key]
if btn then
release(self, btn, "key:" .. key)
end
end
-- Called once per fixed step: promote queued presses to this step's edges.
-- Hold state is owned by live sources (updated in press/release), not
-- re-asserted here -- otherwise a same-frame press→release leaves the
-- button stuck on after the queue drains.
-- Synthetic injects (tests/drivers writing pressQueue directly, with no
-- source entry) still set state so scripted holds keep working.
function Input:step()
self.pressed = {}
for _, btn in ipairs(self.pressQueue) do
self.pressed[btn] = true
self.state[btn] = true
local sources = self.sources[btn]
if sources == nil then
-- synthetic pressQueue inject (tests/drivers): no live source map
self.state[btn] = true
elseif next(sources) ~= nil then
self.state[btn] = true
end
-- sources == {}: real press fully released before this step — keep up
end
for btn, sources in pairs(self.sources) do
if next(sources) == nil then
self.sources[btn] = nil
end
end
self.pressQueue = {}
end
function Input:gamepadpressed(joystick, button)
local btn = GAMEPAD_BINDINGS[button]
local btn = self.padBindings[button]
if btn then
table.insert(self.pressQueue, btn)
press(self, btn, "pad:" .. button)
end
end
function Input:gamepadreleased(joystick, button)
local btn = GAMEPAD_BINDINGS[button]
local btn = self.padBindings[button]
if btn then
self.state[btn] = false
release(self, btn, "pad:" .. button)
end
end
@@ -103,10 +189,10 @@ function Input:gamepadaxis(joystick, axis, value)
if newDir ~= self.stickDir then
if self.stickDir then
self.state[self.stickDir] = false
release(self, self.stickDir, "stick")
end
if newDir then
table.insert(self.pressQueue, newDir)
press(self, newDir, "stick")
end
self.stickDir = newDir
end
+1
View File
@@ -114,6 +114,7 @@ local SPECIAL = {
oakRoute = "Music_Routes2",
bike = "Music_BikeRiding",
surf = "Music_Surfing",
evolution = "Music_SafariZone",
}
-- the label a scene role resolves to; call sites keep their own presence
+141 -10
View File
@@ -27,6 +27,107 @@ local OPTIONS_FILENAME = "options.lua"
local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp"
-- ------- portable mode
-- LÖVE's save directory is always the OS per-user path derived from the
-- identity (conf.lua), so it can't be relocated at runtime. Portable mode
-- instead drops a plain-Lua io.* filesystem next to the game whenever a
-- `portable.txt` marker sits beside the executable/source, letting a USB
-- copy carry its own save.lua/options.lua (and, through options.lua, the
-- mod enable-state) rather than leaving them on the host machine.
local PORTABLE_MARKER = "portable.txt"
local SEP = package.config:sub(1, 1)
local portableChecked = false
local portableBase = false -- resolved base dir when active, else false
local portableFsCache = nil
local function pathExists(path)
local f = io.open(path, "rb")
if not f then return false end
f:close()
return true
end
-- an io.* filesystem exposing the love.filesystem subset the save/options
-- round-trip needs (getInfo/read/write/remove), rooted at `dir`
local function makePortableFs(dir)
local function full(name) return dir .. SEP .. name end
return {
getInfo = function(name)
if not pathExists(full(name)) then return nil end
return { type = "file" }
end,
read = function(name)
local f = io.open(full(name), "rb")
if not f then return nil, "no file: " .. name end
local data = f:read("*a")
f:close()
return data
end,
write = function(name, data)
local f, err = io.open(full(name), "wb")
if not f then return false, err end
f:write(data)
f:close()
return true
end,
remove = function(name)
os.remove(full(name))
return true
end,
}
end
local function detectPortable()
if portableChecked then return portableBase end
portableChecked = true
portableBase = false
if not (love and love.filesystem) then return false end
local candidates = {}
if love.filesystem.getSourceBaseDirectory then
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory()
end
if love.filesystem.getSource then
candidates[#candidates + 1] = love.filesystem.getSource()
end
for _, base in ipairs(candidates) do
if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
portableBase = base
break
end
end
return portableBase
end
function SaveData.isPortable()
return detectPortable() ~= false
end
-- the raw portable-folder path (for callers building their own nested
-- paths, e.g. the ROM-derived asset cache), or nil when portable mode
-- is off
function SaveData.portableBaseDir()
return detectPortable() or nil
end
-- the io.* filesystem for the active portable folder, or nil when off
function SaveData.portableFs()
local base = detectPortable()
if not base then return nil end
if not portableFsCache then portableFsCache = makePortableFs(base) end
return portableFsCache
end
-- Resolve the filesystem a persistent read/write should land on: an
-- explicitly injected non-love fs (headless tests, the mod loader's stub)
-- always wins; otherwise portable mode reroutes off the OS save directory.
local function persistFs(fs)
if fs and love and love.filesystem and fs ~= love.filesystem then
return fs
end
return SaveData.portableFs() or fs or (love and love.filesystem)
end
-- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible.
function SaveData.defaultOptions()
@@ -41,10 +142,14 @@ function SaveData.defaultOptions()
musicVol = 7,
sfxVol = 7,
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/5)
colors = "gbc",
tilt = 0,
gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -90,7 +195,7 @@ end
-- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global).
function SaveData.saveOptions(opts, fs)
fs = fs or love.filesystem
fs = persistFs(fs)
opts = SaveData.mergeOptions(opts)
-- modOptions is per-mod nested state: fold the on-disk sub-tree
-- underneath (newest value winning per key) so one caller's partial
@@ -119,7 +224,7 @@ function SaveData.saveOptions(opts, fs)
end
function SaveData.loadOptions(fs)
fs = fs or love.filesystem
fs = persistFs(fs)
local data, err = readTable(fs, OPTIONS_FILENAME)
if not data then
if fs.getInfo(OPTIONS_FILENAME) then
@@ -307,7 +412,7 @@ end)
-- afterwards either way
SaveData.addCoreMigration(1, function(save)
if type(save.options) == "table"
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
and not persistFs(nil).getInfo(OPTIONS_FILENAME) then
SaveData.saveOptions(save.options)
end
end)
@@ -338,7 +443,7 @@ function SaveData.save(data, mods)
if k ~= "options" then gameOnly[k] = v end
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = love.filesystem
local fs = persistFs(nil)
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
@@ -367,7 +472,7 @@ end
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report
function SaveData.load()
local fs = love.filesystem
local fs = persistFs(nil)
local data, err = readTable(fs, FILENAME)
local recovered
if not data then
@@ -516,8 +621,8 @@ end
local function scrubMaps(save, data, report)
local boot = (data.field and data.field.boot) or {}
local spawn = { map = boot.startMap or "PALLET_TOWN",
x = boot.startX or 5, y = boot.startY or 6 }
local spawn = { map = boot.startMap or "REDS_HOUSE_2F",
x = boot.startX or 3, y = boot.startY or 6 }
-- heal point first, so the player fallback below always lands somewhere
-- valid; boot's heal cell (threaded from field.boot) is the last resort
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
@@ -627,11 +732,31 @@ end
-- boot is Data.field.boot, threaded in by Game: this module must not reach
-- into Data itself. Every read falls back to the Red literal it replaced,
-- so an absent or partial config still produces the vanilla new game.
-- Where blackouts and ESCAPE ROPE return to for a given boot config.
--
-- In vanilla this is NOT the spawn. wLastBlackoutMap is zero-filled at new
-- game and PALLET_TOWN is map 0, so the player starts in the bedroom
-- (special_warps.asm NewGameWarp) but blacks out to Pallet Town's fly_warp
-- cell (5, 6). A world that moves the spawn without naming a heal point
-- keeps the two together -- it may have no Pallet Town at all.
--
-- Shared with the Hall of Fame reset, which pokered writes as a literal
-- (HallOfFameResetEventsAndSaveScript: wLastBlackoutMap := PALLET_TOWN)
-- rather than deriving from the spawn.
function SaveData.defaultHeal(boot)
boot = type(boot) == "table" and boot or {}
local h = boot.lastHeal
if h then return { map = h.map, x = h.x, y = h.y } end
local map = boot.startMap or "REDS_HOUSE_2F"
if map == "REDS_HOUSE_2F" then return { map = "PALLET_TOWN", x = 5, y = 6 } end
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "PALLET_TOWN"
local x, y = boot.startX or 5, boot.startY or 6
local heal = boot.lastHeal or {}
local map = boot.startMap or "REDS_HOUSE_2F"
local x, y = boot.startX or 3, boot.startY or 6
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
player = {
@@ -655,6 +780,12 @@ function SaveData.newGame(boot)
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
-- copied, never aliased, so a save never writes back into Data
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
-- Interiors inherit the SGB palette of the last outdoor map. wLastMap
-- is zero-filled at new game and PALLET_TOWN is map 0, so before the
-- player has ever been outdoors that palette is Pallet Town's -- which
-- matters because the vanilla spawn (REDS_HOUSE_2F) is itself indoors.
-- Without this the palette falls through to the ROUTE default.
lastOutdoor = { id = heal.map or map, x = heal.x or x, y = heal.y or y },
repelSteps = 0,
-- per-mod persistence (mod.save) lives under here, keyed by mod id
modData = {},
+15
View File
@@ -75,6 +75,21 @@ function TouchInput:init()
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
+54
View File
@@ -0,0 +1,54 @@
-- Windowed vs borderless (desktop) fullscreen.
--
-- Persisted as save.options.videoMode. Applied from OptionsMenu and on
-- boot via Game:applyOptions. No-ops on mobile and in headless stubs
-- that lack love.window.
local VideoMode = {}
VideoMode.MODES = { "windowed", "borderless" }
VideoMode.DEFAULT = "windowed"
local LABELS = {
windowed = "WINDOWED",
borderless = "BORDERLESS",
}
function VideoMode.normalize(mode)
if mode == "borderless" then return "borderless" end
return VideoMode.DEFAULT
end
function VideoMode.modeLabel(mode)
return LABELS[VideoMode.normalize(mode)] or LABELS[VideoMode.DEFAULT]
end
function VideoMode.isMobile()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "Android" or osName == "iOS"
end
-- Cycle windowed <-> borderless (dir ignored; two modes).
function VideoMode.cycle(mode, _dir)
return VideoMode.normalize(mode) == "borderless" and "windowed" or "borderless"
end
-- Push the mode into the live window. Safe when love.window is missing.
function VideoMode.apply(mode)
if VideoMode.isMobile() then return end
if not love or not love.window or not love.window.setFullscreen then return end
mode = VideoMode.normalize(mode)
if mode == "borderless" then
-- "desktop" = borderless fullscreen matching the display
love.window.setFullscreen(true, "desktop")
else
love.window.setFullscreen(false)
end
end
function VideoMode.applyOptions(opts)
VideoMode.apply(opts and opts.videoMode)
end
return VideoMode
+8 -3
View File
@@ -88,9 +88,14 @@ end
function LuaWriter.write(path, value)
local parent = path:match("^(.*)/[^/]+$")
if parent then
local ok, err = love.filesystem.createDirectory(parent)
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
-- love.filesystem.createDirectory returns a single boolean (no error
-- value), so a second return here is always nil; getInfo after a failure
-- at least reports what is blocking the path (e.g. a file with that name).
if parent and not love.filesystem.createDirectory(parent) then
local info = love.filesystem.getInfo(parent)
local reason = info and ("a " .. info.type .. " already exists there")
or "unknown reason"
error("could not create " .. parent .. ": " .. reason)
end
local ok, err = love.filesystem.write(path, LuaWriter.encode(value))
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
+41 -3
View File
@@ -1253,14 +1253,24 @@ function RomExtractor:extractTrainers()
"battle/trainers/" .. picture.imageBase .. ".png")
written[picture.imageBase] = true
end
local parties = self:trainerParties(
pointers.bank, partyStarts[index], partyEnds[index])
-- ChiefData is empty in the ROM (the Celadon Chief battle is unused/
-- cut content); tools/rom_manifest.json carries a hand-authored party
-- for the trainers this project reimplements, since no ROM data exists
-- to extract for them.
if #parties == 0 then
local override = self.manifest.trainerPartyOverrides
and self.manifest.trainerPartyOverrides[trainerId]
if override then parties = { copy(override) } end
end
out[trainerId] = {
id = trainerId, index = index, name = decodedNames[index],
source = "ROM:TrainerDataPointers",
pic = picture and picture.path or nil,
baseMoney = math.floor(Rom.bcd(rawMoney) / 100),
aiMods = aiMods[index],
parties = self:trainerParties(
pointers.bank, partyStarts[index], partyEnds[index]),
parties = parties,
}
self:tick("Trainers", index, #order)
end
@@ -1426,7 +1436,7 @@ end
function RomExtractor:extractField()
self:beginStage("Interface artwork")
local done, total = 0, 48
local done, total = 0, 49
local function tick()
done = done + 1
self:tick("Interface artwork", math.min(done, total), total)
@@ -1576,6 +1586,34 @@ function RomExtractor:extractField()
"fx/" .. spec[4], { transparent = true })
tick()
end
-- The cuttable tree's own sprite: InitCutAnimOAM (engine/overworld/cut.asm)
-- copies Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half)
-- into the OAM tiles the Cut animation slides apart, so this comes out of
-- the OVERWORLD tileset's own graphics blob rather than a named symbol.
do
local overworldIndex
for i, name in ipairs(self.manifest.constants.tilesetOrder) do
if name == "OVERWORLD" then overworldIndex = i break end
end
assert(overworldIndex, "OVERWORLD tileset not found in tilesetOrder")
local tilesetHeaders = self:symbol("Tilesets")
local rowAddress = tilesetHeaders.address + (overworldIndex - 1) * 12
local gfxBank = self.rom:byte(tilesetHeaders.bank, rowAddress)
local gfxPointer = self.rom:word(tilesetHeaders.bank, rowAddress + 3)
local cutTree = ImageWriter.blank(16, 16, 1, 1, 1, 0)
for _, spec in ipairs({
{ 0x2d, 0, 0 }, { 0x2e, 8, 0 }, { 0x3d, 0, 8 }, { 0x3e, 8, 8 },
}) do
local tileIndex, dx, dy = spec[1], spec[2], spec[3]
local tile = ImageWriter.decode2bpp(
self.rom:bytes(gfxBank, gfxPointer + tileIndex * 16, 16),
8, 8, true)
ImageWriter.blit(cutTree, tile, dx, dy)
end
self:save(cutTree, "fx/cut_tree.png"); tick()
end
self:raw2bpp("BattleTransitionTile", 8, 8,
"fx/battle_transition.png"); tick()
self:raw2bpp("PokedexTileGraphics", 24, 48,
+194 -6
View File
@@ -2,7 +2,7 @@ local RomImporter = {}
RomImporter.__index = RomImporter
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
local CACHE_MARKER = "rom-cache-v5:" .. ROM_SHA1
local CACHE_MARKER = "rom-cache-v7:" .. ROM_SHA1
local MARKER_PATH = "rom-cache.complete"
local COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
@@ -38,8 +38,124 @@ local function sourceTreeHasData()
return real == love.filesystem.getSource()
end
-- ------- portable ROM-derived asset cache
--
-- The extracted cache (data/generated, assets/generated) is written
-- exclusively through love.filesystem.write, which always targets the OS
-- save directory -- it cannot be redirected to an arbitrary folder. So a
-- portable install mirrors the cache both ways instead: after a fresh
-- import, every generated file is copied out to the portable folder
-- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a
-- different machine sharing the same USB copy -- a matching portable
-- cache is copied back into the save directory before the normal
-- isReady() check runs, so nothing downstream needs to know the cache
-- ever lived anywhere but the save directory.
local PORTABLE_CACHE_DIRS = { "data/generated", "assets/generated" }
local PORTABLE_MANIFEST_NAME = "portable_cache_manifest.txt"
local PORTABLE_SEP = package.config:sub(1, 1)
local function walkLoveDir(dir, out)
out = out or {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do
local full = dir .. "/" .. name
local info = love.filesystem.getInfo(full)
if info and info.type == "directory" then
walkLoveDir(full, out)
elseif info and info.type == "file" then
out[#out + 1] = full
end
end
return out
end
local function portablePath(base, relPath)
return base .. PORTABLE_SEP .. relPath:gsub("/", PORTABLE_SEP)
end
local function ensurePortableDir(fullDirPath)
if love.system.getOS() == "Windows" then
os.execute(('mkdir "%s" 2>NUL'):format(fullDirPath))
else
os.execute(("mkdir -p '%s' 2>/dev/null"):format(fullDirPath))
end
end
-- copies data/generated + assets/generated out to the portable folder
-- after a fresh import; a plain-text manifest travels alongside so a
-- later sync-in knows exactly which files to copy back without needing
-- to list an arbitrary external directory (io.* has no listdir)
local function syncCacheToPortable()
local SaveData = require("src.core.SaveData")
local base = SaveData.portableBaseDir()
if not base then return end
local manifest = {}
for _, dir in ipairs(PORTABLE_CACHE_DIRS) do
if love.filesystem.getInfo(dir, "directory") then
for _, relPath in ipairs(walkLoveDir(dir)) do
local data = love.filesystem.read(relPath)
if data then
local outPath = portablePath(base, relPath)
local outDir = outPath:match("^(.*)" .. PORTABLE_SEP .. "[^" .. PORTABLE_SEP .. "]+$")
if outDir then ensurePortableDir(outDir) end
local f, err = io.open(outPath, "wb")
if f then
f:write(data)
f:close()
manifest[#manifest + 1] = relPath
else
require("src.core.Logger").error(
"portable cache: could not write %s: %s", outPath, tostring(err))
end
end
end
end
end
local mf = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "wb")
if mf then
mf:write(table.concat(manifest, "\n"))
mf:close()
end
local mk = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "wb")
if mk then
mk:write(CACHE_MARKER)
mk:close()
end
end
-- copies a matching portable cache back into the save directory before
-- isReady() runs its normal check; a mismatched or missing marker means
-- either no portable cache exists yet or it belongs to an older build, so
-- it is left alone and a fresh import proceeds as usual
local function syncCacheFromPortable()
local SaveData = require("src.core.SaveData")
local base = SaveData.portableBaseDir()
if not base then return end
local markerFile = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "rb")
if not markerFile then return end
local marker = markerFile:read("*a")
markerFile:close()
if marker ~= CACHE_MARKER then return end
local manifestFile = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "rb")
if not manifestFile then return end
local manifestBody = manifestFile:read("*a")
manifestFile:close()
for relPath in manifestBody:gmatch("[^\r\n]+") do
local f = io.open(portablePath(base, relPath), "rb")
if f then
local data = f:read("*a")
f:close()
love.filesystem.write(relPath, data)
end
end
love.filesystem.write(MARKER_PATH, CACHE_MARKER)
end
function RomImporter.isReady()
if sourceTreeHasData() then return true end
if love.filesystem.read(MARKER_PATH) ~= CACHE_MARKER
and require("src.core.SaveData").isPortable() then
syncCacheFromPortable()
end
return love.filesystem.read(MARKER_PATH) == CACHE_MARKER
and allRequiredFilesExist()
end
@@ -110,6 +226,22 @@ local function commandOutput(command)
return result ~= "" and result or nil
end
-- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog
-- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so
-- neither desktop path below works there. conf.lua points the Android save
-- directory at the app's external-files folder instead (readable/writable
-- via USB or a file manager, no runtime permission needed), and this scans
-- it directly through love.filesystem -- already mounted at the physfs
-- root, so no io.* absolute-path handling is needed.
local function scanForRom()
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom()
local platform = love.system.getOS()
if platform == "OS X" then
@@ -136,19 +268,52 @@ local function chooseRom()
end
function RomImporter.new(onComplete)
return setmetatable({
local previousMarker = love.filesystem.read(MARKER_PATH)
local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER
local android = love.system.getOS() == "Android"
local self = setmetatable({
onComplete = onComplete,
logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"),
state = "waiting",
status = "Choose or drop a Pokemon Red ROM",
detail = "The ROM is verified before any files are created.",
returning = returning,
android = android,
status = returning and "More assets are needed from your ROM"
or "Choose or drop a Pokemon Red ROM",
detail = returning
and "This update pulls a few more things from your ROM. "
.. "Please re-import it to continue (it's quick)."
or "The ROM is verified before any files are created.",
progress = 0,
stageCurrent = 0,
stageTotal = 1,
pulse = 0,
button = {},
}, RomImporter)
if android then
self.status = returning and "More ROM assets needed" or "Get your Pokemon Red ROM (.gb) in"
self.detail = "Tap Choose ROM to pick your file"
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
end
return self
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 folder scanForRom checks, so a rescan on refocus picks it up
-- without the player needing to tap the button again.
function RomImporter:focus(f)
if not (f and self.android and self.state ~= "working") then return end
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
end
function RomImporter:setError(message)
@@ -204,6 +369,11 @@ function RomImporter:startData(data, displayName)
collectgarbage("collect")
local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER)
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
if require("src.core.SaveData").isPortable() then
self.status = "Copying data to the portable folder"
coroutine.yield()
syncCacheToPortable()
end
self.state = "complete"
self.status = "Ready"
self.detail = "Starting Pokemon Red..."
@@ -234,6 +404,22 @@ end
function RomImporter:choose()
if self.state == "working" then return end
if self.android then
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path. Not setError(): that status
-- text ("could not be imported") reads as a rejected file, not "none
-- found yet" -- and detail only renders 3 wrapped lines, so the path
-- again gets the line to itself.
self.state = "waiting"
self.status = "No picker available, copy your ROM into:"
self.detail = love.filesystem.getSaveDirectory()
end
return
end
local path = chooseRom()
if path then
self:startPath(path)
@@ -304,7 +490,8 @@ function RomImporter:draw()
height * 0.075,
0, logoScale, logoScale)
setColor255(74, 88, 72)
printCentered("FIRST RUN", height * 0.205, smallFont, width)
printCentered(self.returning and "UPDATE REQUIRED" or "FIRST RUN",
height * 0.205, smallFont, width)
local zoneY, zoneH = height * 0.29, math.min(180, height * 0.31)
setColor255(215, 220, 202)
@@ -347,7 +534,8 @@ function RomImporter:draw()
buttonWidth, "center")
setColor255(74, 88, 72)
love.graphics.setFont(smallFont)
love.graphics.printf("or drop the .gb file here",
love.graphics.printf(
self.android and "or copy the .gb via USB" or "or drop the .gb file here",
0, buttonY + buttonHeight + 12, width, "center")
end
+4
View File
@@ -11,6 +11,10 @@ local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
end
-- exported so item lists that share save.inventory (e.g. the PC deposit
-- menu) can exclude badges the same way the bag does
Bag.isBadge = isBadge
function Bag.slots(save)
local n = 0
for id in pairs(save.inventory) do
+3 -1
View File
@@ -129,7 +129,9 @@ end
-- tripwire on for tests that cannot set the environment.
function Loader.new(opts)
local dev = opts and opts.dev
if dev == nil then dev = os.getenv("POKEPORT_DEV") == "1" end
if dev == nil then
dev = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
end
local self = setmetatable({
mods = {}, loaded = {}, errors = {}, initialized = false,
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
+6 -1
View File
@@ -9,6 +9,7 @@
-- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the
-- evolution.check hook so a mod can cancel or force any evolution.
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats")
@@ -112,11 +113,15 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, onDone))
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
if onDone then onDone() end
end))
end
-- Entry point for mods whose methods fire outside the vanilla moments
+8 -2
View File
@@ -9,12 +9,18 @@ local Pokemon = {}
-- keeping the most recent four (engine/pokemon/learn_move.asm behavior).
function Pokemon.movesAtLevel(speciesDef, level)
local moves = {}
local function add(id)
for _, existing in ipairs(moves) do
if existing == id then return end
end
table.insert(moves, id)
end
for _, m in ipairs(speciesDef.level1Moves) do
table.insert(moves, m)
add(m)
end
for _, entry in ipairs(speciesDef.learnset) do
if entry.level <= level then
table.insert(moves, entry.move)
add(entry.move)
end
end
while #moves > 4 do
+5 -4
View File
@@ -86,10 +86,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
end
local tint
local pals = data.palettes
if pals then
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local c = pals.palettes[name][3] -- GB color 2 is the fill shade
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),
+277 -14
View File
@@ -5,17 +5,20 @@
-- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette.
--
-- Port display option: COLORS (GBC / OG / OG INV / GBC INV / CLASSIC)
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
-- transforms every zone's palette at send time via effectiveColors.
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors.
local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2
PaletteFX.MODES = { "gbc", "og", "og_inv", "gbc_inv", "classic" }
PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
PaletteFX.MODE_LABELS = {
gbc = "GBC", og = "OG", og_inv = "OG INV",
gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV",
gbc_inv = "GBC INV", classic = "CLASSIC",
}
PaletteFX.mode = "gbc"
@@ -124,22 +127,258 @@ function PaletteFX.whole(colors)
return PaletteFX.zone(colors, 0, 0, 19, 17)
end
-- named palette from data/generated/palettes.lua (nil on stale builds)
-- Red++ / pokered-gbc SuperPalette pack (committed; optional if absent).
function PaletteFX.gbcPack()
if gbcPack == nil then
local ok, pack = pcall(require, "data.palettes_gbc")
gbcPack = ok and pack or false
end
return gbcPack or nil
end
function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode
return mode == "redpp"
end
-- Per-object overworld sprite coloring (ColorOverworldSprite) applies in
-- plain GBC mode too, not only under the RED++ pack: without it the
-- whole-map zone shader paints characters with whatever two mid shades
-- the terrain palette defines. RED++ handles sprites through the baked
-- usesGbcPack() path in SpriteRenderer; this names the modes where the
-- OBP bake plus the post-zone redraw (below) stand in for real OBJ
-- palettes over a shader-colorized background.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "gbc"
end
-- ------- post-zone sprite redraw (GBC mode)
--
-- In GBC mode the world canvas still runs through the per-map zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
-- late. Entries carrying `colors` are re-colorized draws (the tall-grass
-- feet overdraw, which must keep hiding sprite feet) issued through the
-- color-0-keyed shade-remap shader. World pass only; cleared per frame.
local spriteRedraws = {}
function PaletteFX.clearSpriteRedraws()
for i = #spriteRedraws, 1, -1 do spriteRedraws[i] = nil end
end
function PaletteFX.markSpriteRedraw(image, quad, x, y, sx, colors, keyed)
if currentPass ~= "world" then return end
spriteRedraws[#spriteRedraws + 1] =
{ image = image, quad = quad, x = x, y = y, sx = sx or 1,
colors = colors, keyed = keyed }
end
-- whether a draw issued right now would land in the redraw list -- the
-- OBP bake is only correct when the replay can restore it after the zone
-- pass (tilt's upright pass colorizes per-billboard instead, so sprites
-- there keep the raw sheet)
function PaletteFX.spriteRedrawPassActive()
return currentPass == "world"
end
function PaletteFX.spriteRedraws()
return spriteRedraws
end
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data)
if PaletteFX.usesGbcPack() then
local g = PaletteFX.gbcPack()
if g then return g end
end
return data and data.palettes or nil
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
function PaletteFX.pal(data, name)
local p = data.palettes
return p and p.palettes[name] or nil
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
return data.palettes.palettes[name]
end
return nil
end
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color).
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
function PaletteFX.monPal(data, species, transformed)
local p = data.palettes
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then return p.palettes.GRAYMON end
return p.palettes[p.pokemon[species] or "MEWMON"]
if transformed then
return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON)
end
local name = p.pokemon[species] or "MEWMON"
local c = p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
name = data.palettes.pokemon[species] or "MEWMON"
return data.palettes.palettes[name]
end
return nil
end
-- palette name a species currently resolves to (for image-cache keys)
function PaletteFX.monPalName(data, species, transformed)
if transformed then return "GRAYMON" end
local p = PaletteFX.pack(data)
if p and p.pokemon[species] then return p.pokemon[species] end
if data and data.palettes and data.palettes.pokemon[species] then
return data.palettes.pokemon[species]
end
return "MEWMON"
end
-- ------- true GBC overworld coloring (color/loadpalettes.asm,
-- color/data/*, color/sprites.asm ColorOverworldSprite) -------------------
--
-- RED++ pairs its named-palette battle/mon colors above with pokered-gbc's
-- real per-tile system: LoadTilesetPalette assigns one of 8 four-color BG
-- palettes to every tile GRAPHIC in a tileset (by tile id, not by map
-- position), and LoadTownPalette swaps just the ROOF slot (index 6) per
-- town/route. `data/palettes_gbc.lua`'s `world` table holds the extracted
-- data (tools/extract/palettes.py extract_gbc_world); these queries are
-- mode-independent (only check the pack exists) so TileRenderer can
-- precompute geometry once regardless of the active COLORS mode -- callers
-- that resolve to actual on-screen COLOR should gate on usesGbcPack()
-- themselves, the same way they already gate other RED++-only behavior.
--
-- LoadTilesetPalette's 3 hardcoded single-tile fixes (Celadon Mart) and
-- LoadTownPalette's Route 6/Saffron y<2 roof split are control flow, not
-- data, so they are not in the extracted pack -- they live here instead.
local TILE_GROUP_EXCEPTIONS = {
-- tile ids $4b-$4f -> BLUE (outside sky, seen through the mart's roof)
CELADON_MART_ROOF = { tiles = { [0x4b] = true, [0x4c] = true, [0x4d] = true,
[0x4e] = true, [0x4f] = true }, group = 3 },
-- tile $37 -> BROWN (counter miscoloration fix)
CELADON_MART_3F = { tiles = { [0x37] = true }, group = 5 },
-- tiles $07/$08/$17/$18 -> YELLOW (bench, blue by default)
CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true,
[0x17] = true, [0x18] = true }, group = 4 },
}
-- keyed by tileset id (applies on every map that uses it), consulted after
-- the per-map table above
local TILESET_GROUP_EXCEPTIONS = {
-- tile $22 (the hollow-square grave marker) -> GRAY: the extracted pack
-- files it under the bright blue family, which makes a purely
-- decorative floor marker read as an interactive pad
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
}
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
-- different palettes; the vanilla-derived blockset shares the one tile
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
-- is baked as a copy of `tile` in `group`'s colors, and the listed
-- 0-based block cells draw the alias instead of the shared tile.
PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
},
}
local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
-- whether the extracted pack has real per-tile GBC data for this tileset
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
-- pack failed to load at all)
function PaletteFX.hasWorldTileset(tileset)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
return (w and w.tileGroups[tileset]) ~= nil
end
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
-- with the current map's tile-id exceptions (if any) applied first
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local groups = w and w.tileGroups[tileset]
if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId]
if exc and exc.tiles[tileId] then return exc.group end
exc = TILESET_GROUP_EXCEPTIONS[tileset]
if exc and exc.tiles[tileId] then return exc.group end
return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
end
-- this tileset's resolved 8-entry {r,g,b}x4 palette array, with the ROOF
-- slot swapped to the current town/route (Route 6's north end uses
-- Saffron's roof colors while the player stands in its top 2 cell rows,
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local base = w and w.groupColors[tileset]
if not base then return nil end
if not w.roofGroup[tileset] then return base end
local roofMapId = mapId
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
roofMapId = ROUTE_6_SAFFRON.useMapId
end
local roofMap = data and data.maps and data.maps[roofMapId]
local roof = roofMap and w.roofByMapIndex[roofMap.index]
if not roof then return base end
local out = {}
for i = 1, 8 do out[i] = base[i] end
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
-- and 2 (0-indexed) of the 4-color ROOF slot -- color 0 (background,
-- typically the sky-through-gaps white) and color 3 (outline black) keep
-- the tileset's own OUTDOOR_ROOF/INDOOR_ROOF base, only the roof
-- material's 2 middle shades are town-specific
local base4 = base[ROOF_GROUP + 1]
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
return out
end
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
-- or nil when unassigned/unavailable, plus the resolved group index (for
-- callers that want a stable cache key without hashing the colors table).
-- spriteDef carries the ROM picture-id crosswalk in its `source` field
-- ("ROM:SpriteSheetPointerTable[N]"); seed (any stable per-instance value,
-- e.g. an NPC's `id`) resolves the "random" sentinel -- a deliberate
-- approximation of ColorOverworldSprite's per-OAM-slot pseudo-random pick
-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent
-- here): a stable hash instead, so the same NPC instance always shows the
-- same one of the 4 SPR_PAL_* colors.
function PaletteFX.spriteObp(spriteDef, seed)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local src = spriteDef and spriteDef.source
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite loads outside SpriteSheetPointerTable
-- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed
-- index; it wears the player's own palette, same as SPRITE_RED
if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end
local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end
if group == "random" then
local h = 0
seed = tostring(seed or "")
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
group = h % 4
end
return w.spritePalettes[group], group
end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
@@ -173,13 +412,35 @@ function PaletteFX.permute(colors, map)
end
function PaletteFX.setMode(mode)
local prev = PaletteFX.mode
local ok = false
for _, m in ipairs(PaletteFX.MODES) do
if m == mode then
PaletteFX.mode = mode
return
ok = true
break
end
end
PaletteFX.mode = "gbc"
if not ok then PaletteFX.mode = "gbc" end
-- battle pics and overworld sprites bake the active pack into ImageData;
-- drop those caches when the pack (or any COLORS mode) changes so the
-- next draw re-tints
if prev ~= PaletteFX.mode then
pcall(function() require("src.battle.BattleState").invalidate() end)
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer
-- dropped and the currently-visible one rebuilt in place -- otherwise
-- the on-screen map keeps its stale (wrong-mode) atlas until the next
-- map transition happens to reload it.
pcall(function()
require("src.world.MapLoader").invalidateAll()
local Game = require("src.core.Game")
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
end
end)
end
end
function PaletteFX.cycleMode()
@@ -188,7 +449,7 @@ function PaletteFX.cycleMode()
for i, m in ipairs(PaletteFX.MODES) do
if m == cur then idx = i; break end
end
PaletteFX.mode = PaletteFX.MODES[idx % #PaletteFX.MODES + 1]
PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1])
return PaletteFX.mode
end
@@ -202,7 +463,7 @@ end
-- When a state exposes no SGB zones but COLORS needs a forced palette
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
-- shader still runs. GBC / GBC INV leave nil alone (raw DMG canvas).
-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas).
function PaletteFX.ensureZones(zones)
if zones and zones[1] then return zones end
local mode = PaletteFX.mode or "gbc"
@@ -213,6 +474,8 @@ function PaletteFX.ensureZones(zones)
end
-- Transform a 4-color palette for the active COLORS display mode.
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
function PaletteFX.effectiveColors(c)
if not c then return nil end
local mode = PaletteFX.mode or "gbc"
+32 -1
View File
@@ -62,8 +62,10 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
-- last frame's trueColor rects go before anything draws this one
-- last frame's trueColor rects and sprite redraws go before anything
-- draws this one
PaletteFX.clearTrueColor()
PaletteFX.clearSpriteRedraws()
PaletteFX.setPass("ui")
love.graphics.setCanvas(self.canvas)
if transparent then
@@ -363,6 +365,35 @@ function Renderer:endFrame(zones, worldZones)
else
blit(self.worldCanvas, s, zones, S, 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
-- feet-overdraw entries carry `colors` and re-colorize through the
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
local redraws = PaletteFX.spriteRedraws()
if redraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
local activeShader = nil
for _, r in ipairs(redraws) do
local wanted = r.colors
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
or nil
if wanted ~= activeShader then
activeShader = wanted
love.graphics.setShader(wanted)
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)
else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
end
end
if activeShader then love.graphics.setShader() end
love.graphics.setScissor()
end
end
-- Composite the tilt upright pass over the ground (projected or, in the
-- rare no-shader fallback, flat). It already carries its billboards'
+79 -8
View File
@@ -18,10 +18,49 @@ local function getImage(path)
return imageCache[path]
end
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
-- shades to the resolved OBP colors, cached per (image path, group).
--
-- Sprite sheets carry no real alpha (every pixel, including the
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
-- "transparent" look in every other draw path is a coincidence of the
-- whole-canvas shade-remap shader, where shade 0 (white) happens to map to
-- a similarly light color in whatever terrain zone the sprite stands over.
-- That coincidence breaks once terrain is colored per-tile instead of one
-- flat color per map (different tiles can have very different color-0s),
-- so shade 0 is keyed to alpha 0 here explicitly -- matching real GBC OBJ
-- hardware, where sprite palette index 0 is unconditionally transparent
-- (same rule TileRenderer's getColor0KeyShader documents for tall grass).
local obpCache = {}
local function getObpImage(path, colors, group)
local key = path .. "#obp" .. group
if not obpCache[key] then
local img
if love.image and love.image.newImageData then
local id = Assets.imageData(path)
id:mapPixel(function(_, _, r, g, b, a)
if a == 0 then return r, g, b, a end
if r > 0.83 then return r, g, b, 0 end -- OBJ color 0: always transparent
local col = r > 0.5 and colors[2] or r > 0.17 and colors[3] or colors[4]
return col[1] / 255, col[2] / 255, col[3] / 255, a
end)
img = love.graphics.newImage(id)
else
img = getImage(path) -- headless stub: no pixel access
end
obpCache[key] = img
end
return obpCache[key]
end
-- hot reload drops the sheets; live instances hold their own image, so
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
function SpriteRenderer.invalidate()
imageCache = {}
obpCache = {}
end
Assets.register(SpriteRenderer.invalidate)
@@ -29,9 +68,12 @@ Assets.register(SpriteRenderer.invalidate)
local STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 }
function SpriteRenderer.new(spriteDef)
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
function SpriteRenderer.new(spriteDef, seed)
local self = setmetatable({}, SpriteRenderer)
self.def = spriteDef
self.seed = seed
self.image = getImage(spriteDef.image)
local iw, ih = self.image:getDimensions()
self.frames = {}
@@ -43,16 +85,49 @@ end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
else
love.graphics.draw(image, quad, x, y)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
end
end
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local image = self.image
local redraw = false
-- full-color art claims its 16x16 cell out of the shade-remap pass
if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) end
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16)
elseif PaletteFX.usesGbcPack() then
-- RED++: the world canvas is already true-color (TileRenderer bakes
-- terrain, this bakes the sprite) and the world pass runs unshaded
-- (OverworldState.sgbWorldZones), so this draws like any normal sprite
-- -- opaque character pixels over a real-alpha-transparent background,
-- no trueColor rect needed (there is no shader left to exempt it from).
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then
image = getObpImage(self.def.image, colors, group)
end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- plain GBC: the terrain zone shader still runs over the world canvas,
-- so the baked sprite is also queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then
image = getObpImage(self.def.image, colors, group)
redraw = true
end
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then
love.graphics.draw(self.image, self.frames[0], x, y)
blitFrame(image, self.frames[0], x, y, false, redraw)
return
end
local frame = (self.def.walker and walkPhase == 1)
@@ -64,11 +139,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if flip then
love.graphics.draw(self.image, quad, x + 16, y, 0, -1, 1)
else
love.graphics.draw(self.image, quad, x, y)
end
blitFrame(image, quad, x, y, flip, redraw)
end
return SpriteRenderer
+195 -14
View File
@@ -108,10 +108,23 @@ end
-- the water/flower branches did before they were data.
-- ------------------------------------------------------------------
-- the 8 shifted variants of one tile (built once per sheet + tile id)
-- shade 0-3 -> one of `colors`' 4 entries (same cutoffs PaletteFX's shader
-- uses), alpha passed through unchanged; nil colors leaves r,g,b as-is.
-- Shared by the whole-atlas bake (getGbcAtlas) and the animated-tile
-- variants below, so water/flowers/spinners match the static tiles around
-- them under RED++ instead of showing their un-recolored grayscale.
local function recolorSample(r, g, b, a, colors)
if not (colors and a > 0) then return r, g, b, a end
local col = r > 0.83 and colors[1] or r > 0.5 and colors[2]
or r > 0.17 and colors[3] or colors[4]
return col[1] / 255, col[2] / 255, col[3] / 255, a
end
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
local shiftVariants = {}
local function getShiftVariants(tilesetImagePath, perRow, tile)
local key = tilesetImagePath .. "#" .. tile
local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey)
local key = tilesetImagePath .. "#" .. tile .. (gbcKey or "")
if shiftVariants[key] ~= nil then return shiftVariants[key] end
if not (love.image and love.image.newImageData) then
shiftVariants[key] = false
@@ -126,6 +139,7 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = id:getPixel(sx + x, sy + y)
r, g, b, a = recolorSample(r, g, b, a, colors)
v:setPixel((x + o) % 8, y, r, g, b, a)
end
end
@@ -136,12 +150,27 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
end
local frameImages = {}
local function getFrameImages(paths)
local key = table.concat(paths, "|")
local function getFrameImages(paths, colors, gbcKey)
local key = table.concat(paths, "|") .. (gbcKey or "")
if frameImages[key] ~= nil then return frameImages[key] end
local out = {}
for i, path in ipairs(paths) do
local ok, img = pcall(getImage, path)
local ok, img = pcall(function()
if not (colors and love.image and love.image.newImageData) then
return getImage(path)
end
local id = Assets.imageData(path)
local w, h = id:getDimensions()
local out2 = love.image.newImageData(w, h)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, a = id:getPixel(x, y)
r, g, b, a = recolorSample(r, g, b, a, colors)
out2:setPixel(x, y, r, g, b, a)
end
end
return love.graphics.newImage(out2)
end)
if not ok then
frameImages[key] = false
return false
@@ -237,17 +266,33 @@ end
-- one entry's runtime form: the tile ids it claims, the textures a step
-- picks from, and either a step sequence (hshift/frames) or a gate
-- (toggle). nil when the entry's pixels could not be built.
local function buildAnim(spec, tilesetImagePath, perRow, quads)
--
-- gbc, when present (RED++ with a baked atlas -- see getGbcAtlas), recolors
-- hshift/frames entries (water/flowers) the same way the atlas bakes their
-- static tile, so they match their surroundings instead of showing raw
-- grayscale over an otherwise fully-colored map. The "toggle" kind
-- (spinner puzzle blur, gfx/overworld/spinners.png) is a whole-atlas clone
-- built from the ORIGINAL grayscale atlas, not worth recoloring for a rare,
-- gameplay-gated blur -- it is skipped under gbc, same as the buildAnim
-- caller already does for a texture-build failure (the static, correctly-
-- colored tile shows through unanimated).
local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc)
local tiles = spec.tiles
if not tiles then
if spec.tile == nil then return nil end
tiles = { spec.tile }
end
local period = spec.period or ANIM_PERIOD
local colors
if gbc then
local group = PaletteFX.worldGroupAt(gbc.tilesetId, gbc.mapId, tiles[1])
colors = group and gbc.groupColors[group + 1]
end
if spec.kind == "hshift" then
local offsets = spec.offsets
if not offsets or #offsets == 0 then return nil end
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1])
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1],
colors, gbc and gbc.key)
if not textures then return nil end
local sequence = {}
for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end
@@ -256,11 +301,12 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
elseif spec.kind == "frames" then
local sequence = spec.sequence
if not (spec.images and sequence and #sequence > 0) then return nil end
local textures = getFrameImages(spec.images)
local textures = getFrameImages(spec.images, colors, gbc and gbc.key)
if not textures then return nil end
return { tiles = tiles, textures = textures, sequence = sequence,
period = period }
elseif spec.kind == "toggle" then
if gbc then return nil end
local image = getToggleImage(spec, tilesetImagePath, perRow)
if not image then return nil end
-- the patch texture is a whole-atlas clone, so each cell needs the
@@ -271,10 +317,102 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
return nil
end
function TileRenderer.new(map)
-- True GBC overworld coloring (COLORS=RED++): recolor the WHOLE tileset
-- atlas once, per (tileset image, map), rather than trying to retrofit the
-- SGB zone/shade-remap-shader post-process (built for a handful of coarse
-- screen regions) into per-tile precision -- pokered-gbc's real model is
-- "one of 8 four-color BG palettes baked per tile GRAPHIC"
-- (color/loadpalettes.asm LoadTilesetPalette), which is exactly a
-- recolored atlas, not a shader pass. Every existing draw path (batches,
-- quads, border fill) then just works unmodified, with no shader at
-- draw time; OverworldState.sgbWorldZones skips the shade-remap zone pass
-- entirely when this is active (re-running it over already-true-color
-- pixels would corrupt them), and SpriteRenderer's own OBP bake composites
-- on top with ordinary alpha blending -- no trueColor exemption needed,
-- because there is no shader left for it to be exempted from.
--
-- Only the ROOF group (index 6, OVERWORLD/PLATEAU only) varies by town
-- (LoadTownPalette); Route 6's mid-map Saffron-roof y<2 split is not
-- reproduced (it would need a rebuild on crossing the boundary for two
-- tile-rows of one route -- not worth the complexity), so it bakes with
-- the route's own default roof (Vermilion's) throughout.
local gbcAtlasCache = {}
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
local key = imagePath .. "#gbc:" .. mapId
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
local img = false
if love.image and love.image.newImageData then
local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil)
if groupColors then
local src = Assets.imageData(imagePath)
local iw, ih = src:getDimensions()
local total = (iw / 8) * (ih / 8)
local out = love.image.newImageData(iw, ih)
local tileColors = {}
for t = 0, total - 1 do
local colors = tileColors[t]
if colors == nil then
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
colors = (group and groupColors[group + 1]) or false
tileColors[t] = colors
end
local ox, oy = (t % perRow) * 8, math.floor(t / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local sx, sy = ox + px, oy + py
local r, g, b, a = src:getPixel(sx, sy)
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(sx, sy, r, g, b, a)
end
end
end
-- duplicate-tile aliases: bake a copy of a shared tile graphic into
-- a spare slot under a different palette group, so block cells that
-- draw the alias can color apart from cells sharing the raw tile
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[mapId] or {}) do
if al.alias < total then
local colors = groupColors[al.group + 1]
local sxo = (al.tile % perRow) * 8
local syo = math.floor(al.tile / perRow) * 8
local dxo = (al.alias % perRow) * 8
local dyo = math.floor(al.alias / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = src:getPixel(sxo + px, syo + py)
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(dxo + px, dyo + py, r, g, b, a)
end
end
end
end
img = love.graphics.newImage(out)
end
end
gbcAtlasCache[key] = img
return img or nil
end
-- data: Game.data (threaded through explicitly, not required lazily, so
-- headless tests that build a map from a plain local table still work)
function TileRenderer.new(map, data)
local self = setmetatable({}, TileRenderer)
self.map = map
self.data = data
self.image = getImage(map.tileset.image)
local gbcCtx
if data and PaletteFX.usesGbcPack() and PaletteFX.hasWorldTileset(map.tileset.id) then
local gbc = getGbcAtlas(map.tileset.image, map.tileset.id, map.id,
map.tileset.tilesPerRow, data)
if gbc then
self.image = gbc
self.gbcAtlas = true
-- also recolors the animated water/flower entries below, so they
-- match the atlas's static tiles instead of showing raw grayscale
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
end
end
-- a full-color atlas colors everything it paints, ring and border fill
-- included, so every draw entry point claims its rect out of the pass
self.trueColor = map.tileset.trueColor or nil
@@ -301,7 +439,7 @@ function TileRenderer.new(map)
local declared = map.tileset.animatedTiles
or TileRenderer.defaultAnimatedTiles(map.tileset)
for _, spec in ipairs(declared) do
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads)
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
if anim then
anim.cells = {}
anims[#anims + 1] = anim
@@ -311,14 +449,38 @@ function TileRenderer.new(map)
end
end
-- duplicate-tile alias remap (RED++ atlas only): [blockId][0-based cell]
-- -> alias tile id (see PaletteFX.TILE_ALIASES / getGbcAtlas's bake)
local aliasMap
if gbcCtx then
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[map.id] or {}) do
aliasMap = aliasMap or {}
local cells = aliasMap[al.block] or {}
for ci in pairs(al.cells) do cells[ci] = al.alias end
aliasMap[al.block] = cells
end
end
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
local batch = inside and self.mapBatch or self.ringBatch
local block = map.tileset.blocks[map:blockAt(bx, by) + 1]
-- beyond-edge ring cells use the same override drawBorderFill does,
-- so the ring and the far background fill agree (OVERWORLD maps
-- whose raw border_block is water still ring with the tree wall)
local blockId = inside and map:blockAt(bx, by) or borderBlockFor(map)
local block = map.tileset.blocks[blockId + 1]
if not block then
-- a tileset without the tree-wall block keeps its own border
blockId = map:blockAt(bx, by)
block = map.tileset.blocks[blockId + 1]
end
local remap = aliasMap and aliasMap[blockId]
for ty = 0, 3 do
for tx = 0, 3 do
local tile = block[ty * 4 + tx + 1]
local ci = ty * 4 + tx
local tile = block[ci + 1]
if remap and remap[ci] then tile = remap[ci] end
local quad = self.quads[tile]
if quad then
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
@@ -438,6 +600,22 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY)
if shader then love.graphics.setShader() end
end
-- queue the same bottom tile row for the post-zone sprite-redraw pass
-- (GBC mode: OBP-baked sprites replay after the zone shader, so the
-- grass patch that hides their feet must replay over them, colorized
-- with the map's palette and color-0 keyed)
function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
local ty = cy * 2 + 1
for i = 0, 1 do
local tx = cx * 2 + i
local quad = self.quads[self.map:tileAt(tx, ty)]
if quad then
PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX),
ty * 8 - math.floor(camY), 1, colors, true)
end
end
end
-- animated overdraw at the current step; bodyOnly skips the ring
-- positions (connected maps draw body-only)
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
@@ -487,7 +665,10 @@ end
-- rebuild after a block change (Cut trees)
function TileRenderer:rebuild()
local fresh = TileRenderer.new(self.map)
local fresh = TileRenderer.new(self.map, self.data)
self.image = fresh.image
self.gbcAtlas = fresh.gbcAtlas
self.quads = fresh.quads
self.ringBatch = fresh.ringBatch
self.mapBatch = fresh.mapBatch
self.anims = fresh.anims
+27 -10
View File
@@ -5,6 +5,7 @@
-- Conditionals: check_flag stores its result in ctx.lastCheck;
-- jump_if_true/jump_if_false jump to an absolute row index.
local Collision = require("src.world.Collision")
local Flags = require("src.script.Flags")
local Logger = require("src.core.Logger")
local Screens = require("src.ui.Screens")
@@ -225,7 +226,7 @@ function Commands.start_battle(ctx, kind, a, b)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
if ctx.overworld then
ctx.overworld:afterBattle(result)
ctx.overworld:afterBattle(result, battle)
end
runner:resume()
end
@@ -269,7 +270,10 @@ end
local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" },
{ -1, 0, "left" }, { 1, 0, "right" } }
local function bfsPath(map, sx, sy, tx, ty)
-- entities/mover let the walk route around other entities' cells (the
-- player especially), so a scripted NPC never clips through them; the
-- target cell is exempt so an NPC can still walk up onto its goal
local function bfsPath(map, sx, sy, tx, ty, entities, mover)
local key = function(x, y) return y * 1000 + x end
local prev = { [key(sx, sy)] = false }
local queue = { { sx, sy } }
@@ -289,8 +293,11 @@ local function bfsPath(map, sx, sy, tx, ty)
for _, d in ipairs(DIRS4) do
local nx, ny = cx + d[1], cy + d[2]
local nk = key(nx, ny)
local isTarget = nx == tx and ny == ty
if prev[nk] == nil and map:inBounds(nx, ny)
and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then
and (map:isWalkableCell(nx, ny) or isTarget)
and (isTarget or not entities
or not Collision.occupied(entities, nx, ny, mover)) then
prev[nk] = { cx, cy, d[3] }
table.insert(queue, { nx, ny })
end
@@ -303,7 +310,7 @@ function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld
local npc = ow:npcByIndex(objIndex)
if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty)
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
if not path then
Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty)
return
@@ -390,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
-- positions mid-cutscene)
local ow = ctx.overworld
if not ow or ow.map.id ~= mapId then return end
ow.npcPool = ow.npcPool or {}
if visible then
for _, n in ipairs(ow.npcs) do
if n.def.name == objName then return end
@@ -400,12 +408,16 @@ local function toggleObject(ctx, mapId, objName, visible)
local npc = NPC.new(ctx.game.data, mapId, obj)
table.insert(ow.npcs, npc)
table.insert(ow.entities, npc)
ow.npcPool[npc.id] = npc
return
end
end
else
for i = #ow.npcs, 1, -1 do
if ow.npcs[i].def.name == objName then table.remove(ow.npcs, i) end
if ow.npcs[i].def.name == objName then
ow.npcPool[ow.npcs[i].id] = nil
table.remove(ow.npcs, i)
end
end
for i = #ow.entities, 1, -1 do
local e = ow.entities[i]
@@ -523,11 +535,11 @@ function Commands.record_hall_of_fame(ctx)
-- save keeps the player standing in the HALL_OF_FAME room. (The
-- E4 room-script/event resets that precede the save in pokered are
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
-- The reset heal point is field.boot's spawn, PALLET_TOWN (5,6)
-- in the vanilla dataset.
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
-- that split (and lets a total conversion redirect it).
local boot = game.data.field and game.data.field.boot or {}
ctx.save.lastHeal = { map = boot.startMap or "PALLET_TOWN",
x = boot.startX or 5, y = boot.startY or 6 }
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
if game.writeSave then game:writeSave() end
end)
end)
@@ -823,7 +835,12 @@ end
-- registry and block until the screen pops itself
function Commands.push_screen(ctx, screenId, args)
local screens = ctx.game.data.screens
if not (screens and screens[screenId]) then
local known = screens and screens[screenId] ~= nil
if not known then
-- builtin engine screens (src/ui/<id>.lua) resolve through Screens too
known = pcall(Screens.get, ctx.game, screenId)
end
if not known then
error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0)
end
local runner = ctx.runner
+49 -28
View File
@@ -71,6 +71,12 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id)
list:close()
showMessages(game, payload, function()
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
-- wBattleResult, so a script that reads the result afterwards sees
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
-- (the Poke Doll trick); the flag lets it tell this escape from an
-- ordinary RUN, which writes $2.
battle.pokeDollEscape = true
battle.result = "run"
battle.afterQueue = "finish"
battle.phase = "messages"
@@ -290,37 +296,52 @@ local function useOn(game, battle, id, target, list, moveIndex)
showMessages(game, payload) -- failed
end
local function pickTargetAndUse(game, battle, id, list)
-- pick a target from the party
-- 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", {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
useOn(game, battle, id, mon, list)
return
end
local rows = {}
for mi, mv in ipairs(mon.moves) do
local mdef = game.data.moves[mv.id]
table.insert(rows, {
value = mi,
label = mdef and mdef.name or mv.id,
right = ("%d"):format(mv.pp),
})
end
game.stack:push(ListMenu.new(game, "Which move?", rows, {
onChoose = function(row, l)
l:close()
useOn(game, battle, id, mon, list, row.value)
end,
}))
end,
})
end
local function useItem(game, battle, id, list)
local def = game.data.items[id]
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- pick a target from the party
-- 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", {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
useOn(game, battle, id, mon, list)
return
end
local rows = {}
for mi, mv in ipairs(mon.moves) do
local mdef = game.data.moves[mv.id]
table.insert(rows, {
value = mi,
label = mdef and mdef.name or mv.id,
right = ("%d"):format(mv.pp),
})
end
game.stack:push(ListMenu.new(game, "Which move?", rows, {
onChoose = function(row, l)
l:close()
useOn(game, battle, id, mon, list, row.value)
end,
}))
end,
})
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
if def and def.machine then
local moveDef = game.data.moves[def.machine.move]
local moveName = moveDef and moveDef.name or def.machine.move
local booted = def.machine.kind == "HM"
and "Booted up an HM!" or "Booted up a TM!"
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
function() pickTargetAndUse(game, battle, id, list) end)
return
end
pickTargetAndUse(game, battle, id, list)
else
useOn(game, battle, id, nil, list)
end
+4 -2
View File
@@ -1,11 +1,12 @@
-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half,
-- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON"
-- capture and the captured key or pad button lands in
-- save.options.bindings -- the overlay src/core/Bindings.lua
-- (04-mod-api-core) reads back over Input's fixed map.
-- save.options.bindings, which Input:applyBindings layers over its fixed
-- default map (see src/core/Input.lua and Game:applyOptions).
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Input = require("src.core.Input")
local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
@@ -78,6 +79,7 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundKey(opts.bindings, item.button):upper()
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
+3
View File
@@ -5,6 +5,7 @@
-- has no cancel; the flash always completes).
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local EvolutionState = {}
EvolutionState.__index = EvolutionState
@@ -39,6 +40,7 @@ function EvolutionState.new(game, mon, newSpecies, onDone)
self.newSprite = frontSprite(game, newSpecies)
self.t = 0
self.done = false
Music.play(game.data, Music.special(game.data, "evolution"))
return self
end
@@ -57,6 +59,7 @@ function EvolutionState:update(dt)
("Congratulations!\nYour %s\nevolved into\n%s!")
:format(self.oldName, newName),
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
end))
+56 -6
View File
@@ -16,6 +16,11 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true
-- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon
-- sprite in from the right. Both play out before the beat's text prints.
local FADE_FRAMES = 24
local WIPE_FRAMES = 32
-- naming presets are boot config (field.boot.namePresets), which a total
-- conversion replaces; the Red/Blue lists remain the fallback
local function namePresets(game, who, fallback)
@@ -93,16 +98,23 @@ function OakSpeech:say(key, next)
end
local STEPS = {
-- 1. Oak's welcome
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
function(self)
self.pic = self.oakPic
self:say("_OakSpeechText1", function() self:advance() end)
self:revealPic("fade", function()
self:say("_OakSpeechText1", function() self:advance() end)
end)
end,
-- 2. NIDORINO show-off, with its cry
-- 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)
self.pic = self.demoPic
Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end)
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)
@@ -175,8 +187,21 @@ function OakSpeech:lastPageLines(key)
return ok and lines or nil
end
-- Reveal the current pic over `dur` frames, then run `next`. Ticked from
-- update() while OakSpeech is the top state (before the beat's text box is
-- pushed), so the fade/wipe plays out ahead of the text like the ROM.
function OakSpeech:revealPic(kind, next)
self.picReveal = {
kind = kind,
t = 0,
dur = kind == "fade" and FADE_FRAMES or WIPE_FRAMES,
next = next,
}
end
function OakSpeech:advance()
self.step = self.step + 1
self.picFlip = false
local fn = STEPS[self.step]
if fn then
fn(self)
@@ -205,6 +230,15 @@ end
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
function OakSpeech:update(dt)
local r = self.picReveal
if r then
r.t = r.t + 1
if r.t >= r.dur then
self.picReveal = nil
if r.next then r.next() end
end
return
end
if not self.shrink then return end
local s = self.shrink
s.frame = s.frame + 1
@@ -238,7 +272,23 @@ function OakSpeech:draw()
local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 8
love.graphics.draw(self.pic, x, y)
local reveal = self.picReveal
local off = 0
if reveal and reveal.kind == "fade" then
-- FadeInIntroPic: ramp the pic's alpha up over the fade window
love.graphics.setColor(1, 1, 1, math.min(1, reveal.t / reveal.dur))
elseif reveal and reveal.kind == "wipe" then
-- MovePicLeft: the pic slides in from the right edge to its spot
off = math.floor((160 - x) * (1 - math.min(1, reveal.t / reveal.dur)))
end
if self.picFlip then
-- LoadFlippedFrontSpriteByMonIndex mirrors the front sprite
-- horizontally (wSpriteFlipped): draw with a negative x scale
love.graphics.draw(self.pic, x + off + w, y, 0, -1, 1)
else
love.graphics.draw(self.pic, x + off, y)
end
love.graphics.setColor(1, 1, 1, 1)
end
if self.walkVisible and self.walkSheet then
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
+24 -1
View File
@@ -3,7 +3,7 @@
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- quirks), plus the port's audio rows and display rows: music/SFX
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- GBC FX, and the MODS row that opens the mod manager.
-- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager.
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
-- add their own; CANCEL is appended after the hook and stays fixed on the
-- bottom line like pokered's.
@@ -11,6 +11,8 @@
local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -190,6 +192,27 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx)
return true
end },
{ id = "videoMode", label = "VIDEO MODE",
value = function(g)
return VideoMode.modeLabel(g.save.options.videoMode)
end,
step = function(g, dir)
local o = g.save.options
o.videoMode = VideoMode.cycle(o.videoMode, dir)
VideoMode.apply(o.videoMode)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED",
value = function(g)
return GameSpeed.levelLabel(g.save.options.speed)
end,
step = function(g, dir)
local o = g.save.options
o.speed = GameSpeed.cycle(o.speed, dir)
return true
end },
-- the manager's discoverable home (18-mod-manager-ux); inert until
-- opened, so the row costs a vanilla install nothing
{ id = "mods", label = "MODS",
+7 -1
View File
@@ -99,7 +99,13 @@ end
local function deposit(game)
local pc = game.save.pcItems
local inv = game.save.inventory
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv), {
local Bag = require("src.inventory.Bag")
-- badges live in save.inventory alongside items but are not depositable
local depositable = {}
for id, count in pairs(inv) do
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
+19
View File
@@ -4,6 +4,7 @@
-- item list runs through the ui.start_menu.items hook before the menu
-- opens, so mods insert or remove rows without patching this file.
local Font = require("src.render.Font")
local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu")
local Runtime = require("src.mods.Runtime")
@@ -126,6 +127,24 @@ function StartMenu.new(game)
baseUpdate(self, dt)
game.save.startMenuIndex = self.index
end
-- inside the Safari Zone the start menu also shows remaining steps and
-- SAFARI BALLs (PrintSafariZoneSteps): a 9x5 border at the top-left with
-- "steps/500" and "BALL xx"
if game.save.safari then
local baseDraw = menu.draw
menu.draw = function(self)
baseDraw(self)
local safari = game.save.safari
Font.drawBox(0, 0, 9, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8)
Font.draw("/500", 32, 8)
Font.draw("BALL", 8, 24)
Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24)
love.graphics.setColor(1, 1, 1, 1)
end
end
return menu
end
+6 -1
View File
@@ -1,7 +1,7 @@
-- Title screen (engine/movie/title.asm + engine/menus/main_menu.asm):
-- the logo (or a text fallback while the asset is missing), a cycling
-- Pokémon front sprite, the copyright line, and the CONTINUE / NEW GAME
-- / OPTION main menu on START or A.
-- / OPTION / EXIT GAME main menu on START or A.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
@@ -165,6 +165,11 @@ function TitleState:openMenu()
table.insert(items, { label = "OPTION", onSelect = function()
require("src.ui.Screens").push(game, "OptionsMenu")
end })
table.insert(items, { label = "EXIT GAME", onSelect = function()
if love.event and love.event.quit then
love.event.quit()
end
end })
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
end
+13
View File
@@ -85,6 +85,19 @@ local PLAYER_SPRITES = {
local LAST_MAP_REWRITES = {
ROUTE_22_GATE = { axis = "y", rules = { { below = 4, map = "ROUTE_23" },
{ map = "ROUTE_22" } } },
-- UndergroundPathRoute{5,6,7,8}_Script force wLastMap to their own route on
-- map load, so crossing the tunnel and taking the far building's LAST_MAP
-- exit lands you on that route instead of the one you entered from.
UNDERGROUND_PATH_ROUTE_5 = { rules = { { map = "ROUTE_5" } } },
UNDERGROUND_PATH_ROUTE_6 = { rules = { { map = "ROUTE_6" } } },
UNDERGROUND_PATH_ROUTE_7 = { rules = { { map = "ROUTE_7" } } },
UNDERGROUND_PATH_ROUTE_8 = { rules = { { map = "ROUTE_8" } } },
-- DiglettsCaveRoute{2,11}_Script are the same pattern for the cave's two
-- entrance houses. Without these, crossing the cave and taking the far
-- house's LAST_MAP door warps you back to the route you ENTERED from --
-- the whole cave was a one-way trip that always returned to its start.
DIGLETTS_CAVE_ROUTE_2 = { rules = { { map = "ROUTE_2" } } },
DIGLETTS_CAVE_ROUTE_11 = { rules = { { map = "ROUTE_11" } } },
}
FieldDefaults.FIELD = {
+60
View File
@@ -24,11 +24,63 @@ local NO_SHORE_TILESETS = { SHIP_PORT = true }
-- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap)
local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" }
-- warp pads and fall-through holes (data/tilesets/warp_pad_hole_tile_ids
-- .asm WarpPadAndHoleData); a tileset record carrying warpPadTiles
-- ({ [tileId] = "pad"|"hole" }) wins over these vanilla rows
local WARP_PAD_TILES = {
FACILITY = { [0x20] = "pad", [0x11] = "hole" },
CAVERN = { [0x22] = "hole" },
INTERIOR = { [0x55] = "pad" },
}
local function hashSet(list, into)
for _, t in ipairs(list) do into[t] = true end
return into
end
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
-- during an edge crossing. pokered's collision check reads the neighbor
-- strip's tile bytes, so a step off the map edge onto a solid tile of
-- the connected map bumps exactly like an in-map wall; the port needs
-- the same read without building the whole Map. Same math as cellTile
-- on the raw blocks, honoring the surf rule (water/shore passable only
-- while surfing, same fallbacks as Map.new).
function Map.defPassable(def, tilesetDef, cx, cy, surfing)
-- Fail closed: a missing tileset used to return true and re-open the
-- Pallet south-shore stranding (cross onto ROUTE_21 solids). No data
-- means we cannot prove the landing is safe, so the step bumps.
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
return false
end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return false end
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
if surfing then
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
if t == tile then return true end
end
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
shore = SHORE_TILES
end
for _, t in ipairs(shore or {}) do
if t == tile then return true end
end
end
return false
end
function Map.new(def, tilesetDef)
local self = setmetatable({}, Map)
self.def = def
@@ -156,6 +208,14 @@ function Map:isWarpTileCell(cx, cy)
return self.doorTiles[t] or self.warpTiles[t] or false
end
-- "pad"/"hole" when the cell's collision tile is a teleporter warp pad or
-- a fall-through hole (IsPlayerStandingOnWarpPadOrHole), nil otherwise
function Map:warpPadOrHoleAt(cx, cy)
local table_ = self.tileset.warpPadTiles or WARP_PAD_TILES[self.def.tileset]
if not table_ then return nil end
return table_[self:cellTile(cx, cy)]
end
-- counter tiles allow talking to NPCs across them (mart clerks, nurses)
function Map:isCounterCell(cx, cy)
local t = self:cellTile(cx, cy)
+1 -1
View File
@@ -23,7 +23,7 @@ function MapLoader.load(data, mapId)
-- warp tiles are stored per tileset macro name; the generated tilesets
-- module carries them in the tileset entry itself
local map = Map.new(def, tilesetDef)
map.renderer = TileRenderer.new(map)
map.renderer = TileRenderer.new(map, data)
cache[mapId] = map
return map
end
+1 -1
View File
@@ -26,7 +26,7 @@ function NPC.new(data, mapId, objDef)
self.id = string.format("%s_obj_%d", mapId, objDef.index)
local spriteDef = data.sprites[objDef.sprite]
assert(spriteDef, "unknown sprite " .. tostring(objDef.sprite))
self.sprite = SpriteRenderer.new(spriteDef)
self.sprite = SpriteRenderer.new(spriteDef, self.id)
-- object_event coordinates are already walk-grid cells
self.cellX, self.cellY = objDef.x, objDef.y
self.px, self.py = self.cellX * 16, self.cellY * 16
+412 -38
View File
@@ -40,6 +40,10 @@ local HEAL_BALL_XY = {
{ 40, 37 }, { 48, 37, true },
}
-- the healing machine's flash beat (FlashSprite8Times: rOBP1 ^= $28)
-- swaps the two middle shades of the monitor/ball art in place
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- object_event spawn filter (toggleable_objects, items taken, beaten
-- static encounters), shared by the current map's real NPCs and the
-- visual-only ghosts on connected neighbor maps
@@ -91,11 +95,25 @@ local NEIGHBOR_HOPS = 2
-- (32 px), the same alignment the connection macro encodes
-- (macros/scripts/maps.asm: _x = offset * -2 walk cells for
-- north/south, _y = offset * -2 for west/east).
function OverworldState.computeNeighbors(maps, rootId, hops)
-- reachW/reachH (optional, world pixels): with a full zoom-out the view
-- shows far more world than the fixed hop count covers, so any map whose
-- body could overlap the current map's rect inflated by the view
-- half-extents joins the set (and keeps the walk going) regardless of how
-- many connections away it sits -- otherwise far map bodies pop between
-- real tiles and the border filler when a crossing re-roots the BFS.
function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
local out = {}
local rootDef = maps[rootId]
local placed = { [rootId] = true }
local queue = { { def = maps[rootId], ox = 0, oy = 0, hops = 0 } }
local queue = { { def = rootDef, ox = 0, oy = 0, hops = 0 } }
local qi = 1
local function inReach(def, ox, oy)
if not (reachW and reachH and rootDef) then return false end
return ox + def.width * 32 > -reachW
and ox < rootDef.width * 32 + reachW
and oy + def.height * 32 > -reachH
and oy < rootDef.height * 32 + reachH
end
while queue[qi] do
local cur = queue[qi]
qi = qi + 1
@@ -114,11 +132,13 @@ function OverworldState.computeNeighbors(maps, rootId, hops)
ox, oy = cur.def.width * 32, conn.offset * 32
end
ox, oy = cur.ox + ox, cur.oy + oy
table.insert(out, { id = conn.map, ox = ox, oy = oy })
if cur.hops + 1 < hops then
table.insert(queue,
{ def = destDef, ox = ox, oy = oy,
hops = cur.hops + 1 })
if cur.hops + 1 <= hops or inReach(destDef, ox, oy) then
table.insert(out, { id = conn.map, ox = ox, oy = oy })
if cur.hops + 1 < hops or inReach(destDef, ox, oy) then
table.insert(queue,
{ def = destDef, ox = ox, oy = oy,
hops = cur.hops + 1 })
end
end
end
end
@@ -200,6 +220,24 @@ 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)
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
if self.map:blockAt(door.bx, door.by) ~= want then
self.map:setBlock(door.bx, door.by, want)
stamped = true
end
end
if stamped then self.map.renderer:rebuild() end
end
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
@@ -278,6 +316,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.player.surfing)
end
-- forced bike/surf tiles fire the moment the player is placed on the
-- map, like EnterMap's unconditional CheckForceBikeOrSurf farcall
-- (home/overworld.asm) -- a warp can land directly on one (the Route
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement()
-- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at
-- the old scroll position for the whole fade-in
@@ -299,13 +344,29 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
hooks.onEnter(Game, self)
end
-- neighbor maps drawn at the composed connection offsets, two hops
-- out (the GB only ever streamed a 32px strip of the single
-- directly connected map -- home/overworld.asm .loadNewMap)
self:rebuildNeighbors()
Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
end
-- Neighbor maps drawn at the composed connection offsets: at least the
-- configured hop count out (the GB only ever streamed a 32px strip of
-- the single directly connected map -- home/overworld.asm .loadNewMap),
-- widened to everything the current view size can show so a full
-- zoom-out never runs past the rendered set. Re-run whenever the view
-- grows (zoom/resize), not only on setMap.
function OverworldState:rebuildNeighbors()
local mapId = self.map.id
self.neighbors = {}
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps,
mapId, hops)) do
local vw, vh = Game.renderer:worldViewSize()
self.neighborViewW, self.neighborViewH = vw, vh
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
hops,
math.floor(vw / 2) + 64,
math.floor(vh / 2) + 64)) do
table.insert(self.neighbors,
{ map = MapLoader.load(Game.data, n.id),
ox = n.ox, oy = n.oy })
@@ -328,10 +389,6 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
end
end
end
Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
end
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld):
@@ -360,10 +417,15 @@ function OverworldState:paletteNameFor(map)
local palettes = FieldDefaults.field(Game.data, "palettes")
local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset)
if not name then
-- interiors inherit the outdoor map they sit in; before the player has
-- been outdoors at all that is wherever the game starts
-- Interiors inherit the outdoor map they sit in. Before the player has
-- been outdoors at all, that is wLastMap's zero-fill -- map 0,
-- PALLET_TOWN -- and NOT the spawn: the vanilla spawn (REDS_HOUSE_2F)
-- is itself an interior and would fall through to the ROUTE default.
-- defaultHeal derives the same zero-fill map (wLastBlackoutMap shares
-- the reasoning) and lets a total conversion redirect it.
local boot = (Game.data.field and Game.data.field.boot) or {}
local last = self.lastOutdoor and self.lastOutdoor.id
or FieldDefaults.fieldValue(Game.data, "boot", "startMap")
or require("src.core.SaveData").defaultHeal(boot).map
local lastDef = last and Game.data.maps[last]
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
or palettes.default
@@ -383,8 +445,25 @@ end
-- which recolored the whole screen per map -- see the survey zoom
-- entry in docs/known-differences.md). Border fill inherits the
-- current map's palette.
--
-- RED++ true overworld coloring does NOT go through this zone/shader
-- system at all: TileRenderer bakes real per-tile GBC colors straight into
-- a recolored tileset atlas (see TileRenderer's gbcAtlas), and
-- SpriteRenderer bakes sprites' OBP colors the same way, so the world
-- canvas is already final RGB by the time this runs. Returning an EMPTY
-- list here (when the current map has that baked atlas) skips the shader
-- entirely -- Renderer:endFrame's blit sees zoneList[1] == nil and falls
-- back to a plain, unshaded draw. Returning plain `nil` would NOT do this:
-- endFrame treats a nil worldZones as "no world-specific zones, reuse the
-- UI pass's zones" (sgbPalettes' whole-screen named-palette zone), which
-- would re-run the DMG shade-remap over already-true-color pixels using
-- an unrelated 4-color palette -- exactly the "colors are wrong" bug this
-- fixes.
function OverworldState:sgbWorldZones()
local PaletteFX = require("src.render.PaletteFX")
if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then
return {}
end
local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map))
if not base then return nil end
local vw, vh = Game.renderer:worldViewSize()
@@ -585,6 +664,14 @@ function OverworldState:update(dt)
-- keep the player sprite in sync with the bike state (the drawer
-- picks the red_bike sheet while riding)
self.player.onBike = Game.save.onBike
-- the rendered neighbor set depends on the view size; zooming out (or
-- resizing) past what setMap computed re-runs the walk in place
if self.map and (self.neighborViewW or 0) > 0 then
local vw, vh = Game.renderer:worldViewSize()
if vw ~= self.neighborViewW or vh ~= self.neighborViewH then
self:rebuildNeighbors()
end
end
if self.dustAnim then
local da = self.dustAnim
da.frames = da.frames - 1
@@ -593,6 +680,14 @@ function OverworldState:update(dt)
if da.onDone then da.onDone() end
end
end
if self.cutAnim then
local ca = self.cutAnim
ca.frames = ca.frames - 1
if ca.frames <= 0 then
self.cutAnim = nil
if ca.onDone then ca.onDone() end
end
end
if self.healAnim then
local ha = self.healAnim
local Music = require("src.core.Music")
@@ -682,6 +777,14 @@ function OverworldState:update(dt)
end
local stepped = self.player:update()
-- the warp-arrival cell goes stale the instant the player's real cell
-- leaves it, scripted walk-outs included -- pokered re-checks warps
-- after simulated steps too (CheckWarpsNoCollision), so a forced
-- door-mat exit must not leave the door permanently inert
local entry = self.warpEntryCell
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
self.warpEntryCell = nil
end
if stepped and not scripted then
self:onStepComplete()
end
@@ -878,8 +981,7 @@ function OverworldState:checkEdgeExit(dir)
local conn = self.map:connection(COMPASS[dir])
if conn then
self:crossConnection(dir, conn)
return true
return self:crossConnection(dir, conn)
end
return false
end
@@ -893,7 +995,7 @@ function OverworldState:crossConnection(dir, conn)
local dest = Game.data.maps[conn.map]
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn.map))
return
return false
end
local p = self.player
local x, y = p.cellX, p.cellY
@@ -909,6 +1011,15 @@ function OverworldState:crossConnection(dir, conn)
end
x = math.max(0, math.min(destW - 1, x))
y = math.max(0, math.min(destH - 1, y))
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
-- stepping off the edge onto a solid tile of the connected map bumps
-- exactly like an in-map wall. Without this read, Pallet's south
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
-- collision tile -- stranding the player on a cell no walk can leave.
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
p.surfing) then
return false
end
self:setMap(conn.map, x, y, p.facing, { seamless = true })
-- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step
@@ -926,6 +1037,7 @@ function OverworldState:crossConnection(dir, conn)
p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
return true
end
-- -------------------------------------------------------------------------
@@ -1017,7 +1129,7 @@ function OverworldState:goFishing(rod)
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
battle:makeSafari(Game.save.safari)
end
battle.onFinish = function(result) self:afterBattle(result) end
battle.onFinish = function(result) self:afterBattle(result, battle) end
self:pushBattle(battle)
end))
end))
@@ -1241,6 +1353,14 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
-- Bill's cell-separator PC (data/events/hidden_events.asm: hidden_event
-- 1,4 BillsHousePC SPRITE_FACING_UP)
if self.map.id == "BILLS_HOUSE" and fx == 1 and fy == 4
and self.player.facing == "up" then
self:billsHousePC()
return true
end
local extras = field.hiddenExtras
if not extras then return false end
local facing = self.player.facing
@@ -1323,7 +1443,18 @@ function OverworldState:tryCardKeyDoor(fx, fy)
return true
end
require("src.core.Sound").play(Game.data, "Go_Inside")
self:replaceBlock(math.floor(fx / 2), math.floor(fy / 2), openBlock)
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
self:replaceBlock(bx, by, openBlock)
-- opened doors stay open across reloads (the per-door unlock events
-- the floors' gate callbacks check, EVENT_SILPH_CO_n_UNLOCKED_DOOR*)
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
for _, door in ipairs(closedDoors and closedDoors[self.map.id] or {}) do
if door.bx == bx and door.by == by then
Game.save.flags[door.event] = true
break
end
end
Game.stack:push(TextBox.new(Game,
(t._CardKeySuccessText1 or "Bingo!")
.. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!")))
@@ -1425,6 +1556,71 @@ function OverworldState:trashCanSwitch(canIndex)
end
end
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
-- Bill steps back out of the machine human again
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
function OverworldState:billsHousePC()
local t = Game.data.text
local flags = Game.save.flags
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
return
end
require("src.core.Music").stop()
Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText
or "{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!", function()
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
require("src.core.Sound").play(Game.data, "Switch")
self:queueScript({
{ "wait", 32 },
{ "play_sound", "Tink" },
{ "wait", 80 },
{ "play_sound", "Shrink" },
{ "wait", 48 },
{ "play_sound", "Tink" },
{ "wait", 32 },
{ "play_sound", "Get_Item1" },
{ "wait", 30 },
}, { onDone = function() self:billsHouseBillExits() end })
end))
end
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
local function done()
Game.save.flags.EVENT_MET_BILL = true
Game.save.flags.EVENT_MET_BILL_2 = true
require("src.core.Music").playMap(Game.data, self.map.id,
Game.save.onBike, self.player.surfing)
end
local bill
for _, n in ipairs(self.npcs) do
if n.def and n.def.name == "BILLSHOUSE_BILL1" then bill = n break end
end
if not (bill and self.map.id == "BILLS_HOUSE") then
done()
return
end
bill.cellX, bill.cellY = 1, 2
bill.px, bill.py = 16, 32
bill.facing = "down"
self:scriptMove(bill, "down", 1, function()
self:scriptMove(bill, "right", 3, function()
self:scriptMove(bill, "down", 1, done)
end)
end)
end
-- Any hidden item still unfound NEAR the player? (the ITEMFINDER,
-- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5)
-- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes
@@ -1501,13 +1697,30 @@ function OverworldState:trySurf(fx, fy)
end
function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
-- $50) have cuttable anything. Matching raw block ids alone
-- false-positives on every other tileset -- block ids are only
-- meaningful within one tileset, so Route 23 (PLATEAU) had blocks
-- matching a swap's `before`, and applying it wrote a block id that
-- does not exist in PLATEAU's block table: the renderer indexed nil
-- and the game crashed. The same false match is what made the bot
-- chain-cut "ornamental bushes" around Saffron and Celadon.
local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy)
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
if not ((ts == "OVERWORLD" and tile == 0x3d)
or (ts == "GYM" and tile == 0x50)
or isGrass) then
return false
end
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
local block = self.map:blockAt(bx, by)
local swap
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
if sw.before == block then swap = sw break end
end
if not swap or self.map:isWalkableCell(fx, fy) then return false end
if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return false end
local mon = self:partyKnows("CUT")
if not mon then return false end
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
@@ -1522,13 +1735,37 @@ function OverworldState:tryCut(fx, fy)
{ bx = bx, by = by, block = block })
self.map:setBlock(bx, by, swap.after)
self.map.renderer:rebuild()
self:startDustAnim(fx, fy, function()
local finish = function()
require("src.core.Sound").play(Game.data, "Cut")
end)
end
if isGrass then
-- AnimCut .grass: tall grass gets the leaf-swirl / dust puff, not
-- the tree-split slide
self:startDustAnim(fx, fy, finish)
elseif ts == "OVERWORLD" then
-- the tree splits in half and slides apart (AnimCut .cutTreeLoop);
-- the GYM plant keeps the shared dust/leaf puff
self:startCutTreeAnim(fx, fy, finish)
else
self:startDustAnim(fx, fy, finish)
end
end))
return true
end
-- The cut-tree split (engine/overworld/cut.asm InitCutAnimOAM +
-- engine/overworld/cut2.asm AnimCut): the tree sprite's top half slides
-- +1px and its bottom half -1px per frame for 8 frames, flickering,
-- before the swapped block shows through. Falls back to the dust puff
-- when the extracted tree sprite is unavailable.
function OverworldState:startCutTreeAnim(cx, cy, onDone)
local fxDef = Game.data.field.overworldFx
if not (fxDef and fxDef.cutTree) then
return self:startDustAnim(cx, cy, onDone)
end
self.cutAnim = { x = cx, y = cy, frames = 8, total = 8, onDone = onDone }
end
-- Party-menu SURF entry (start_sub_menus.asm .surf): badge-check SOULBADGE,
-- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard
-- (item_effects.asm), which either tries to dismount (already surfing) or
@@ -1586,13 +1823,24 @@ function OverworldState:useCutFieldMove()
if not self:partyKnows("CUT") then return "no_badge" end
local fx, fy = self.player:facingCell()
if not self.map:inBounds(fx, fy) then return "nothing" end
-- same tileset/tile gate as tryCut (UsedCut, engine/overworld/cut.asm):
-- a tree BLOCK also contains fence/path cells, and facing those is
-- "nothing to cut" in vanilla
local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy)
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
if not ((ts == "OVERWORLD" and tile == 0x3d)
or (ts == "GYM" and tile == 0x50)
or isGrass) then
return "nothing"
end
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
local block = self.map:blockAt(bx, by)
local swap
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
if sw.before == block then swap = sw break end
end
if not swap or self.map:isWalkableCell(fx, fy) then return "nothing" end
if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return "nothing" end
return "ok"
end
@@ -1649,7 +1897,7 @@ function OverworldState:talkTo(npc)
if e == npc then table.remove(self.entities, i) break end
end
end
self:afterBattle(result)
self:afterBattle(result, battle)
unfreeze()
end
self:pushBattle(battle)
@@ -1764,9 +2012,11 @@ function OverworldState:openPC(onDone)
end
table.insert(items, { label = "LOG OFF", onSelect = logOff })
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm)
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
-- tw here (total width) is 16
Game.stack:push(Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, onCancel = logOff,
{ tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff,
noSound = true }))
end
@@ -1967,7 +2217,7 @@ function OverworldState:engageTrainer(npc, onDone)
end
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
local after = function()
self:afterBattle(result)
self:afterBattle(result, battle)
if onDone then onDone() end
end
if wonText then
@@ -1976,7 +2226,7 @@ function OverworldState:engageTrainer(npc, onDone)
after()
end
else
self:afterBattle(result)
self:afterBattle(result, battle)
if onDone then onDone() end
end
end
@@ -1985,6 +2235,8 @@ function OverworldState:engageTrainer(npc, onDone)
end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local victories = require("data.scripts.victories")
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
@@ -1993,6 +2245,11 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
if Game.save.flags[reward.flag] then return self:runVictoryHook() end
Game.save.flags[reward.flag] = true
end
if reward.deactivate then
for _, flag in ipairs(reward.deactivate) do
Game.save.flags[flag] = true
end
end
local lines = {}
if reward.badge then
Game.save.inventory[reward.badge] = 1
@@ -2275,8 +2532,19 @@ function OverworldState:onStepComplete()
-- arriving on a door/warp tile warp; a non-door warp square also fires
-- when the extra check passes and the d-pad is held
-- (CheckWarpsNoCollision)
-- The cell we warped in on is inert until we step off it: standing on it,
-- or being walked back onto it before leaving, does not re-fire (see
-- warpEntryCell where it is set). Once we are on any other cell it clears
-- and every warp is live again.
local entry = self.warpEntryCell
if entry and (p.cellX ~= entry.x or p.cellY ~= entry.y) then
self.warpEntryCell = nil
entry = nil
end
if self.justWarped then
self.justWarped = false
elseif entry then
-- still standing on the warp we arrived through; do not re-trigger it
else
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
if not w and self:dirHeld() then
@@ -2332,7 +2600,7 @@ function OverworldState:onStepComplete()
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
battle:makeSafari(Game.save.safari)
end
battle.onFinish = function(result) self:afterBattle(result) end
battle.onFinish = function(result) self:afterBattle(result, battle) end
self:pushBattle(battle)
return
end
@@ -2665,7 +2933,9 @@ function OverworldState:safariGameOver(text)
end
-- Blackouts return to the last heal point; evolutions run after battles.
function OverworldState:afterBattle(result)
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
function OverworldState:afterBattle(result, battle)
local lead = Game.save.party[1]
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
lead and lead.species or "-", lead and lead.hp or 0,
@@ -2675,6 +2945,13 @@ function OverworldState:afterBattle(result)
Evolution.checkParty(Game)
end
if result == "lose" then
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
and self.map and self.map.id == "OAKS_LAB"
if oaksLabRival then
-- stay in the lab; OaksLabRivalEndBattleScript heals and continues
evolutions()
return
end
-- blackout: revive the party at the last heal point; half the
-- money is lost (like the original)
local Pokemon = require("src.pokemon.Pokemon")
@@ -2723,6 +3000,25 @@ function OverworldState:takeWarp(warpDef)
-- facing carries across the warp (leaving a gate sideways keeps you
-- walking sideways; house exit mats are stepped onto facing down)
local facing = self.player.facing
-- warp pads and fall-through holes are not doors (WarpFound2
-- .indoorMaps: IsPlayerStandingOnWarpPadOrHole routes them through
-- LeaveMapAnim/EnterMapAnim instead of the door SFX)
local pad = self.map.warpPadOrHoleAt
and self.map:warpPadOrHoleAt(self.player.cellX, self.player.cellY)
if pad == "pad" then
-- teleporter: spin out with the exit SFX, spin back in on arrival
-- (player_animations.asm _LeaveMapAnim / EnterMapAnim)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.spinning = true
self.player.spinTimer = 0
self.arriveWarp = "teleport"
self:startWarpTo(destMap, x, y, facing)
return
elseif pad == "hole" then
-- falling through a hole: no door SFX, no walk-out step
self:startWarpTo(destMap, x, y, facing)
return
end
self.doorWarp = true -- door SFX + outdoor walk-out step
self:startWarpTo(destMap, x, y, facing)
end
@@ -2777,6 +3073,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
self.justWarped = true
-- The warp we land ON stays inert until we physically step off it, so a
-- warp whose destination cell is itself a warp cannot bounce us straight
-- back (elevator cars, stacked stair/door mats). This generalizes the
-- one-step justWarped guard, which only skipped the very next frame's
-- check and so let a mon walked back onto the pad re-trigger it.
self.warpEntryCell = { x = x, y = y }
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player
-- back in (player_animations.asm EnterMapAnim); ordinary door
-- warps never take this branch
@@ -2786,14 +3088,26 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
require("src.core.Sound").play(Game.data, "Teleport_Enter1")
-- ENTER_2 caps the spin-down a moment later
self.delaySfx = { frames = 40, key = "Teleport_Enter2" }
-- the sprite spins down into place (EnterMapAnim
-- PlayerSpinWhileMovingDown), not just the SFX
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinDrop = true
end
if doorWarp then
local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
-- stepping out of an outdoor door mat (the original's walk-out)
-- stepping out of an outdoor door/cave entrance (the original's
-- walk-out). Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
if outdoor and self.player.facing == "down"
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1)
end
end
@@ -3058,7 +3372,7 @@ function OverworldState:drawWorld()
-- at (64,64); anchoring those coords to where the player stood keeps
-- the overlay on the machine at any zoom.
local function fxHeal()
if not (self.healAnim and self.healAnim.visible) then return end
if not self.healAnim then return end
local ha = self.healAnim
local fxDef = Game.data.field.overworldFx
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
@@ -3074,6 +3388,19 @@ function OverworldState:drawWorld()
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
}
end
-- the jingle flash recolors the machine sprites in place
-- (FlashSprite8Times XORs rOBP1; the sprites never disappear):
-- ha.visible == false is the flashed half of each beat, drawn with
-- the light/dark shades swapped instead of skipped
local shader
if not ha.visible then
shader = PaletteFX.shader()
if shader then
PaletteFX.sendColors(shader,
PaletteFX.permute(PaletteFX.GRAYS, HEAL_FLASH_MAP))
love.graphics.setShader(shader)
end
end
local ox = ha.px - 64 - cam.x
local oy = ha.py - 64 - cam.y
love.graphics.setColor(1, 1, 1, 1)
@@ -3088,6 +3415,7 @@ function OverworldState:drawWorld()
ox + b[1], oy + b[2])
end
end
if shader then love.graphics.setShader() end
end
end
@@ -3118,6 +3446,37 @@ function OverworldState:drawWorld()
end
end
-- the cut tree splitting apart (AnimCut): top half slides right,
-- bottom half slides left, 1px per frame, flickering as they go
local function fxCutTree()
if not self.cutAnim then return end
local fxDef = Game.data.field.overworldFx
local tree = fxDef and fxDef.cutTree
if not tree then return end
if self.cutTreeImg == nil then
local ok, img = pcall(love.graphics.newImage, tree.path)
self.cutTreeImg = ok and img or false
end
local img = self.cutTreeImg
if not img then return end
if not self.cutTreeQuads then
local w, h = img:getWidth(), img:getHeight()
self.cutTreeQuads = {
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
}
end
local ca = self.cutAnim
local off = (ca.total or 8) - ca.frames
local dx = ca.x * 16 - cam.x
local dy = ca.y * 16 - cam.y
local flicker = ca.frames % 2 == 0
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
love.graphics.setColor(1, 1, 1, 1)
end
-- the "!" bubble above a trainer who spotted the player
local function fxEmote()
if not (self.emote and self.emote.npc) then return end
@@ -3209,6 +3568,11 @@ function OverworldState:drawWorld()
if not tilt then
-- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
-- current map's palette (see PaletteFX.markSpriteRedraw)
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
for _, g in ipairs(self.ghosts) do
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
end
@@ -3220,14 +3584,23 @@ function OverworldState:drawWorld()
love.graphics.setColor(1, 1, 1, 1)
if self.map:isGrassCell(e.cellX, e.cellY) then
self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY,
cam.x, bgY, grassColors)
end
end
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY,
cam.x, bgY, grassColors)
end
end
end
end
fxHeal()
fxDust()
fxCutTree()
fxEmote()
fxDark()
fxBird()
@@ -3241,6 +3614,7 @@ function OverworldState:drawWorld()
-- layers are separate and composited ground-under-upright, so drawing
-- it now into the still-active ground canvas is order-equivalent.
fxDust()
fxCutTree()
Game.renderer:beginUprightPass()
@@ -3301,7 +3675,7 @@ function OverworldState:drawWorld()
-- the ground in front of where the player was)
-- emote bubble -> the spotting NPC's foot (rides above its head)
-- fly bird, rod -> the player's foot
if self.healAnim and self.healAnim.visible then
if self.healAnim then
local fx = self.healAnim.px - cam.x + 8
local fy = self.healAnim.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
+32 -9
View File
@@ -26,12 +26,12 @@ function Player.new(data, cx, cy, facing)
local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk")
local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf")
local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike")
self.sprite = SpriteRenderer.new(data.sprites[walkId])
self.sprite = SpriteRenderer.new(data.sprites[walkId], "player")
if surfId and data.sprites[surfId] then
self.surfSprite = SpriteRenderer.new(data.sprites[surfId])
self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player")
end
if bikeId and data.sprites[bikeId] then
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId])
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player")
end
-- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png,
-- LedgeHoppingShadow, engine/overworld/ledges.asm)
@@ -84,9 +84,21 @@ function Player:update()
if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1
end
if self.spinFrames then
self.spinFrames = self.spinFrames - 1
if self.spinFrames <= 0 then
self.spinFrames = nil
self.spinDrop = nil
self.spinning = false
end
end
if not self.moving then return false end
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
self.progress = self.progress + 1
-- the walk-cycle clock ticks once per real frame while moving, so the
-- leg cadence stays constant when the bike halves stepFramesCur (only
-- translation speed doubles, like UpdatePlayerSprite's frame counters)
self.animClock = (self.animClock or 0) + 1
local d = Collision.DELTA[self.facing]
local px = math.floor(self.progress * 16 / stepLen)
self.px = self.cellX * 16 + d[1] * px
@@ -108,8 +120,8 @@ end
function Player:walkPhase()
if not self.moving then return 0 end
-- walk frame during the middle of the step
local p = self.progress % 16
-- 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
end
@@ -139,15 +151,26 @@ function Player:draw(camX, camY)
py = py + (self.bobTimer < 16 and 0 or 1)
end
local facing = self.facing
local phase = self:walkPhase()
-- alternate walk cycles mirror the up/down frame; derived from the
-- fixed-rate animation clock so the bike's shorter steps don't double
-- the leg cadence
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
if self.spinning then
-- spinner tiles whirl the sprite (PlayerSpinningFacingOrder)
-- spinner tiles whirl the sprite on its standing pose, one facing
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame)
self.spinTimer = (self.spinTimer or 0) + 1
facing = SPIN_ORDER[math.floor(self.spinTimer / 4) % 4 + 1]
facing = SPIN_ORDER[self.spinTimer % 4 + 1]
phase, flip = 0, false
-- teleport arrivals spin the sprite down into place
-- (EnterMapAnim PlayerSpinWhileMovingDown)
if self.spinFrames and self.spinDrop then
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
end
end
local sprite = (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
sprite:draw(self.px, py, camX, camY, facing,
self:walkPhase(), self.stepFlip)
sprite:draw(self.px, py, camX, camY, facing, phase, flip)
end
return Player
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
-- Same-frame press→release and multi-source hold regressions for Input.lua.
-- Self-contained: `luajit tests/input_hold_test.lua`; also dofile'd by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("input hold")
local check = S.check
local Input = require("src.core.Input")
Input:init()
-- Quick tap before the next FixedStep must edge-fire without leaving isDown.
Input:keypressed("up")
Input:keyreleased("up")
Input:step()
check(Input:wasPressed("up"), "same-frame tap still edges wasPressed")
check(not Input:isDown("up"), "same-frame tap does not stick isDown")
Input:reset()
Input:keypressed("up")
Input:step()
check(Input:wasPressed("up"), "held press edges wasPressed")
check(Input:isDown("up"), "held press keeps isDown across step")
Input:step()
check(not Input:wasPressed("up"), "hold does not re-edge next step")
check(Input:isDown("up"), "hold stays down next step")
Input:keyreleased("up")
check(not Input:isDown("up"), "release clears isDown")
-- W and Up both map to up; releasing one must not drop the other.
Input:reset()
Input:keypressed("w")
Input:keypressed("up")
Input:step()
Input:keyreleased("w")
check(Input:isDown("up"), "second source keeps up held after first release")
Input:keyreleased("up")
check(not Input:isDown("up"), "last source release clears up")
-- Stick flick on→off before step must not stick.
Input:reset()
Input:gamepadaxis(nil, "leftx", -0.9)
Input:gamepadaxis(nil, "leftx", 0)
Input:step()
check(Input:wasPressed("left"), "stick flick edges wasPressed")
check(not Input:isDown("left"), "stick flick does not stick isDown")
-- Drivers that only inject pressQueue still get a one-step hold.
Input:reset()
table.insert(Input.pressQueue, "down")
Input:step()
check(Input:wasPressed("down"), "synthetic pressQueue edges wasPressed")
check(Input:isDown("down"), "synthetic pressQueue sets isDown")
S.finish()
+12 -7
View File
@@ -112,8 +112,8 @@ check(#Data.constants.hmMoves == 5 and Data.constants.hmMoves[1] == "CUT",
"hmMoves seeded")
local boot = Data.field.boot
check(boot.startMap == "PALLET_TOWN" and boot.startX == 5 and boot.startY == 6
and boot.startFacing == "down", "field.boot seeded with the Pallet spawn")
check(boot.startMap == "REDS_HOUSE_2F" and boot.startX == 3 and boot.startY == 6
and boot.startFacing == "down", "field.boot seeded with the NewGameWarp spawn")
check(boot.playerName == "RED" and boot.rivalName == "BLUE"
and boot.startMoney == 3000, "field.boot seeded with the Red new-game values")
check(boot.namePresets.player[1] == "RED" and boot.namePresets.rival[1] == "BLUE",
@@ -312,16 +312,21 @@ check(#worldData.field.ledges == #Data.field.ledges,
-- ------- field.boot changes the new game
local vanillaSave = SaveData.newGame(Data.field.boot)
check(vanillaSave.player.map == "PALLET_TOWN" and vanillaSave.player.x == 5
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
-- walking out of the house, so a new game skipped Red's house entirely.
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down",
"the seeded boot config reproduces the Pallet spawn")
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
-- zero-filled at new game and PALLET_TOWN is map 0
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
and vanillaSave.lastHeal.y == 6, "heal point defaults to the spawn")
and vanillaSave.lastHeal.y == 6, "blackouts return to Pallet Town, not the spawn")
-- an absent config is still the vanilla new game
local bareSave = SaveData.newGame()
check(bareSave.player.map == "PALLET_TOWN" and bareSave.money == 3000
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
and bareSave.lastHeal.map == "PALLET_TOWN",
"newGame without a boot config is unchanged")
@@ -355,7 +360,7 @@ check(bootData.field.boot.screens.title == "TitleState",
check(bootData.field.hiddenItems.CERULEAN_CAVE_1F ~= nil
and #bootData.field.flyOrder == #Data.field.flyOrder,
"sibling field keys are intact after a boot patch")
check(Data.field.boot.startMap == "PALLET_TOWN",
check(Data.field.boot.startMap == "REDS_HOUSE_2F",
"the boot merge never touched the live Data")
-- the save table is a copy: writing to it cannot reach back into Data
+17
View File
@@ -598,6 +598,23 @@ check(PaletteFX.monPal(palData, "TESTMON") == monPalette,
check(PaletteFX.monPal(palData, "UNKNOWN") == nil,
"an unmapped species falls through to MEWMON (absent here)")
-- RED++ pack: per-species SuperPalettes from data/palettes_gbc.lua
local prevMode = PaletteFX.mode
PaletteFX.setMode("redpp")
check(PaletteFX.usesGbcPack(), "redpp mode selects the gbc pack")
local gbc = PaletteFX.gbcPack()
check(gbc ~= nil and gbc.palettes.BULBASAUR ~= nil,
"data/palettes_gbc.lua ships per-species pals")
check(PaletteFX.monPalName({ palettes = nil }, "BULBASAUR") == "BULBASAUR",
"RED++ monPalName resolves to the species palette id")
check(PaletteFX.monPal({ palettes = nil }, "BULBASAUR") == gbc.palettes.BULBASAUR,
"RED++ monPal reads the species colors without a ROM pack")
check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)")
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
PaletteFX.setMode(prevMode)
-- ------- the transitions registry
local transitions = Registry.new("transitions", Schemas.REGISTRIES.transitions)
+3 -3
View File
@@ -205,7 +205,7 @@ end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "mods", "controls" }
"gbcfx", "videoMode", "speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -238,7 +238,7 @@ check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[11].activate(mgGame)
om.rows[13].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +248,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[12].activate(cbGame)
om.rows[14].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+99
View File
@@ -263,4 +263,103 @@ do
package.loaded["src.render.TextBox"] = realTB
end
-- (7) gym / dojo leader victory deactivates unfought non-leader trainers
-- (PewterGym.asm "; deactivate gym trainers" / SetEventRange in the
-- other gyms; FightingDojo.asm SetEventRange through TRAINER_3).
do
local victories = require("data.scripts.victories")
local expected = {
["OPP_BROCK#1"] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
["OPP_MISTY#1"] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0",
"EVENT_BEAT_CERULEAN_GYM_TRAINER_1" },
["OPP_LT_SURGE#1"] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0",
"EVENT_BEAT_VERMILION_GYM_TRAINER_1",
"EVENT_BEAT_VERMILION_GYM_TRAINER_2" },
["OPP_ERIKA#1"] = 7,
["OPP_KOGA#1"] = 6,
["OPP_SABRINA#1"] = 7,
["OPP_BLAINE#1"] = 7,
["OPP_GIOVANNI#3"] = 8,
["OPP_BLACKBELT#1"] = 4,
}
for key, want in pairs(expected) do
local d = victories[key] and victories[key].deactivate
check(d ~= nil, key .. " lists trainers to deactivate")
if type(want) == "number" then
eq(#d, want, key .. " deactivates " .. want .. " trainers")
else
for i, flag in ipairs(want) do
eq(d[i], flag, key .. " deactivate[" .. i .. "]")
end
end
end
-- Cinnabar trainers have no extracted def_trainers; seeded headers let
-- EVENT_BEAT_CINNABAR_GYM_TRAINER_* satisfy trainerDefeated without
-- stamping defeatedTrainers (which would also open the quiz gates).
Data:seedCinnabarGymTrainerHeaders()
check(Data.trainer_headers.CinnabarGym ~= nil,
"CinnabarGym trainer headers seeded for deactivate flags")
local nerd1 = Data:trainerHeader("CinnabarGym", 2)
check(nerd1 and nerd1.event == "EVENT_BEAT_CINNABAR_GYM_TRAINER_0"
and nerd1.range == 0,
"Cinnabar SUPER_NERD1 header: event + no sight range")
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(game, text, done) return { text = text, onDone = done } end,
}
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
Game.save.inventory = {}
Game.save.defeatedTrainers = {}
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
local ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BROCK", 1)
check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK")
check(Game.save.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE")
check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0,
"Brock victory deactivates the Pewter Gym Cooltrainer")
local pewterNpc
for _, npc in ipairs(ow.npcs) do
if npc.def.index == 2 then pewterNpc = npc break end
end
check(pewterNpc and ow:trainerDefeated(pewterNpc),
"unfought Pewter gym trainer is defeated after badge")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
Game.save.inventory = {}
Game.save.defeatedTrainers = {}
Game.stack:push(OW, "CINNABAR_GYM", 16, 15, "up")
ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BLAINE", 1)
check(Game.save.flags.EVENT_BEAT_CINNABAR_GYM_TRAINER_3,
"Blaine victory deactivates Cinnabar quiz trainers")
check(next(Game.save.defeatedTrainers) == nil,
"Blaine deactivate does not stamp defeatedTrainers (gates stay put)")
local cinnabarNpc
for _, npc in ipairs(ow.npcs) do
if npc.def.index == 5 then cinnabarNpc = npc break end
end
check(cinnabarNpc and ow:trainerDefeated(cinnabarNpc),
"unfought Cinnabar trainer is defeated via seeded header event")
while Game.stack:top() do Game.stack:pop() end
package.loaded["src.render.TextBox"] = realTB
end
S.finish()
+16 -3
View File
@@ -192,21 +192,34 @@ check(actsOn.cut and actsOn.surf and actsOn.strength,
"CUT/SURF/STRENGTH submenu entries appear once the badges are held")
-- ===========================================================================
-- I: CUT from the party menu (CERULEAN_CITY cut tree @18,28, stand @17,28)
-- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14)
-- spans two unwalkable cells: the fence half at (18,28) (tile $50) and the
-- tree itself at (19,28) (tile $3d). UsedCut (engine/overworld/cut.asm)
-- checks the facing TILE, so only the tree cell cuts -- stand above it at
-- (19,27) facing down, the way a vanilla player does.
-- ===========================================================================
Game.save.party = { mkMon("BULBASAUR", "CUT") }
Game.save.inventory = { CASCADEBADGE = true }
ow = pushOW("CERULEAN_CITY", 17, 28, "right")
ow = pushOW("CERULEAN_CITY", 19, 27, "down")
check(ow.map:blockAt(9, 14) == 50, "cut tree block (50) present before CUT")
eq(ow:useCutFieldMove(), "ok", "useCutFieldMove ok when facing a cut tree")
ow.player.facing = "up"
eq(ow:useCutFieldMove(), "nothing", "useCutFieldMove nothing when not facing a tree")
ow.player.facing = "right"
ow.player.facing = "down"
Game.save.inventory.CASCADEBADGE = nil
eq(ow:useCutFieldMove(), "no_badge", "useCutFieldMove no_badge without CASCADEBADGE")
Game.save.inventory.CASCADEBADGE = true
-- the fence half of the same tree block is NOT cuttable (tile $50 on
-- OVERWORLD, not the $3d tree tile)
popToOW()
ow = pushOW("CERULEAN_CITY", 17, 28, "right")
eq(ow:useCutFieldMove(), "nothing",
"useCutFieldMove nothing when facing the tree block's fence cell")
popToOW()
ow = pushOW("CERULEAN_CITY", 19, 27, "down")
-- success path: facing the tree -> _UsedCutText, menu closes, tree replaced
clearCaptured()
local pmCut = PartyMenu.new(Game)
+89
View File
@@ -0,0 +1,89 @@
-- Parity test: a wild battle always returns to the action menu after a
-- POKé BALL fails to catch.
--
-- BattleState's message pump only leaves the "messages" phase through
-- afterQueue:
--
-- if self.phase == "messages" then
-- if not self:updateQueue() then
-- if self.afterQueue == "menu" then self.phase = "menu"
-- elseif self.afterQueue == "finish" then self:finish() end
-- end
-- return
-- end
--
-- so a drained queue with afterQueue set to anything else -- or to nothing
-- -- parks the battle in "messages" for good. Nothing on screen is
-- waiting, no input advances it, and the encounter can never end.
--
-- Found by the route driver, which reported it precisely once it was made
-- to give up rather than spin: "catch: livelocked on ROUTE_6 after 1201
-- iterations (steps=81, thrown=6, inBattle=true, phase=messages)". Six
-- balls thrown, battle still live, phase stuck. Before the guard it span
-- 10,229 times on one ODDISH and pinned the whole run.
--
-- Self-contained; run via `luajit tests/parity_ball_miss.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity ball miss")
local check, eq = S.check, S.eq
-- Drive the phase machine the way BattleState:update does, with a queue
-- that drains. The decision under test is what the phase becomes when the
-- queue empties, so the queue's contents do not matter -- only afterQueue.
local function pump(afterQueue, queued)
local b = {
phase = "messages",
afterQueue = afterQueue,
queue = queued or {},
finished = false,
}
b.updateQueue = function(self)
if #self.queue > 0 then table.remove(self.queue, 1) return true end
return false
end
b.finish = function(self) self.finished = true end
-- the real update()'s messages branch, verbatim
for _ = 1, 50 do
if b.phase == "messages" then
if not b:updateQueue() then
if b.afterQueue == "menu" then
b.phase = "menu"
elseif b.afterQueue == "finish" then
b:finish()
end
end
end
end
return b
end
-- The contract: "menu" and "finish" both leave the messages phase.
eq(pump("menu").phase, "menu", "afterQueue=menu returns to the action menu")
check(pump("finish").finished, "afterQueue=finish ends the battle")
-- ...and anything else is the livelock. This is the assertion that would
-- have caught it: a drained queue with no afterQueue never leaves
-- "messages", so the battle is unreachable by any input.
local stuck = pump(nil, { {}, {}, {} })
eq(stuck.phase, "messages", "afterQueue=nil is the stuck state (documented)")
check(#stuck.queue == 0, "the queue really did drain -- nothing is pending")
-- The real thing: openItems -> throwBall -> miss must leave afterQueue as
-- "menu" the whole way through, since throwBall itself never sets it.
local BattleState = require("src.battle.BattleState")
local openItems = BattleState.openItems
local fake = {
queue = {},
say = function(self, t) table.insert(self.queue, { text = t }) end,
act = function(self, f) table.insert(self.queue, { fn = f }) end,
ui = function(self, f) table.insert(self.queue, { ui = f }) end,
buildScreen = function() return {} end,
}
openItems(fake)
eq(fake.phase, "messages", "openItems parks the battle in the messages phase")
eq(fake.afterQueue, "menu", "openItems arms afterQueue=menu before the bag")
check(#fake.queue == 1 and fake.queue[1].ui ~= nil,
"openItems queues the bag as a ui item")
S.finish()
+95
View File
@@ -0,0 +1,95 @@
-- Regression: map-edge crossings must read the NEIGHBOR map's collision.
--
-- Without that read, Pallet Town's south shore land spit (cells x=2,3 at
-- y=17) walked straight onto ROUTE_21 (2,0)/(3,0) -- solid tiles. From
-- (2,0) a further step south lands on walkable (2,1), after which the
-- solid edge cell blocks the only path back to the seam, so the player
-- cannot return north. pokered's CollisionCheckOnLand reads the neighbor
-- strip's tile bytes and bumps; the port must do the same via
-- Map.defPassable before crossConnection commits.
--
-- Self-contained; run via `luajit tests/parity_connection_collision.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity connection collision")
local check, eq = S.check, S.eq
local pallet = MapLoader.load(Data, "PALLET_TOWN")
local route21 = MapLoader.load(Data, "ROUTE_21")
local ts = Data.tilesets[route21.def.tileset]
-- ground truth: the spit is land, the Route 21 landings are solid, and
-- the water column next to it is surf-only
for _, x in ipairs({ 2, 3 }) do
check(pallet:isWalkableCell(x, 17),
("Pallet south shore (%d,17) is walkable land"):format(x))
check(not route21:isWalkableCell(x, 0),
("ROUTE_21 (%d,0) is a solid landing"):format(x))
check(not Map.defPassable(route21.def, ts, x, 0, false),
("defPassable refuses land->solid at ROUTE_21 (%d,0)"):format(x))
end
check(route21:isWaterCell(5, 0), "ROUTE_21 (5,0) is water")
check(not Map.defPassable(route21.def, ts, 5, 0, false),
"water landing without Surf is refused")
check(Map.defPassable(route21.def, ts, 5, 0, true),
"water landing with Surf is allowed")
check(not Map.defPassable(route21.def, nil, 2, 0, false),
"missing tileset fails closed (no permissive fallback)")
-- Live edge: standing on the spit and pressing into the seam must bump,
-- not swap maps. Pallet -> Route 1 north still crosses on grass.
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 2, 17, "down")
local ow = Game.stack:top()
eq(ow.map.id, "PALLET_TOWN", "start on Pallet south shore")
eq(ow.player.cellX, 2, "spit x")
eq(ow.player.cellY, 17, "spit y")
local conn = ow.map:connection("south")
check(conn and conn.map == "ROUTE_21", "Pallet south connects to ROUTE_21")
check(ow:crossConnection("down", conn) == false,
"crossConnection bumps on ROUTE_21 solid landing")
eq(ow.map.id, "PALLET_TOWN", "still on Pallet after the refused cross")
eq(ow.player.cellX, 2, "x unchanged after bump")
eq(ow.player.cellY, 17, "y unchanged after bump")
-- surfing the water column still crosses
ow.player.cellX, ow.player.cellY = 5, 17
ow.player.px, ow.player.py = 5 * 16, 17 * 16
ow.player.surfing = true
ow.player.moving = false
check(ow:crossConnection("down", conn) == true,
"surfing Pallet water still crosses onto ROUTE_21")
eq(ow.map.id, "ROUTE_21", "surf cross lands on ROUTE_21")
-- north seam onto Route 1 grass still works on foot
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up")
ow = Game.stack:top()
local north = ow.map:connection("north")
check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1")
check(ow:crossConnection("up", north) == true,
"Pallet -> Route 1 grass crossing still allowed")
eq(ow.map.id, "ROUTE_1", "north cross lands on ROUTE_1")
S.finish()
+109
View File
@@ -0,0 +1,109 @@
-- Parity test: losing your last POKéMON always blacks you out, even when
-- the battle was already decided in your favour.
--
-- A double faint -- the lead dying to residual damage on the same turn it
-- lands the KO -- used to leave BattleState:playerMonFainted at its
-- "the battle is decided" guard, so it returned with result = "win" still
-- set. OverworldState:afterBattle only revives the party and warps to the
-- heal point on "lose" (src/world/OverworldController.lua), so the player
-- was handed back to the overworld standing on the map with every POKéMON
-- at 0 HP. Nothing recovers from that: BattleState refuses to start an
-- encounter with no healthy party ("wild battle with no healthy party;
-- skipping"), so the save is bricked in place.
--
-- pokered cannot reach that state -- HandlePlayerMonFainted runs the
-- player-side check on its own account, so being out of useable POKéMON
-- blacks you out whatever happened to the enemy.
--
-- Found by the automated route driver: an attempt spent its whole length
-- "wiping" on VIRIDIAN_FOREST fourteen times without ever moving, because
-- the party was dead but the game had never blacked out.
-- Self-contained; run via `luajit tests/parity_double_faint.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity double faint")
local check, eq = S.check, S.eq
-- A stand-in carrying only what playerMonFainted touches. Deliberately not
-- a real battle: the point is the decision, and a full encounter would need
-- a loaded Data, an RNG and a queue to say nothing more than this does.
local function battleWith(partyHP, result)
local party = {}
for i, hp in ipairs(partyHP) do
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
end
return {
kind = "wild",
result = result,
afterQueue = nil,
said = {},
-- the reserves-left path reads the "Use next POKéMON?" prompt off Data
data = { text = { _UseNextMonText = "Use next POKéMON?" } },
game = { save = { party = party, player = { name = "RED" } } },
sayNext = function(self, m) self.said[#self.said + 1] = m end,
say = function(self, m) self.said[#self.said + 1] = m end,
ui = function() end,
}
end
local function saidBlackout(b)
for _, m in ipairs(b.said) do
if tostring(m):find("blacked") then return true end
end
return false
end
-- The regression itself: a won battle whose last mon died with it.
do
local b = battleWith({ 0 }, "win")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "a win with the last mon dead becomes a blackout")
eq(b.afterQueue, "finish", "the blackout ends the battle")
check(saidBlackout(b), "the blackout text still prints on a double faint")
end
-- Same for the other non-lose results, so no path can strand the party.
for _, result in ipairs({ "run", "caught" }) do
local b = battleWith({ 0 }, result)
BattleState.playerMonFainted(b)
eq(b.result, "lose", ("a %q result with no healthy party becomes a blackout")
:format(result))
end
-- An undecided battle keeps behaving exactly as before.
do
local b = battleWith({ 0 }, nil)
BattleState.playerMonFainted(b)
eq(b.result, "lose", "the ordinary last-mon faint still blacks out")
check(saidBlackout(b), "and still prints the blackout text")
end
-- The guard must not fire while something is still standing: a won battle
-- with a healthy reserve stays won, and the fainted-mon flow is untouched.
do
local b = battleWith({ 0, 15 }, "win")
BattleState.playerMonFainted(b)
eq(b.result, "win", "a win with a healthy reserve is still a win")
check(not saidBlackout(b), "and prints no blackout text")
end
-- Already lost: no second helping of the blackout text.
do
local b = battleWith({ 0 }, "lose")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "an already-lost battle stays lost")
eq(#b.said, 0, "and does not re-announce the blackout")
end
-- A mid-battle faint with reserves left is the wild "Use next POKéMON?"
-- prompt (DoUseNextMonDialogue), not a blackout -- the path the guard
-- sits in front of, so prove it still runs.
do
local b = battleWith({ 0, 15 }, nil)
BattleState.playerMonFainted(b)
eq(b.result, nil, "a faint with reserves left does not decide the battle")
check(not saidBlackout(b), "and does not black out")
end
S.finish()
+161
View File
@@ -0,0 +1,161 @@
-- Parity test: the ghost MAROWAK battle opens with NO Silph Scope check
-- at the trigger, and the Poke Doll escape counts as defeating it.
--
-- PokemonTower6FDefaultScript (scripts/PokemonTower6F.asm) fires on
-- (10,16), shows _PokemonTower6FBeGoneText, and starts the RESTLESS SOUL
-- battle unconditionally -- the SILPH_SCOPE is only consulted inside the
-- battle (IsGhostBattle: disguised sprite, "too scared to move", balls
-- dodged). Our port used to turn the player back without the scope and
-- never open the battle, which made 6F impassable on any route that skips
-- Rocket Hideout -- including the speedrun route the bot follows, whose
-- answer to the MAROWAK is a POKE_DOLL, not the scope.
--
-- The doll works because of wBattleResult: the battle script's
-- "and a / jr nz .did_not_defeat" reads 0 as "defeated". Losing writes $1
-- and running writes $2, but ItemUsePokeDoll ends the battle without
-- touching it -- so the doll escape reads as a win and sets
-- EVENT_BEAT_GHOST_MAROWAK. BagMenu marks that escape as
-- battle.pokeDollEscape; the 6F script keys on it.
--
-- Self-contained; run via `luajit tests/parity_marowak.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity ghost marowak")
local check, eq = S.check, S.eq
-- Real TextBoxes want a Font atlas; the decision under test is which
-- branch runs, not how the text renders.
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
-- A recording stand-in for BattleState: newWild captures the species and
-- level, makeGhost sets the flag the real one sets, and the test drives
-- onFinish by hand.
local madeBattles = {}
package.loaded["src.battle.BattleState"] = {
newWild = function(_, species, level)
local b = { species = species, level = level, ghost = false }
b.makeGhost = function(self) self.ghost = true end
madeBattles[#madeBattles + 1] = b
return b
end,
}
local M = dofile("data/scripts/story3.lua")
local tower = M.POKEMON_TOWER_6F
check(tower ~= nil and tower.onStep ~= nil, "POKEMON_TOWER_6F has a step trigger")
local function gameWith(inventory, flags)
local pushed = {}
return {
save = { inventory = inventory or {}, flags = flags or {} },
data = { text = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}, pushed
end
local function owWith()
local moved, after = {}, {}
return {
player = { facing = "up" },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
afterBattle = function(_, result) after[#after + 1] = result end,
}, moved, after
end
-- Walk the trigger: run onStep, then the Be-gone text's done() to push
-- the battle. Returns the battle object (or nil).
local function trigger(game, pushed, ow, x, y)
local before = #madeBattles
local fired = tower.onStep(game, ow, x, y)
if not fired then return nil, fired end
local box = pushed[#pushed]
check(box ~= nil and box.done ~= nil, "the Be-gone text is pushed first")
box.done()
return madeBattles[#madeBattles], fired, #madeBattles > before
end
-- ---- 1. fires only on (10,16), only while the event is unset ------------
do
local game, pushed = gameWith()
local ow = owWith()
check(not tower.onStep(game, ow, 10, 15), "no trigger off the coord")
check(not tower.onStep(game, ow, 9, 16), "no trigger off the coord (x)")
eq(0, #pushed, "nothing pushed off the coord")
end
do
local game = gameWith({}, { EVENT_BEAT_GHOST_MAROWAK = true })
local ow = owWith()
check(not tower.onStep(game, ow, 10, 16),
"a departed MAROWAK never re-triggers")
end
-- ---- 2. NO scope: the battle still opens, disguised as a ghost ----------
do
local game, pushed = gameWith({})
local ow = owWith()
local battle, fired, created = trigger(game, pushed, ow, 10, 16)
check(fired, "trigger fires without the SILPH_SCOPE")
check(created and battle ~= nil,
"the battle OPENS without the scope (vanilla; the old port turned back)")
eq("MAROWAK", battle.species, "the opponent is the MAROWAK")
eq(30, battle.level, "at level 30")
check(battle.ghost, "and it is ghost-disguised without the scope")
end
-- ---- 3. scope: same battle, not disguised -------------------------------
do
local game, pushed = gameWith({ SILPH_SCOPE = 1 })
local ow = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
check(battle ~= nil and not battle.ghost,
"with the scope the battle is not a ghost")
end
-- ---- 4. a win sets the event and shows the departed text ----------------
do
local game, pushed = gameWith({})
local ow, moved, after = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.onFinish("win")
check(game.save.flags.EVENT_BEAT_GHOST_MAROWAK, "win sets the event")
eq(0, #moved, "no shove after a win")
eq("win", after[#after], "afterBattle still runs")
end
-- ---- 5. the Poke Doll escape counts as a win (wBattleResult trick) ------
do
local game, pushed = gameWith({})
local ow, moved = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.pokeDollEscape = true
battle.onFinish("run")
check(game.save.flags.EVENT_BEAT_GHOST_MAROWAK,
"the doll escape sets EVENT_BEAT_GHOST_MAROWAK")
eq(0, #moved, "and does not shove the player")
end
-- ---- 6. an ordinary flee does NOT count, and steps you right ------------
do
local game, pushed = gameWith({})
local ow, moved = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.onFinish("run")
check(not game.save.flags.EVENT_BEAT_GHOST_MAROWAK,
"running away leaves the MAROWAK standing")
eq(1, #moved, "and walks the player off the trigger")
eq("right", moved[1] and moved[1][1], ".did_not_defeat steps RIGHT")
end
-- ---- 7. a loss neither sets the event nor shoves (blackout handles it) --
do
local game, pushed = gameWith({})
local ow, moved = owWith()
local battle = trigger(game, pushed, ow, 10, 16)
battle.onFinish("lose")
check(not game.save.flags.EVENT_BEAT_GHOST_MAROWAK, "a loss does not clear it")
eq(0, #moved, "no scripted step on a loss")
end
S.finish()
+112
View File
@@ -0,0 +1,112 @@
-- Parity test: mart inventories match pokered, and every item the route
-- driver tries to buy is actually on that mart's shelf.
--
-- Two failures in one, and the second is the one that bit.
--
-- Our extracted mart data was right all along -- VermilionMartClerkText is
-- POKE_BALL, SUPER_POTION, ICE_HEAL, AWAKENING, PARLYZ_HEAL, REPEL
-- (data/items/marts.asm:17), and that is exactly what we import. The route
-- driver's shopping list asked for plain POTION there, which Vermilion has
-- never sold. buyItem logged "shop: no POTION @ VERMILION_MART" into the
-- end-of-run summary and returned, so the bot left town with no healing at
-- all and then died nine times in Surge's gym and five more around
-- Cerulean, every fight taken at whatever HP the last nurse had left it.
--
-- A shopping list that names an unstocked item cannot work, and nothing
-- else in the run reports it loudly enough to notice. This asserts the
-- lists against the data instead.
--
-- Self-contained; run via `luajit tests/parity_mart_stock.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity mart stock")
local check, eq = S.check, S.eq
local T = dofile("data/generated/text_pointers.lua")
-- MAP_NAME -> text-pointer group ("CELADON_MART_4F" -> "CeladonMart4F")
local function groupOf(map)
local s = ""
for part in tostring(map):gmatch("[^_]+") do
if part:match("^%d") then
s = s .. part:upper()
else
s = s .. part:sub(1, 1):upper() .. part:sub(2):lower()
end
end
return s
end
-- every item sold on a map, across all its clerks
local function stockFor(map)
local group = T[groupOf(map)]
if not group then return nil end
local set, any = {}, false
for _, v in pairs(group) do
if type(v) == "table" and v.mart then
for _, id in ipairs(v.mart) do set[id] = true; any = true end
end
end
return any and set or nil
end
-- ---- 1. our extracted stock matches pokered's marts.asm -----------------
-- Spot-checks transcribed from data/items/marts.asm; the Vermilion row is
-- the one the driver got wrong, so it is pinned exactly.
local POKERED = {
VERMILION_MART = { "POKE_BALL", "SUPER_POTION", "ICE_HEAL", "AWAKENING",
"PARLYZ_HEAL", "REPEL" },
PEWTER_MART = { "POKE_BALL", "POTION", "ESCAPE_ROPE", "ANTIDOTE",
"BURN_HEAL", "AWAKENING", "PARLYZ_HEAL" },
LAVENDER_MART = { "GREAT_BALL", "SUPER_POTION", "REVIVE", "ESCAPE_ROPE",
"SUPER_REPEL", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL",
"PARLYZ_HEAL" },
}
for map, want in pairs(POKERED) do
local sold = stockFor(map)
check(sold ~= nil, map .. " has mart data")
if sold then
for _, id in ipairs(want) do
check(sold[id], ("%s stocks %s (marts.asm)"):format(map, id))
end
end
end
-- and the absence that caused the bug
check(not (stockFor("VERMILION_MART") or {}).POTION,
"VERMILION_MART sells SUPER_POTION and NOT plain POTION")
-- ---- 2. every driver shopping list is actually stocked ------------------
-- Mirrors SHOP_STOCK in tests/drivers/route.lua. Kept as a literal rather
-- than reached into the driver, which needs a live Game to load.
local SHOP_STOCK = {
viridianBalls = { "POKE_BALL", "ANTIDOTE", "PARLYZ_HEAL" },
pewter = { "ESCAPE_ROPE", "POTION" },
vermilion = { "SUPER_POTION", "POKE_BALL" },
repels = { "SUPER_POTION" },
buffs = {},
pokeDoll = { "POKE_DOLL" },
tm07 = {}, vending = {}, water = {},
}
local R = dofile("tests/drivers/bot_route.lua")
local checked = 0
for i, seg in ipairs(R) do
for _, step in ipairs(seg.steps) do
if step.op == "shop" then
local list = SHOP_STOCK[step.list]
-- an unknown list name would silently fall back to POTION at runtime
check(list ~= nil,
("segment %d: shop list %q is known to the driver")
:format(i, tostring(step.list)))
local sold = stockFor(seg.map)
for _, id in ipairs(list or {}) do
checked = checked + 1
check(sold and sold[id],
("segment %d: %s sells %s"):format(i, seg.map, id))
end
end
end
end
check(checked > 0, "the route actually contains shop steps to check")
S.finish()
+120
View File
@@ -0,0 +1,120 @@
-- Parity test: the Saffron gate guards take the drink from the COORD
-- TRIGGER, not only when talked to.
--
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
--
-- farcall RemoveGuardDrink
-- ldh a, [hItemToRemoveID]
-- and a
-- jr nz, .have_drink
--
-- before it decides anything, so simply stepping onto the trigger while
-- carrying FRESH_WATER / SODA_POP / LEMONADE hands it over and sets
-- BIT_GAVE_SAFFRON_GUARDS_DRINK. Only a player carrying none of the three
-- gets the "Gee, I'm thirsty" line and the walk-back.
--
-- Our port had the removal on the guard's talk handler alone, so walking up
-- with a drink in the bag was turned away and all four gates stayed shut
-- unless the player happened to talk to him -- which vanilla never asks
-- for. Saffron is the middle of the map, so this sealed the city: Celadon
-- <-> Lavender and the short Vermilion <-> Cerulean crossing both route
-- through it, and the route bot could not reach Lavender for the POKE_FLUTE
-- at all ("travelTo: no route to MR_FUJIS_HOUSE").
--
-- Self-contained; run via `luajit tests/parity_saffron_gate.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity saffron gate")
local check, eq = S.check, S.eq
-- The gate pushes real TextBoxes, which want a loaded Font atlas; the
-- decision under test is which branch runs, not how the text renders.
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local M = dofile("data/scripts/story2.lua")
-- Enough of a game for the gate's onStep: an inventory, flags, a stack that
-- records what was pushed, and a player that records being shoved back.
local function gameWith(inventory)
local pushed, moved = {}, {}
return {
save = { inventory = inventory, flags = {} },
data = { text = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
_pushed = pushed,
_moved = moved,
}, pushed, moved
end
local function owWith(moved)
return {
player = { facing = "up" },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
}
end
-- ROUTE_5_GATE's trigger cells are (3,3) and (4,3) (.PlayerInCoordsArray).
local gate = M.ROUTE_5_GATE
check(gate ~= nil and gate.onStep ~= nil, "ROUTE_5_GATE has a coord trigger")
-- Carrying a drink: the trigger takes it and lets us through.
for _, drink in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do
local game, pushed, moved = gameWith({ [drink] = 1 })
local ow = owWith(moved)
local handled = gate.onStep(game, ow, 3, 3)
check(handled, drink .. ": stepping on the trigger is handled")
check(game.save.flags.EVENT_GAVE_GUARDS_DRINK,
drink .. ": the guards are marked as having been given a drink")
eq(game.save.inventory[drink], nil, drink .. ": exactly one was removed")
check(#pushed == 1, drink .. ": the thanks text is shown")
check(#moved == 0, drink .. ": we are NOT walked back")
end
-- Only ONE drink is taken, even holding several.
do
local game = gameWith({ FRESH_WATER = 2, LEMONADE = 1 })
gate.onStep(game, owWith({}), 3, 3)
eq(game.save.inventory.FRESH_WATER, 1, "only one drink is consumed")
eq(game.save.inventory.LEMONADE, 1, "the other drinks are untouched")
end
-- Carrying nothing: thirsty line, and we get walked back the way we came.
do
local game, pushed, moved = gameWith({})
local ow = owWith(moved)
local handled = gate.onStep(game, ow, 3, 3)
check(handled, "no drink: the trigger still fires")
check(not game.save.flags.EVENT_GAVE_GUARDS_DRINK,
"no drink: the flag is NOT set")
check(#pushed == 1, "no drink: the thirsty text is shown")
-- the shove happens when the text box closes, not while it is up
if pushed[1] and pushed[1].done then pushed[1].done() end
check(#moved == 1 and moved[1][1] == "down",
"no drink: we are walked back the way we came")
end
-- Once given, the gate is open for good and stops triggering.
do
local game, pushed = gameWith({})
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
eq(gate.onStep(game, owWith({}), 3, 3), false,
"after the drink the trigger no longer blocks")
check(#pushed == 0, "and shows nothing")
end
-- Cells that are not trigger cells are ignored.
do
local game = gameWith({ FRESH_WATER = 1 })
eq(gate.onStep(game, owWith({}), 9, 9), false, "a non-trigger cell is ignored")
eq(game.save.inventory.FRESH_WATER, 1, "and takes no drink")
end
-- All four gates carry the same behaviour -- one drink opens every one.
for _, id in ipairs({ "ROUTE_5_GATE", "ROUTE_6_GATE",
"ROUTE_7_GATE", "ROUTE_8_GATE" }) do
check(M[id] and M[id].onStep, id .. " has the guard trigger")
end
S.finish()
+95
View File
@@ -0,0 +1,95 @@
-- Regression: a plan must never be blocked by another map's NPCs.
--
-- The route driver's BFS treats every entity as a wall, keyed by a folded
-- cell id (`y * width + x`). A warp swaps the map id before the entity list
-- is rebuilt, so a plan made in that window sees the PREVIOUS map's NPCs --
-- and folding hides how wrong that is. On an 8-wide gate, a forest NPC at
-- (16,43) folds to 43*8+16 = 360, which is a perfectly ordinary cell of the
-- gate; the wall lands somewhere innocent and nothing looks amiss.
--
-- Observed as: "goto (4,1) unreachable on VIRIDIAN_FOREST_NORTH_GATE; from
-- (2,1); npcs: SPRITE_YOUNGSTER@(16,43) SPRITE_YOUNGSTER@(30,33)
-- SPRITE_POKE_BALL@(12,29)" -- Viridian Forest coordinates listed against a
-- gate the size of a room. Every following segment skipped and the attempt
-- was lost.
--
-- The fix is to ignore any entity outside the current map's bounds. This
-- pins the folding arithmetic that makes the bug invisible, so a future
-- reader can see why the bounds check is not merely defensive.
--
-- Self-contained; run via `luajit tests/parity_stale_npc.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity stale npc")
local check, eq = S.check, S.eq
-- the driver's blocking rule, isolated: fold a cell, wall it, but only for
-- entities that are actually on this map
local function blockedCells(npcs, w, h)
local blocked = {}
for _, npc in ipairs(npcs) do
if npc.x >= 0 and npc.y >= 0 and npc.x < w and npc.y < h then
blocked[npc.y * w + npc.x] = true
end
end
return blocked
end
-- VIRIDIAN_FOREST_NORTH_GATE is 8 cells wide; the forest is far larger.
local GATE_W, GATE_H = 8, 8
-- The arithmetic that made this invisible: a foreign NPC folds onto a real
-- cell of the small map rather than into obvious nonsense.
eq(43 * GATE_W + 16, 360, "forest (16,43) folds onto a plain integer id")
eq(1 * GATE_W + 4, 12, "the gate's own (4,1) folds to 12")
-- Without the bounds check, foreign NPCs wall off cells of the gate.
do
local naive = {}
for _, n in ipairs({ { x = 16, y = 43 }, { x = 30, y = 33 }, { x = 12, y = 29 } }) do
naive[n.y * GATE_W + n.x] = true
end
check(next(naive) ~= nil,
"unguarded, off-map NPCs still produce blocked cell ids")
end
-- With it, they are ignored entirely.
do
local blocked = blockedCells({
{ x = 16, y = 43 }, { x = 30, y = 33 }, { x = 12, y = 29 },
{ x = 2, y = 18 }, { x = 27, y = 40 },
}, GATE_W, GATE_H)
check(next(blocked) == nil,
"every off-map NPC is ignored on the gate")
end
-- ...while the map's own NPCs still block, which is the whole point.
do
local blocked = blockedCells({
{ x = 3, y = 2 }, -- SPRITE_SUPER_NERD, really in the gate
{ x = 2, y = 5 }, -- SPRITE_GRAMPS
{ x = 16, y = 43 }, -- stale, from the forest
}, GATE_W, GATE_H)
check(blocked[2 * GATE_W + 3], "an NPC inside the gate still blocks its cell")
check(blocked[5 * GATE_W + 2], "and so does the second one")
local n = 0
for _ in pairs(blocked) do n = n + 1 end
eq(n, 2, "exactly the two real NPCs block -- the stale one does not")
end
-- Edge cases of the bound itself.
do
local blocked = blockedCells({
{ x = GATE_W - 1, y = GATE_H - 1 }, -- last legal cell
{ x = GATE_W, y = 0 }, -- one past the right edge
{ x = 0, y = GATE_H }, -- one past the bottom
{ x = -1, y = 0 }, -- negative
}, GATE_W, GATE_H)
check(blocked[(GATE_H - 1) * GATE_W + (GATE_W - 1)],
"the far corner is in bounds and blocks")
local n = 0
for _ in pairs(blocked) do n = n + 1 end
eq(n, 1, "off-by-one and negative coordinates are all rejected")
end
S.finish()
+174 -11
View File
@@ -38,6 +38,19 @@ check(pallet:isWarpTileCell(5, 5), "Red's house door is a door tile")
local w = pallet:warpAtCell(5, 5)
eq(w.def.destMap, "REDS_HOUSE_1F", "door warp goes to Red's house")
-- Pallet south shore spit: land that faces ROUTE_21 solids. Crossing
-- without reading the neighbor tile stranded players (see
-- tests/parity_connection_collision.lua).
local Map = require("src.world.Map")
local route21Def = Data.maps.ROUTE_21
local route21Tileset = Data.tilesets[route21Def.tileset]
check(pallet:isWalkableCell(2, 17), "Pallet south shore spit (2,17) walkable")
check(pallet:isWalkableCell(3, 17), "Pallet south shore spit (3,17) walkable")
check(not Map.defPassable(route21Def, route21Tileset, 2, 0, false),
"ROUTE_21 (2,0) refuses a land edge cross")
check(not Map.defPassable(route21Def, route21Tileset, 3, 0, false),
"ROUTE_21 (3,0) refuses a land edge cross")
-- signs
local sign = pallet:signAtCell(13, 13)
eq(sign.text, "TEXT_PALLETTOWN_OAKSLAB_SIGN", "Oak's lab sign at (13,13)")
@@ -719,6 +732,33 @@ do
eq(x, 8, "south gate lands on Route 22 gate warp x")
eq(y, 5, "south gate lands on Route 22 gate warp y")
end
-- UndergroundPathRoute{5,6,7,8}_Script force wLastMap to their own route on
-- map load, so crossing the tunnel and taking the far building's LAST_MAP exit
-- lands you on that route rather than the one you entered from (issue #1)
do
local OW = require("src.world.OverworldController")
local FieldDefaults = require("src.world.FieldDefaults")
local rewrites = FieldDefaults.field(Data, "lastMapRewrites")
local cases = {
{ map = "UNDERGROUND_PATH_ROUTE_5", route = "ROUTE_5", x = 17, y = 27 },
{ map = "UNDERGROUND_PATH_ROUTE_6", route = "ROUTE_6", x = 17, y = 13 },
{ map = "UNDERGROUND_PATH_ROUTE_7", route = "ROUTE_7", x = 5, y = 13 },
{ map = "UNDERGROUND_PATH_ROUTE_8", route = "ROUTE_8", x = 13, y = 3 },
-- DiglettsCaveRoute{2,11}_Script: same pattern for the cave's two ends
{ map = "DIGLETTS_CAVE_ROUTE_2", route = "ROUTE_2", x = 12, y = 9 },
{ map = "DIGLETTS_CAVE_ROUTE_11", route = "ROUTE_11", x = 4, y = 5 },
}
for _, c in ipairs(cases) do
local rewrite = rewrites[c.map]
check(rewrite ~= nil, c.map .. " rewrites wLastMap to its own route")
eq(OW.rewrittenLastMap(rewrite, 0, 0), c.route, c.map .. " -> " .. c.route)
local door = Data.maps[c.map].warps[1]
local m, x, y = Warp.destination(Data, door, { id = c.route, x = 0, y = 0 })
eq(m, c.route, c.map .. " door exits onto " .. c.route)
eq(x, c.x, c.map .. " door lands on the " .. c.route .. " entrance x")
eq(y, c.y, c.map .. " door lands on the " .. c.route .. " entrance y")
end
end
eq(Data.field.forcedMovement.slopeMaps[1], "ROUTE_17", "Cycling Road slope map")
check(Data.field.seafoam.SEAFOAM_ISLANDS_B3F.currents[1].moves[1] ~= nil,
"Seafoam B3F current movement extracted")
@@ -827,6 +867,11 @@ do
ab.rng = mkseq({ 255 }) -- the 1/256 miss
ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 })
eq(ab.enemy.mon.status, nil, "THUNDER WAVE misses on the 255 roll")
local function hasAnim(b)
for _, r in ipairs(b.queue) do if r.anim then return true end end
return false
end
check(not hasAnim(ab), "a missed THUNDER WAVE plays no move animation")
ab.rng = mkseq({ 254 })
ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 })
eq(ab.enemy.mon.status, "PAR", "THUNDER WAVE lands on the 254 roll")
@@ -837,6 +882,33 @@ do
eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll")
end
-- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss
-- (unless EXPLODE_EFFECT)
do
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local function hasAnim(b)
for _, r in ipairs(b.queue) do if r.anim then return true end end
return false
end
local function sawMiss(b)
for _, r in ipairs(b.queue) do
if r.text and r.text:find("attack missed!", 1, true) then return true end
end
return false
end
local mb = BattleState.newWild(Game, "RATTATA", 5)
mb.rng = function(a, b) return b end -- accuracy 255: miss
mb:performMove(mb.player, mb.enemy, { id = "TACKLE", pp = 10 })
check(sawMiss(mb), "TACKLE miss prints AttackMissedText")
check(not hasAnim(mb), "a missed TACKLE plays no move animation")
eq(mb.enemy.mon.hp, mb.enemy.mon.stats.hp, "a missed TACKLE deals no damage")
local hb = BattleState.newWild(Game, "RATTATA", 5)
hb.rng = function(a, b) return a end -- hit
hb:performMove(hb.player, hb.enemy, { id = "TACKLE", pp = 10 })
check(hasAnim(hb), "a landing TACKLE still queues its move animation")
end
-- #14: EXP.ALL second pass inherits the participant divisor and skips
-- fainted mons
do
@@ -859,6 +931,61 @@ do
Game.save.inventory.EXP_ALL = nil
end
-- these build throwaway battles/mons, which normally roll DVs off the
-- ambient love.math.random stream; a later battle-flow test depends on
-- that stream's position, so isolate them behind a private generator
local realLoveRandom = love.math.random
local isoN = 0
love.math.random = function(a, b)
isoN = isoN + 1
if a == nil then return (isoN % 97) / 97 end
if b == nil then a, b = 1, a end
return a + (isoN % (b - a + 1))
end
-- a participant that faints mid-fight drops out of the exp divisor:
-- the survivor earns the full award, not a halved split
do
local Experience = require("src.battle.Experience")
local mon1 = Pokemon.new(Data, "BULBASAUR", 30)
local mon2 = Pokemon.new(Data, "PIDGEY", 30)
Game.save.party = { mon1, mon2 }
local fb = BattleState.newWild(Game, "RATTATA", 10)
fb.participants = { [mon1] = true, [mon2] = true }
mon2.hp = 0
fb:onFaint({ mon = mon2, isPlayer = true, name = "PIDGEY" })
check(fb.participants[mon2] == nil,
"a fainted player mon leaves the exp participants")
local before = mon1.exp
fb:enemyMonFainted()
eq(mon1.exp - before,
Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false),
"survivor gets the full award once the fainted teammate leaves the divisor")
end
-- a poisoned/burned mon that just KO'd its opponent skips its own
-- residual damage for the turn (HandlePoisonBurnLeechSeed is bypassed
-- when the move faints the target)
do
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local kb = BattleState.newWild(Game, "RATTATA", 5)
kb.player.mon.status = "PSN"
kb.enemy.mon.hp = 0 -- the opponent was already knocked out this turn
local hpBefore = kb.player.mon.hp
kb:endOfTurn()
eq(kb.player.mon.hp, hpBefore,
"no residual poison on the turn the poisoned mon lands the KO")
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local lb = BattleState.newWild(Game, "RATTATA", 5)
lb.player.mon.status = "PSN"
local live = lb.player.mon.hp
lb:endOfTurn()
check(lb.player.mon.hp < live, "poison still ticks while the opponent lives")
end
love.math.random = realLoveRandom
Game.save.party = savedParty
end
@@ -1073,6 +1200,20 @@ do
tb.trainer.name)),
"trainer defeat uses '<PLAYER> defeated <TRAINER>!'")
-- pret GetTrainerName_: rival classes show wRivalName, not "RIVAL1"
do
local savedRival = Game.save.player.rival
Game.save.player.rival = "GARY"
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) }
local rb = BattleState.newTrainer(Game, "OPP_RIVAL1", 1)
eq(rb.trainer.name, "GARY", "rival battle uses saved rival name")
check(rb.introText:find("GARY", 1, true),
"rival intro wants-to-fight uses rival name")
check(Data.trainers.OPP_RIVAL1.name == "RIVAL1",
"shared trainer data keeps the RIVAL1 placeholder")
Game.save.player.rival = savedRival
end
-- PartyMenu onCancel fires when backing out without a pick
do
local PartyMenu = require("src.ui.PartyMenu")
@@ -1940,14 +2081,16 @@ do
-- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box
-- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like
-- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed),
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX cycle
-- their display modes.
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX / VIDEO MODE
-- cycle their display modes.
do
local OptionsMenu = require("src.ui.OptionsMenu")
local OInput = require("src.core.Input")
local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
SD.saveOptions(SD.defaultOptions())
@@ -1966,6 +2109,8 @@ do
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
eq(og.save.options.tilt, 0, "new saves default TILT to OFF")
eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF")
eq(og.save.options.videoMode, "windowed",
"new saves default VIDEO MODE to WINDOWED")
eq(om.scroll, 0, "options viewport starts at the top")
for _ = 1, 4 do press("down") end
eq(om.index, 5, "cursor reaches MUSIC VOL")
@@ -1986,10 +2131,7 @@ do
press("down")
eq(om.index, 8, "cursor reaches COLORS")
press("a")
eq(og.save.options.colors, "og", "A cycles COLORS to OG")
eq(PaletteFX.mode, "og", "PaletteFX mode tracks COLORS option")
for _ = 1, 4 do press("a") end
eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC")
press("down")
eq(om.index, 9, "cursor reaches TILT")
press("a")
@@ -2005,25 +2147,43 @@ do
for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down")
eq(om.index, 11, "cursor reaches MODS")
eq(om.index, 11, "cursor reaches VIDEO MODE")
press("a")
eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS")
press("a")
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 12, "cursor reaches CONTROLS")
eq(om.index, 12, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
-- speed (20X went in for the bot runs) otherwise fails this as a wrap
-- bug when the cycling is fine and the row is simply one longer.
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 13, "CANCEL stays the fixed final row")
eq(om.scroll, 8, "CANCEL keeps the last option boxes on screen")
eq(om.index, 13, "cursor reaches MODS")
press("down")
eq(om.index, 14, "cursor reaches CONTROLS")
press("down")
eq(om.index, 15, "CANCEL stays the fixed final row")
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 13, "up from the top wraps to CANCEL")
eq(om2.scroll, 8, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 15, "up from the top wraps to CANCEL")
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)
PaletteFX.applyOptions(og.save.options)
Tilt.applyOptions(og.save.options)
GBCFX.applyOptions(og.save.options)
VideoMode.applyOptions(og.save.options)
end
end
@@ -2418,6 +2578,9 @@ do
check(status == 0 or status == true, "save_editor_mod_tests suite")
end
-- ---------------------------------------------- input hold regressions
runSuites({ "tests/input_hold_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity
+196
View File
@@ -0,0 +1,196 @@
-- PokeBotBad route converter.
--
-- luajit tools/botconv/convert.lua <PokeBotBad checkout> [out.lua]
--
-- Reads PokeBotBad's data/red/paths.lua and emits a route data file for
-- the recomp: numeric pokered map ids become our string map names, tile
-- waypoints pass through unchanged (the coordinate systems are
-- identical), and every strategy/control step is classified by
-- table.lua into a generic op, a battle, a manual stub, or nothing.
--
-- The output is data, not code. tests/drivers/route.lua interprets it
-- against the live Game object. Anything table.lua does not cover is a
-- hard error -- the converter never silently drops a step.
local BOT = ... or arg[1]
assert(BOT, "usage: luajit tools/botconv/convert.lua <PokeBotBad checkout> [out]")
local OUT = arg[2] or "tests/drivers/bot_route.lua"
local TBL = dofile("tools/botconv/table.lua")
local mapOrder = dofile("data/generated/constants.lua").mapOrder
setmetatable(_G, { __index = function() return 0 end }) -- speedrun-only globals
local Paths = assert(loadfile(BOT .. "/data/red/paths.lua"))()
local stats = {
waypoint = 0, dropped = 0, battle = 0, verb = 0, manual = 0, control = 0,
}
local manualSeen, unknown = {}, {}
local function face(v) return TBL.face[v] or v end
-- Translate one {s=...} / {c=...} step into an op, or nil to drop it.
local function convertStep(step)
if step.s then
local name = step.s
if TBL.drop[name] then
stats.dropped = stats.dropped + 1
return nil
end
if TBL.battle[name] then
stats.battle = stats.battle + 1
return { op = "battle", face = step.dir and face(step.dir) or nil }
end
if TBL.manual[name] then
stats.manual = stats.manual + 1
manualSeen[name] = (manualSeen[name] or 0) + 1
return { op = "manual", name = name }
end
local v = TBL.verb[name]
if v then
stats.verb = stats.verb + 1
local out = { op = v.op }
for k, val in pairs(v.fixed or {}) do out[k] = val end
for botKey, ourKey in pairs(v.params or {}) do
local val = step[botKey]
if val ~= nil then
out[ourKey] = (ourKey == "face") and face(val) or val
end
end
return out
end
unknown[#unknown + 1] = "s=" .. name
return nil
end
local name = step.c
if TBL.controlDrop[name] then
stats.dropped = stats.dropped + 1
return nil
end
local cv = TBL.controlVerb[name]
if cv then
stats.control = stats.control + 1
local out = { op = cv.op }
if cv.mon ~= nil then out.mon = cv.mon end
return out
end
unknown[#unknown + 1] = "c=" .. name
return nil
end
-- ---------------------------------------------------------------------
-- PokeBotBad advances its path list sequentially whenever the map changes
-- (action/walk.lua:91-93) and only consults entry[1] when re-syncing after
-- a reset, so a wrong map id there never breaks its run -- and some are
-- wrong. Fix the ones we have verified rather than importing the typo:
-- entry 2 is labelled "Red's house" but carries 39 (BLUES_HOUSE); its
-- waypoints continue from Red's stairs, so it is REDS_HOUSE_1F (37).
local MAP_FIXUPS = { [2] = 37 }
local route = {}
for idx, entry in ipairs(Paths) do
local mapId = MAP_FIXUPS[idx] or entry[1]
local mapName = mapOrder[mapId + 1]
assert(mapName, ("unmapped pokered map id %d"):format(mapId))
local steps = {}
for j = 2, #entry do
local step = entry[j]
if step.s or step.c then
local op = convertStep(step)
if op then steps[#steps + 1] = op end
else
-- a bare {x,y} waypoint. Negative coords are the route's idiom for
-- "walk off the map edge into the connecting map"; the runtime
-- clamps into the connection rather than pathfinding to a cell that
-- does not exist.
stats.waypoint = stats.waypoint + 1
steps[#steps + 1] = { op = "goto", x = step[1], y = step[2] }
end
end
route[#route + 1] = { map = mapName, steps = steps }
end
if #unknown > 0 then
io.stderr:write("unclassified steps (add them to tools/botconv/table.lua):\n")
local seen = {}
for _, u in ipairs(unknown) do
if not seen[u] then seen[u] = true; io.stderr:write(" " .. u .. "\n") end
end
os.exit(1)
end
-- ---------------------------------------------------------------------
-- emit
-- ---------------------------------------------------------------------
-- Param values are scalars, or a list of candidate strings -- the route
-- writes poke={"oddish","paras"} for "teach this to whichever of these we
-- actually caught". Anything else is a bug in table.lua's param mapping
-- (an unquoted WRAM address, say) and must not reach the output as a
-- stringified pointer.
local function quote(v)
local t = type(v)
if t == "string" then return ("%q"):format(v) end
if t == "number" or t == "boolean" then return tostring(v) end
if t == "table" then
local parts = {}
for i, item in ipairs(v) do
assert(type(item) == "string",
("list param element %d is %s, expected string"):format(i, type(item)))
parts[i] = ("%q"):format(item)
end
assert(#parts > 0, "empty list param")
return "{ " .. table.concat(parts, ", ") .. " }"
end
error("unserializable param value of type " .. t)
end
local buf = {
"-- Generated by tools/botconv/convert.lua from PokeBotBad's any% route.",
"-- Do not edit; edit tools/botconv/table.lua and regenerate.",
"return {",
}
for _, seg in ipairs(route) do
buf[#buf + 1] = (" { map = %q, steps = {"):format(seg.map)
for _, s in ipairs(seg.steps) do
local parts = {}
local keys = {}
for k in pairs(s) do if k ~= "op" then keys[#keys + 1] = k end end
table.sort(keys)
for _, k in ipairs(keys) do
parts[#parts + 1] = ("%s = %s"):format(k, quote(s[k]))
end
buf[#buf + 1] = (" { op = %q%s },"):format(
s.op, #parts > 0 and (", " .. table.concat(parts, ", ")) or "")
end
buf[#buf + 1] = " } },"
end
buf[#buf + 1] = "}"
local fh = assert(io.open(OUT, "w"))
fh:write(table.concat(buf, "\n"), "\n")
fh:close()
-- ---------------------------------------------------------------------
-- coverage report
-- ---------------------------------------------------------------------
print(("wrote %s (%d map segments)"):format(OUT, #route))
print((" goto %4d"):format(stats.waypoint))
print((" battle %4d"):format(stats.battle))
print((" verb %4d"):format(stats.verb))
print((" control %4d"):format(stats.control))
print((" dropped %4d (speedrun / streaming only)"):format(stats.dropped))
print((" manual %4d <- runtime handlers required"):format(stats.manual))
local names = {}
for n in pairs(manualSeen) do names[#names + 1] = n end
table.sort(names)
print("\nhandlers tests/drivers/route.lua must implement:")
for _, n in ipairs(names) do
print((" %-20s x%d"):format(n, manualSeen[n]))
end
+123
View File
@@ -0,0 +1,123 @@
-- Inventory pass for the PokeBotBad route converter.
--
-- Reads PokeBotBad's data/red/paths.lua and reports every distinct step
-- shape it contains, so the conversion table is driven by what the route
-- actually uses rather than by guesswork.
--
-- luajit tools/botconv/inventory.lua /path/to/PokeBotBad
--
-- Route entries look like:
-- { <mapId>, {x,y}, {s="talk",dir="Up"}, {c="a",a="Brock's Gym"}, ... }
-- entry[1] is a numeric pokered map id; the rest are steps.
local botRoot = ... or arg[1]
assert(botRoot, "usage: luajit tools/botconv/inventory.lua <PokeBotBad checkout>")
-- paths.lua reads a few speedrun-only globals (client.speedmode targets).
-- They are dropped by the converter, so any value will do.
setmetatable(_G, { __index = function() return 0 end })
local pathsFile = botRoot .. "/data/red/paths.lua"
local Paths = assert(loadfile(pathsFile))()
local mapOrder = dofile("data/generated/constants.lua").mapOrder
local strategies, controls = {}, {}
local counts = { waypoint = 0, strategy = 0, control = 0, unknown = 0 }
local unknownMaps, sections = {}, {}
local function note(bucket, name, step, section)
local rec = bucket[name]
if not rec then
rec = { n = 0, params = {}, sections = {} }
bucket[name] = rec
end
rec.n = rec.n + 1
rec.sections[section] = true
for k in pairs(step) do
if k ~= "s" and k ~= "c" then rec.params[k] = true end
end
end
-- Section headers are comments, so recover them by scanning the source and
-- counting which route entry each header precedes.
local function sectionNames()
local names, idx, fh = {}, 0, assert(io.open(pathsFile, "r"))
local current = "0: INTRO"
for line in fh:lines() do
local header = line:match("^%-%-%s*(%d+:%s*.+)$")
if header then current = header end
if line:match("^%s*{%s*%-?%d+%s*,") then
idx = idx + 1
names[idx] = current
end
end
fh:close()
return names
end
local names = sectionNames()
for i, entry in ipairs(Paths) do
local mapId = entry[1]
local section = names[i] or "?"
sections[section] = true
local mapName = mapOrder[mapId + 1]
if not mapName then unknownMaps[mapId] = true end
for j = 2, #entry do
local step = entry[j]
if type(step) ~= "table" then
counts.unknown = counts.unknown + 1
elseif step.s then
counts.strategy = counts.strategy + 1
note(strategies, step.s, step, section)
elseif step.c then
counts.control = counts.control + 1
note(controls, step.c, step, section)
elseif type(step[1]) == "number" and type(step[2]) == "number" then
counts.waypoint = counts.waypoint + 1
else
counts.unknown = counts.unknown + 1
end
end
end
local function dump(title, bucket)
local keys = {}
for k in pairs(bucket) do keys[#keys + 1] = k end
table.sort(keys, function(a, b)
if bucket[a].n ~= bucket[b].n then return bucket[a].n > bucket[b].n end
return a < b
end)
print(("\n=== %s (%d distinct) ==="):format(title, #keys))
for _, k in ipairs(keys) do
local rec = bucket[k]
local params = {}
for p in pairs(rec.params) do params[#params + 1] = p end
table.sort(params)
local nsec = 0
for _ in pairs(rec.sections) do nsec = nsec + 1 end
print(("%-28s x%-4d sections:%-3d %s"):format(
k, rec.n, nsec,
#params > 0 and ("{" .. table.concat(params, ",") .. "}") or ""))
end
end
print(("route entries: %d sections: %d"):format(#Paths, (function()
local n = 0; for _ in pairs(sections) do n = n + 1 end; return n
end)()))
print(("waypoints:%d strategies:%d controls:%d unknown:%d"):format(
counts.waypoint, counts.strategy, counts.control, counts.unknown))
local bad = {}
for id in pairs(unknownMaps) do bad[#bad + 1] = id end
if #bad > 0 then
table.sort(bad)
print("UNMAPPED MAP IDS: " .. table.concat(bad, ", "))
else
print("all map ids resolve against data/generated/constants.lua mapOrder")
end
dump("STRATEGIES", strategies)
dump("CONTROLS", controls)
+187
View File
@@ -0,0 +1,187 @@
-- Classification table for the PokeBotBad route converter.
--
-- Every strategy/control name the route references maps to exactly one
-- bucket. convert.lua errors on anything missing, so this file is the
-- single place that decides what a PokeBotBad step becomes.
--
-- DROP speedrun or streaming only; no gameplay effect. Removing it
-- cannot make the run unwinnable, only slower.
-- BATTLE resolve the fight with the generic battle handler. We are not
-- optimizing turns, so every named fight collapses to one op.
-- VERB a generic parameterized action; params are rewritten below.
-- MANUAL progression-critical and not inferable from the route data.
-- Emitted as a stub op the runtime must implement, and listed in
-- the coverage report.
local T = {}
-- ---------------------------------------------------------------------
-- DROP
-- ---------------------------------------------------------------------
-- Timer splits, Twitch/LiveSplit bridge chatter, emulator speed control.
-- Stat-boost item pickups (carbos/rare candy) exist to hit damage
-- breakpoints on an optimal route; a bot that grinds normally outlevels
-- the need. "redbar" deliberately parks a mon at low HP for the Gen 1
-- low-HP speed trick and is actively harmful when not speedrunning.
-- "dodge*" routes around trainer sight lines to skip fights -- we let
-- those trainers engage and the generic battle handler takes them.
local DROP = {
"split", "splitBrock", "changeSpeed", "battleModeSet", "guess",
"tweetBrock", "tweetMisty", "tweetSurge", "tweetVictoryRoad",
"reportMtMoon", "announceMachop", "announceOddish", "announceVenonat",
"epicCutscene", "centerSkip", "jingleSkip",
"dodgeCerulean", "dodgePalletBoy", "dodgeDepartment", "dodgeGirl",
"dodgeViridianOldMan",
"redbarCubone", "redbarMankey",
"cinnabarCarbos", "safariCarbos", "silphCarbos",
"drivebyRareCandy", "rareCandyEarly", "rareCandyGiovanni",
"tossInSafari", "tossInVictoryRoad",
"swapXSpecials", "swapXSpeeds",
-- pre-emptive heals sized to an optimal route; the runtime's own
-- "heal when below threshold" rule supersedes all of them.
"potionBeforeMisty", "potionBeforeCocoons", "potionBeforeHypno",
"potionBeforeLorelei", "potionBeforeRaticate", "potionBeforeRocket",
"potionBeforeShorts", "potionBeforeSurge", "potionForMankey",
"extraFullRestore", "checkEther", "checkGiovanni",
-- turn-by-turn battle tactics, superseded by the generic battle AI
"thunderboltFirst", "fourTurnThrash", "thrashGeodude", "rivalSandAttack",
"swapThrash", "fightGiovanniMachoke", "fightSilphMachoke",
}
-- ---------------------------------------------------------------------
-- BATTLE
-- ---------------------------------------------------------------------
-- Named fights. Each becomes {op="battle"} -- walk in, fight until the
-- battle state pops. Rival/gym/E4 fights are all the same op; the route
-- has already put us in front of the right trainer.
local BATTLE = {
"fightBrock", "fightMisty", "fightSurge", "fightErika", "fightKoga",
"fightGiovanni", "fightSilphGiovanni", "fightBulbasaur", "fightMetapod",
"fightWeedle", "fightGrimer", "fightHypno", "fightX",
"lorelei", "bruno", "agatha", "lance", "blue", "champion",
"viridianRival", "lavenderRival", "silphRival",
"bugCatcher", "shortsKid", "digFight", "waitToFight",
"hornAttackCaterpie", "catchFlierBackup",
}
-- NOTE: squirtleIChooseYou is NOT here. Despite sitting between two fights
-- in the route it is the starter pick -- walk to the ball, press A, accept
-- the prompt -- and is classified as a talk below.
-- ---------------------------------------------------------------------
-- VERB
-- ---------------------------------------------------------------------
-- name -> { op, params = { botKey = ourKey } }
-- Params not listed are dropped. `face` values are rewritten from
-- PokeBotBad's "Up"/"Down"/"Left"/"Right" to our "up"/"down"/"left"/"right".
local VERB = {
talk = { op = "talk", params = { dir = "face" } },
waitToTalk = { op = "talk", params = { dir = "face" } },
interact = { op = "talk", params = { dir = "face" } },
dialogue = { op = "talk", params = { dir = "face", decline = "decline" } },
take = { op = "pickup", params = { dir = "face" } },
grabAntidote = { op = "pickup" },
grabForestPotion = { op = "pickup" },
grabMaxEther = { op = "pickup" },
grabTreePotion = { op = "pickup" },
bicycle = { op = "bike" },
procureBicycle = { op = "talk" },
-- the starter pick: face the ball, A, accept the prompt
squirtleIChooseYou = { op = "talk" },
fly = { op = "fly", params = { dest = "dest", map = "map" } },
push = { op = "push", params = { dir = "face", x = "x", y = "y" } },
teach = { op = "teach", params = { move = "move", poke = "mon", replace = "replace" } },
-- these two name the move rather than passing it as a param
teachThrash = { op = "teach", fixed = { move = "thrash" } },
learnThrash = { op = "teach", fixed = { move = "thrash" } },
swapMove = { op = "swapMove", params = { move = "move", to = "to" } },
swap = { op = "swapItem", params = { item = "item", dest = "dest" } },
item = { op = "useItem", params = { item = "item", poke = "mon", all = "all" } },
potion = { op = "heal", params = { hp = "hp", full = "full" } },
elixer = { op = "useItem", params = { move = "move" } },
ether = { op = "useItem", params = { max = "max" } },
hikerElixer = { op = "useItem" },
lassEther = { op = "useItem" },
undergroundElixer = { op = "useItem" },
healParalysis = { op = "heal" },
-- shops: the route knows the location, the runtime knows the list
shopPewterMart = { op = "shop", fixed = { list = "pewter" } },
shopViridianPokeballs = { op = "shop", fixed = { list = "viridianBalls" } },
shopVermilionMart = { op = "shop", fixed = { list = "vermilion" } },
shopRepels = { op = "shop", fixed = { list = "repels" } },
shopTM07 = { op = "shop", fixed = { list = "tm07" } },
shopPokeDoll = { op = "shop", fixed = { list = "pokeDoll" } },
shopVending = { op = "shop", fixed = { list = "vending" } },
shopExtraWater = { op = "shop", fixed = { list = "water" } },
shopBuffs = { op = "shop", fixed = { list = "buffs" } },
-- Not shops, despite the names. prepareForBlue/prepareForLance are
-- thin wrappers over strategyFunctions.potion (they fire in Lance's
-- and Agatha's rooms, where no mart exists). equipForBrock is a
-- level-8 reset gate plus a cure-poison, and the gate is speedrun-only
-- -- a bot that grinds normally arrives overlevelled.
prepareForBlue = { op = "heal", fixed = { full = true } },
prepareForLance = { op = "heal", fixed = { full = true } },
equipForBrock = { op = "heal", fixed = { status = true } },
-- "skill" is the route's field-move verb (cut/surf/strength/dig/flash)
skill = { op = "fieldMove", params = { move = "move", dir = "face",
x = "x", y = "y", map = "map" } },
}
-- ---------------------------------------------------------------------
-- MANUAL
-- ---------------------------------------------------------------------
-- Progression gates. A generic verb cannot infer these from route data:
-- they involve party composition, puzzle state, or menus the route only
-- names. Each emits {op="manual", name=...} and the runtime dispatches to
-- a hand-written handler keyed by name.
local MANUAL = {
"catchNidoran", "catchOddish", -- party composition the route assumes
"evolveNidoking", "evolveNidorino", -- level/stone gating
"trashcans", -- Surge gym can-search puzzle
"depositPokemon", -- PC box menu
"deptElevator", "silphElevator", -- elevator floor menus
"giveWater", -- Saffron guard gate
"playPokeFlute", -- Snorlax
"pokeDoll", -- Lavender Rocket blocker
"talkToBill", -- S.S. Ticket gate
"exitForest", -- Viridian Forest exit routing
"trainerSightSkip",
}
-- ---------------------------------------------------------------------
-- CONTROLS ({c="..."} steps)
-- ---------------------------------------------------------------------
-- Almost all of these are reset conditions or run telemetry. {c="a"} is
-- Bridge.chat -- Twitch commentary, 50 of the 98 control calls.
local CONTROL_DROP = {
"a", "encounters", "trackEncounters", "allowDeath", "pp", "thrash",
"moon1Exp", "moon2Exp", "moon3Exp", "startMtMoon",
"nidoranBackupExp", "viridianBackupExp", "viridianExp",
}
-- Catch permission toggles do affect what the bot ends up with.
local CONTROL_VERB = {
catchNidoran = { op = "allowCatch", mon = "nidoran" },
catchOddish = { op = "allowCatch", mon = "oddish" },
catchParas = { op = "allowCatch", mon = "paras" },
catchFlier = { op = "allowCatch", mon = "flier" },
disableCatch = { op = "allowCatch", mon = false },
potion = { op = "heal" },
}
-- ---------------------------------------------------------------------
local function set(list)
local t = {}
for _, name in ipairs(list) do t[name] = true end
return t
end
T.drop = set(DROP)
T.battle = set(BATTLE)
T.verb = VERB
T.manual = set(MANUAL)
T.controlDrop = set(CONTROL_DROP)
T.controlVerb = CONTROL_VERB
T.face = { Up = "up", Down = "down", Left = "left", Right = "right" }
return T
+54 -2
View File
@@ -116,7 +116,8 @@ def parse_hidden_events(pokered):
Itemfinder detects), HiddenCoins (Game Corner floor coins) and
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
Also collects the engine text hooks that the port implements natively:
OpenPokemonCenterPC, PrintBenchGuyText, GymStatues and the Vermilion
OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the
bedroom), PrintBenchGuyText, GymStatues and the Vermilion
Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the
fourth macro argument is the facing direction required to trigger the
event, except GymTrashScript where it is the can index.
@@ -153,7 +154,7 @@ def parse_hidden_events(pokered):
elif arg == "SLOTS_SOMEONESKEYS":
state = "keys"
slots.setdefault(current, []).append({"x": x, "y": y, "state": state})
elif func == "OpenPokemonCenterPC":
elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC":
extras["pcTiles"].setdefault(current, []).append(
{"x": x, "y": y, "facing": DIRS.get(arg, arg)})
elif func == "PrintBenchGuyText":
@@ -303,12 +304,63 @@ def parse_card_key_doors(pokered):
util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)")
if n_doors != 22 or len(maps) != 10:
util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}")
# closedDoors is hand-ported, not extracted: no retail .blk layout places
# a closed-door block at any of the coordinates above (the feature is
# unused/cut in the original game), so there is no ROM or disassembly
# source to derive this from. It restores that cut content by stamping
# facility.bst blocks 0x54/0x5f (2F-10F) or interior.bst block 0x20
# (11F) over each door on map load, opened by that door's
# EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. Kept in sync by hand with
# tools/rom_manifest.json's field.cardKeyDoors.closedDoors.
closed_doors = {
"SILPH_CO_2F": [
{"block": 0x54, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_3F": [
{"block": 0x5f, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x5f, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_4F": [
{"block": 0x54, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_5F": [
{"block": 0x5f, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x5f, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x5f, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 0x0e},
],
"SILPH_CO_6F": [
{"block": 0x5f, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_7F": [
{"block": 0x54, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x54, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 0x0e},
],
"SILPH_CO_8F": [
{"block": 0x5f, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_9F": [
{"block": 0x5f, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x54, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 0x0e},
{"block": 0x5f, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 0x0e},
],
"SILPH_CO_10F": [
{"block": 0x54, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_11F": [
{"block": 0x20, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 0x03},
],
}
return {
"maps": maps, # maps where the engine checks for doors
"doors": doors, # tile coords; block coord = floor(coord/2)
"doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset)
"openBlock": 0x0e, # block written over the door's block
"silphCo11F": {"doorTile": 0x5e, "openBlock": 0x03},
"closedDoors": closed_doors,
}

Some files were not shown because too many files have changed in this diff Show More