mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c2a5a4220 | |||
| f64666c6ce | |||
| 3f4aaccbf5 | |||
| 20ad4e6c41 | |||
| 60b3cebdce | |||
| 9468ffccdf | |||
| 948ae8abbd | |||
| cb22684c2a | |||
| 94be169e35 | |||
| 36188ef18a | |||
| 7a1b4e5c45 | |||
| eba6e8d752 | |||
| ec5a72b365 | |||
| 9325d56046 | |||
| d650e605b0 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -206,7 +214,40 @@ 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" \
|
||||
|
||||
@@ -55,6 +55,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 +84,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?
|
||||
[](https://buymeacoffee.com/bryanthaboi)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+51
-39
@@ -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.
|
||||
@@ -58,33 +58,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 +107,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 +120,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 +180,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" })
|
||||
|
||||
+134
-36
@@ -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,
|
||||
}
|
||||
|
||||
@@ -104,7 +135,7 @@ M.BILLS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BILLSHOUSE_BILL_POKEMON = {
|
||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
|
||||
{ "jump_if_true", 11 }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 2
|
||||
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
|
||||
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
|
||||
-- the cell-separator PC throws its switch
|
||||
@@ -118,8 +149,21 @@ M.BILLS_HOUSE = {
|
||||
{ "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
|
||||
-- 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 -- which is
|
||||
-- exactly what stranded the bot after it beat Misty.
|
||||
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
|
||||
-- CeruleanHideRocket), so either route opens the path.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11
|
||||
{ "jump", 14 }, -- 12
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -208,7 +252,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 +397,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
|
||||
|
||||
+79
-14
@@ -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,11 @@ 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._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!"), done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
|
||||
@@ -317,6 +327,27 @@ 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._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!")))
|
||||
return true
|
||||
end
|
||||
local back
|
||||
if horizontal then
|
||||
back = ow.player.facing == "left" and "right" or "left"
|
||||
@@ -386,6 +417,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 +445,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 +460,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 +468,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"),
|
||||
|
||||
+28
-14
@@ -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))
|
||||
|
||||
@@ -633,4 +633,94 @@ 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,
|
||||
}
|
||||
|
||||
return M
|
||||
|
||||
+10
-1
@@ -360,7 +360,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 +368,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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -33,6 +50,8 @@ function love.load(args)
|
||||
editorMode = 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 +86,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)
|
||||
@@ -191,6 +223,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)
|
||||
|
||||
+11
-18
@@ -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
|
||||
|
||||
|
||||
+138
-21
@@ -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)))
|
||||
@@ -1547,6 +1564,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 +2187,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 +2248,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
|
||||
@@ -2240,10 +2295,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 +2315,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 +2355,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
|
||||
@@ -2558,16 +2624,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
|
||||
@@ -3026,6 +3129,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 +3459,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 +3481,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 +3492,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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
+39
-3
@@ -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
|
||||
@@ -363,6 +398,7 @@ 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)
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
|
||||
@@ -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
|
||||
+35
-5
@@ -41,10 +41,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 = {},
|
||||
@@ -516,8 +520,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 +631,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 +679,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 = {},
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
+200
-14
@@ -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,181 @@ 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
|
||||
|
||||
-- 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 },
|
||||
}
|
||||
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
|
||||
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+)%]"))
|
||||
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 +335,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 +372,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 +386,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 +397,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"
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -46,13 +88,26 @@ 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
|
||||
-- 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
|
||||
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)
|
||||
love.graphics.draw(image, self.frames[0], x, y)
|
||||
return
|
||||
end
|
||||
local frame = (self.def.walker and walkPhase == 1)
|
||||
@@ -65,9 +120,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
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)
|
||||
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
|
||||
else
|
||||
love.graphics.draw(self.image, quad, x, y)
|
||||
love.graphics.draw(image, quad, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+133
-12
@@ -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,82 @@ 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 out = love.image.newImageData(iw, ih)
|
||||
local tileColors = {}
|
||||
for t = 0, (iw / 8) * (ih / 8) - 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
|
||||
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 +419,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
|
||||
@@ -487,7 +605,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
|
||||
|
||||
@@ -225,7 +225,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
|
||||
@@ -523,11 +523,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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+24
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -29,6 +29,49 @@ local function hashSet(list, into)
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -360,10 +360,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 +388,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()
|
||||
@@ -878,8 +900,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 +914,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 +930,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 +956,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 +1048,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))
|
||||
@@ -1501,6 +1532,21 @@ 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)
|
||||
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
||||
or (ts == "GYM" and tile == 0x50)) then
|
||||
return false
|
||||
end
|
||||
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
||||
local block = self.map:blockAt(bx, by)
|
||||
local swap
|
||||
@@ -1586,6 +1632,15 @@ 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)
|
||||
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
||||
or (ts == "GYM" and tile == 0x50)) then
|
||||
return "nothing"
|
||||
end
|
||||
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
||||
local block = self.map:blockAt(bx, by)
|
||||
local swap
|
||||
@@ -1649,7 +1704,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)
|
||||
@@ -1967,7 +2022,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 +2031,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 +2040,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 +2050,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 +2337,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 +2405,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 +2738,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 +2750,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")
|
||||
@@ -2777,6 +2859,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Parity: losing the Oak's Lab starter rival must not black out.
|
||||
-- pret HandlePlayerBlackOut special-cases OPP_RIVAL1 on OAKS_LAB:
|
||||
-- Rival1WinText only, no PlayerBlackedOutText, no warp / half-money.
|
||||
-- OaksLabRivalEndBattleScript then HealParty and continues either way.
|
||||
-- Self-contained; run via `luajit tests/parity_oaks_lab_rival_loss.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 oaks lab rival loss")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function saidBlackout(b)
|
||||
for _, m in ipairs(b.said) do
|
||||
if tostring(m):find("blacked") then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function saidRivalTaunt(b)
|
||||
for _, m in ipairs(b.said) do
|
||||
if tostring(m):find("great or what") then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function rivalBattle(mapId)
|
||||
return {
|
||||
kind = "trainer",
|
||||
oppClass = "OPP_RIVAL1",
|
||||
result = nil,
|
||||
afterQueue = nil,
|
||||
said = {},
|
||||
data = {
|
||||
text = { _Rival1WinText = "{RIVAL}: Yeah! Am\nI great or what?" },
|
||||
},
|
||||
game = {
|
||||
save = {
|
||||
party = { { species = "SQUIRTLE", hp = 0, stats = { hp = 20 } } },
|
||||
player = { name = "RED", rival = "BLUE", map = mapId },
|
||||
},
|
||||
overworld = { map = { id = mapId } },
|
||||
},
|
||||
sayNext = function(self, m) self.said[#self.said + 1] = m end,
|
||||
say = function(self, m) self.said[#self.said + 1] = m end,
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local b = rivalBattle("OAKS_LAB")
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, "lose", "lab rival wipe is still a loss")
|
||||
check(saidRivalTaunt(b), "lab rival wipe shows Rival1WinText")
|
||||
check(not saidBlackout(b), "lab rival wipe does not say blacked out")
|
||||
check(BattleState.isOaksLabStarterRival(b), "oaks-lab starter-rival detector")
|
||||
end
|
||||
|
||||
do
|
||||
local b = rivalBattle("ROUTE_22")
|
||||
BattleState.playerMonFainted(b)
|
||||
eq(b.result, "lose", "route-22 rival wipe is a loss")
|
||||
check(saidRivalTaunt(b), "route-22 rival still shows Rival1WinText")
|
||||
check(saidBlackout(b), "route-22 rival still blacks out")
|
||||
check(not BattleState.isOaksLabStarterRival(b),
|
||||
"route-22 is not the oaks-lab special case")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Parity test, Viridian City's two old men + the Pokédex object swap.
|
||||
--
|
||||
-- pokered has TWO old men on this map (data/maps/objects/ViridianCity.asm):
|
||||
--
|
||||
-- object_event 18, 9, SPRITE_GAMBLER_ASLEEP, STAY, NONE, ..._OLD_MAN_SLEEPY
|
||||
-- object_event 17, 5, SPRITE_GAMBLER, WALK, LEFT_RIGHT, ..._OLD_MAN
|
||||
--
|
||||
-- The sleeper only ever grumbles "private property" and shoves you back
|
||||
-- down; he never wakes, moves or hides. The coffee ask and the catch
|
||||
-- tutorial belong to the walking man, who starts OFF
|
||||
-- (data/maps/toggleable_objects.asm) and is swapped in for the sleeper
|
||||
-- when Oak hands over the Pokédex (scripts/OaksLab.asm:602-606). The
|
||||
-- north corridor is gated on EVENT_GOT_POKEDEX at exactly (19,9)
|
||||
-- (ViridianCityCheckGotPokedexScript), not on either man's visibility.
|
||||
--
|
||||
-- All three of those were wrong at once: the port merged both men into
|
||||
-- the sleeper, never ran the swap (so the walking man stayed hidden for
|
||||
-- the entire game), and gated the corridor on the sleeper being hidden --
|
||||
-- which made talking to him and answering "yes, I'm in a hurry" the only
|
||||
-- way out of Viridian. Self-contained; run via `luajit tests/parity_viridian.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.VIRIDIAN_CITY) then Data:load() end
|
||||
-- The gate builds a real TextBox, which wants a loaded Font atlas we have
|
||||
-- no graphics device for. The hook requires it lazily, so a stub in
|
||||
-- package.loaded is enough to exercise the branch headlessly -- we only
|
||||
-- care that the step was blocked and that a box was pushed, not what it
|
||||
-- rendered (parity_flavor already covers the text labels themselves).
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
||||
}
|
||||
local init = require("data.scripts.init")
|
||||
local S = require("tests.harness").suite("parity viridian")
|
||||
local check = S.check
|
||||
|
||||
local function rowsOf(script)
|
||||
return type(script) == "table" and script or nil
|
||||
end
|
||||
|
||||
-- find the first row whose command is `cmd`; returns index, row
|
||||
local function findRow(rows, cmd, arg2)
|
||||
for i, row in ipairs(rows or {}) do
|
||||
if row[1] == cmd and (arg2 == nil or row[2] == arg2) then return i, row end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (1) the sleeper is text-and-shove only
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local sleepy = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY"))
|
||||
check(sleepy ~= nil, "sleeper resolves to a row-list script")
|
||||
if sleepy then
|
||||
check(findRow(sleepy, "show_text", "_ViridianCityOldManSleepyPrivatePropertyText") ~= nil,
|
||||
"sleeper shows the private-property text")
|
||||
check(findRow(sleepy, "move_player") ~= nil, "sleeper shoves the player back")
|
||||
-- the bugs: his script must NOT own the other man's dialogue, and must
|
||||
-- never hide himself (pokered's HideObject fires from Oak's lab instead)
|
||||
check(findRow(sleepy, "ask") == nil, "sleeper does not ask about coffee")
|
||||
check(findRow(sleepy, "old_man_demo") == nil, "sleeper does not run the catch demo")
|
||||
check(findRow(sleepy, "hide_object") == nil, "sleeper never hides himself")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (2) the walking old man owns the coffee ask + the real catch tutorial
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local oldMan = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN"))
|
||||
-- a Lua function handler here would mean data/scripts/flavor/viridian_city.lua
|
||||
-- (which loads AFTER story.lua) had silently won the talk-table merge back
|
||||
check(oldMan ~= nil, "walking old man resolves to a row-list script, not a handler")
|
||||
if oldMan then
|
||||
local askAt = findRow(oldMan, "ask", "_ViridianCityOldManHadMyCoffeeNowText")
|
||||
check(askAt ~= nil, "walking old man asks the coffee question")
|
||||
check(findRow(oldMan, "old_man_demo") ~= nil, "walking old man runs the catch demo")
|
||||
-- polarity: the question is "Are you in a hurry?", so YES is the refusal
|
||||
-- (ViridianCityOldManText: `and a / jr z, .refused` -- wCurrentMenuItem 0
|
||||
-- is YES). jump_if_true must therefore land on the "Time is money" line.
|
||||
local jAt, jRow = findRow(oldMan, "jump_if_true")
|
||||
check(jAt ~= nil and askAt ~= nil and jAt > askAt, "the yes/no branch follows the ask")
|
||||
if jRow then
|
||||
local target = oldMan[jRow[2]]
|
||||
check(target ~= nil and target[1] == "show_text"
|
||||
and target[2] == "_ViridianCityOldManTimeIsMoneyText",
|
||||
"YES (in a hurry) brushes you off rather than starting the demo")
|
||||
end
|
||||
-- pokered prints this AFTER the demo battle (EndCatchTrainingScript)
|
||||
local demoAt = findRow(oldMan, "old_man_demo")
|
||||
local weakenAt = findRow(oldMan, "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText")
|
||||
check(demoAt and weakenAt and weakenAt > demoAt,
|
||||
"the weaken-the-target line comes after the demo, not before it")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (3) the Pokédex performs the swap (OaksLab.asm:602-606)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local oak1 = rowsOf(init.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1"))
|
||||
check(oak1 ~= nil, "Oak's main script resolves")
|
||||
if oak1 then
|
||||
local dexAt = findRow(oak1, "set_flag", "EVENT_GOT_POKEDEX")
|
||||
check(dexAt ~= nil, "Oak sets EVENT_GOT_POKEDEX")
|
||||
local hideAt = findRow(oak1, "hide_object", "VIRIDIAN_CITY")
|
||||
local showAt = findRow(oak1, "show_object", "VIRIDIAN_CITY")
|
||||
check(hideAt ~= nil, "the Pokédex hides the Viridian sleeper")
|
||||
check(showAt ~= nil, "the Pokédex shows the walking Viridian old man")
|
||||
if hideAt then
|
||||
check(oak1[hideAt][3] == "VIRIDIANCITY_OLD_MAN_SLEEPY", "hides the right object")
|
||||
end
|
||||
if showAt then
|
||||
check(oak1[showAt][3] == "VIRIDIANCITY_OLD_MAN", "shows the right object")
|
||||
end
|
||||
check(dexAt and hideAt and showAt and hideAt > dexAt and showAt > dexAt,
|
||||
"the swap runs on the branch that grants the Pokédex")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (4) every jump target in the touched scripts is in range
|
||||
--
|
||||
-- Inserting the two swap rows renumbered Oak's whole 30-row jump table.
|
||||
-- An off-by-one there is invisible until a branch silently runs the wrong
|
||||
-- line, so check every target lands on a real row (or the end sentinel,
|
||||
-- #rows + 1) across both files.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local JUMPS = { jump = true, jump_if_true = true, jump_if_false = true }
|
||||
local checkedJumps = 0
|
||||
for _, modname in ipairs({ "data.scripts.oaks_lab", "data.scripts.story" }) do
|
||||
local mod = require(modname)
|
||||
-- oaks_lab returns one map's table; story returns { [mapId] = table }
|
||||
local maps = mod.talk and { [modname] = mod } or mod
|
||||
for mapId, m in pairs(maps) do
|
||||
if type(m) == "table" and m.talk then
|
||||
for const, script in pairs(m.talk) do
|
||||
local rows = rowsOf(script)
|
||||
if rows then
|
||||
for i, row in ipairs(rows) do
|
||||
if type(row) == "table" and JUMPS[row[1]] then
|
||||
local t = row[2]
|
||||
checkedJumps = checkedJumps + 1
|
||||
check(type(t) == "number" and t >= 1 and t <= #rows + 1,
|
||||
("%s/%s row %d: %s -> %s in range"):format(
|
||||
mapId, const, i, tostring(row[1]), tostring(t)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check(checkedJumps > 0, "found jump rows to range-check (got " .. checkedJumps .. ")")
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- (5) the corridor gate keys off EVENT_GOT_POKEDEX at (19,9)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local onStep = init.get("VIRIDIAN_CITY").onStep
|
||||
check(type(onStep) == "function", "VIRIDIAN_CITY has an onStep hook")
|
||||
|
||||
local function step(flags, x, y)
|
||||
local pushed = 0
|
||||
local game = {
|
||||
save = { flags = flags, inventory = {}, objectToggles = {} },
|
||||
data = Data,
|
||||
stack = { push = function() pushed = pushed + 1 end },
|
||||
}
|
||||
local ow = { player = {}, scriptMove = function() end }
|
||||
local ok, blocked = pcall(onStep, game, ow, x, y)
|
||||
return ok, blocked, pushed
|
||||
end
|
||||
|
||||
if type(onStep) == "function" then
|
||||
local ok, blocked, pushed = step({}, 19, 9)
|
||||
check(ok, "onStep runs at the gate cell")
|
||||
check(ok and blocked == true, "(19,9) is blocked without the Pokédex")
|
||||
check(ok and pushed == 1, "being blocked shows a text box")
|
||||
|
||||
local ok2, blocked2 = step({ EVENT_GOT_POKEDEX = true }, 19, 9)
|
||||
check(ok2 and blocked2 ~= true, "(19,9) is walkable once you have the Pokédex")
|
||||
|
||||
-- the old port blocked the whole 3-wide corridor (x 17-19, y<=8); pokered
|
||||
-- blocks one cell, and the sleeper/girl bodies do the rest
|
||||
local ok3, blocked3 = step({}, 19, 8)
|
||||
check(ok3 and blocked3 ~= true, "(19,8) north of the gate is not itself gated")
|
||||
local ok4, blocked4 = step({}, 17, 8)
|
||||
check(ok4 and blocked4 ~= true, "(17,8) is not gated (only (19,9) triggers)")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
+116
-8
@@ -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
|
||||
@@ -1073,6 +1145,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 +2026,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 +2054,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")
|
||||
@@ -2005,25 +2095,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+475
-3
@@ -1,6 +1,6 @@
|
||||
"""Extract the Super Game Boy colorization palettes.
|
||||
|
||||
Sources:
|
||||
Sources (vanilla pokered):
|
||||
data/sgb/sgb_palettes.asm SuperPalettes: 4 colors per PAL_* entry,
|
||||
5-bit RGB (IF DEF(_RED) variants are used;
|
||||
this is a Red port)
|
||||
@@ -8,17 +8,60 @@ Sources:
|
||||
in Pokédex order with species names in the
|
||||
line comments
|
||||
|
||||
Sources (pokered-gbc / Red++ SuperPalettes, optional COLORS mode):
|
||||
data/super_palettes.asm multi-line RGB + ; 0xNN: PAL_NAME comments;
|
||||
includes per-species pals under GEN_2_GRAPHICS
|
||||
data/mon_palettes.asm MonsterPalettes: GEN_2 per-species PAL_* ids
|
||||
|
||||
Output: data/generated/palettes.lua
|
||||
palettes[NAME] = { {r,g,b} x4 } with 8-bit components, color 0 first
|
||||
pokemon[SPECIES] = NAME (PAL_ prefix stripped)
|
||||
|
||||
Output: data/palettes_gbc.lua (committed; not wiped by ROM import)
|
||||
same shape, plus per-species palette entries (BULBASAUR, …), plus a
|
||||
`world` table (below) for true overworld tile/roof/sprite coloring.
|
||||
|
||||
The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR /
|
||||
PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor
|
||||
(>= 27 pixels green, >= 10 yellow, else red).
|
||||
|
||||
Sources (pokered-gbc color/**, the real GBC overworld coloring engine):
|
||||
color/data/map_palette_assignments.asm per tileset: 96 tile-graphic-id
|
||||
-> palette-group (0-7) bytes
|
||||
color/data/map_palette_sets.asm per tileset: 8 group -> palette-
|
||||
constant bytes (positional)
|
||||
color/data/map_palettes.asm palette-constant -> 4 RGB colors
|
||||
color/data/roofpalettes.asm per pokered map ID (positional,
|
||||
matches map.def.index): roof
|
||||
2-color override + the labeled
|
||||
RGB blocks it points at
|
||||
color/data/spritepalettes.asm 8 four-color OBJ palettes
|
||||
color/sprites.asm SpritePaletteAssignments: 1-based
|
||||
picture ID -> OBJ palette 0-3, or
|
||||
the "db 4" random-per-instance
|
||||
sentinel (ColorOverworldSprite)
|
||||
|
||||
Output `world` table shape:
|
||||
world.tileGroups[TILESET][tileId 0-95] = group index 0-7
|
||||
world.groupColors[TILESET][group 0-7] = { {r,g,b} x4 }
|
||||
world.roofGroup[TILESET] = the town-overridable group index (OVERWORLD/
|
||||
PLATEAU only)
|
||||
world.roofByMapIndex[mapIndex] = { {r,g,b} x2 } (matches map.def.index)
|
||||
world.spritePalettes[0-7] = { {r,g,b} x4 }
|
||||
world.spriteAssignment[pictureIndex 0-based] = group 0-3 or "random"
|
||||
(pictureIndex matches the N in a sprite def's
|
||||
`source = "ROM:SpriteSheetPointerTable[N]"`)
|
||||
|
||||
The 3 hardcoded Celadon Mart tile-group exceptions and the Route 6/Saffron
|
||||
roof y-split (LoadTilesetPalette / LoadTownPalette control flow, not data)
|
||||
are NOT extracted here -- they live as a small named table in
|
||||
src/render/PaletteFX.lua next to the code that consumes `world`.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from . import util
|
||||
|
||||
@@ -28,6 +71,11 @@ def _scale5(v):
|
||||
return round(int(v) * 255 / 31)
|
||||
|
||||
|
||||
def _rgb8(nums12):
|
||||
return [[_scale5(nums12[i]), _scale5(nums12[i + 1]), _scale5(nums12[i + 2])]
|
||||
for i in range(0, 12, 3)]
|
||||
|
||||
|
||||
def _read_raw(path):
|
||||
"""Raw lines WITH comments (palette / species names live in them)."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
@@ -54,8 +102,7 @@ def extract(pokered, out_dir):
|
||||
if len(nums) != 12:
|
||||
util.die(f"sgb_palettes.asm:{lineno}: expected 12 components, got {len(nums)}")
|
||||
name = m.group(2)
|
||||
palettes[name] = [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])]
|
||||
for i in range(0, 12, 3)]
|
||||
palettes[name] = _rgb8(nums)
|
||||
order.append(name)
|
||||
|
||||
# ---- MonsterPalettes -------------------------------------------------
|
||||
@@ -91,3 +138,428 @@ def extract(pokered, out_dir):
|
||||
header="SGB colorization: 4 8-bit RGB colors per palette (color 0\n"
|
||||
"first) and the per-species palette assignment.")
|
||||
return palettes, mon_pals
|
||||
|
||||
|
||||
def _parse_gbc_super_palettes(path):
|
||||
"""pokered-gbc data/super_palettes.asm: ; 0xNN: PAL_NAME then 4x RGB lines."""
|
||||
palettes = {}
|
||||
order = []
|
||||
pending = None
|
||||
buf = []
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = re.match(r";\s*0x[0-9a-fA-F]+:\s*PAL_(\w+)", s)
|
||||
if m:
|
||||
pending = m.group(1)
|
||||
buf = []
|
||||
continue
|
||||
m = re.match(r"RGB\s+([\d,\s]+)", s)
|
||||
if not m or not pending:
|
||||
continue
|
||||
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
|
||||
buf.extend(nums)
|
||||
if len(buf) < 12:
|
||||
continue
|
||||
if len(buf) != 12:
|
||||
util.die(f"{path}:{lineno}: expected 12 components for PAL_{pending}, "
|
||||
f"got {len(buf)}")
|
||||
palettes[pending] = _rgb8(buf)
|
||||
order.append(pending)
|
||||
pending = None
|
||||
buf = []
|
||||
if pending:
|
||||
util.die(f"{path}: incomplete palette PAL_{pending}")
|
||||
return palettes, order
|
||||
|
||||
|
||||
def _parse_gbc_monster_palettes(path):
|
||||
"""GEN_2_GRAPHICS MonsterPalettes paired with ELSE-branch species comments."""
|
||||
lines = _read_raw(path)
|
||||
# species order from the vanilla ELSE comments (dex order + MISSINGNO)
|
||||
species_order = []
|
||||
in_else = False
|
||||
for _, line in lines:
|
||||
s = line.strip()
|
||||
if s.startswith("ELSE"):
|
||||
in_else = True
|
||||
continue
|
||||
if s.startswith("ENDC"):
|
||||
break
|
||||
if not in_else:
|
||||
continue
|
||||
m = re.match(r"db\s+PAL_\w+\s*;\s*(\w+)", s)
|
||||
if m:
|
||||
species_order.append(m.group(1))
|
||||
|
||||
gen2_pals = []
|
||||
in_gen2 = False
|
||||
for lineno, line in lines:
|
||||
s = line.strip()
|
||||
if s.startswith("MonsterPalettes:"):
|
||||
continue
|
||||
if s.startswith("IF GEN_2_GRAPHICS"):
|
||||
in_gen2 = True
|
||||
continue
|
||||
if s.startswith("ELSE") or s.startswith("TrainerPalettes:"):
|
||||
break
|
||||
if not in_gen2:
|
||||
continue
|
||||
m = re.match(r"db\s+PAL_(\w+)", s)
|
||||
if m:
|
||||
gen2_pals.append(m.group(1))
|
||||
|
||||
if len(species_order) != len(gen2_pals):
|
||||
util.die(f"{path}: GEN_2 MonsterPalettes ({len(gen2_pals)}) != "
|
||||
f"ELSE species comments ({len(species_order)})")
|
||||
mon_pals = {}
|
||||
for species, pal in zip(species_order, gen2_pals):
|
||||
if species != "MISSINGNO":
|
||||
mon_pals[species] = pal
|
||||
return mon_pals
|
||||
|
||||
|
||||
# ---- color/** overworld attribute data ------------------------------------
|
||||
#
|
||||
# Two tiny fixed enums, hand-transcribed rather than parsed: both are
|
||||
# `EQU`/`const` lists that never change (they are the hardware-shaped slot
|
||||
# numbering, not game content), and each uses a different const-declaration
|
||||
# style that isn't worth a bespoke parser for 4-8 entries.
|
||||
# color/data/map_palette_constants.asm:69-78 (`const_value = 0` block)
|
||||
_GROUP_NAMES = ["GRAY", "RED", "GREEN", "BLUE", "YELLOW", "BROWN", "ROOF", "TEXT"]
|
||||
_GROUP_INDEX = {name: i for i, name in enumerate(_GROUP_NAMES)}
|
||||
# color/sprites.asm:15-18 (`SPR_PAL_* EQU n`)
|
||||
_SPR_PAL = {"SPR_PAL_ORANGE": 0, "SPR_PAL_BLUE": 1, "SPR_PAL_GREEN": 2, "SPR_PAL_BROWN": 3}
|
||||
|
||||
|
||||
def _rgbN(nums):
|
||||
return [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])]
|
||||
for i in range(0, len(nums), 3)]
|
||||
|
||||
|
||||
def _parse_rgb_table(path, header_re, n_nums):
|
||||
"""name -> n_nums/3 RGB colors, from blocks of `header_re` + RGB lines
|
||||
(one or more `RGB r,g,b[,r,g,b...]` lines per block, however they're
|
||||
wrapped -- matches both the 1-color-per-line and 4-colors-per-line
|
||||
styles pokered-gbc uses in different files)."""
|
||||
out = {}
|
||||
order = []
|
||||
pending = None
|
||||
buf = []
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = header_re.match(s)
|
||||
if m:
|
||||
if pending:
|
||||
util.die(f"{path}:{lineno}: incomplete RGB block for "
|
||||
f"{pending} ({len(buf)}/{n_nums})")
|
||||
pending = m.group(1)
|
||||
buf = []
|
||||
continue
|
||||
m = re.match(r"RGB\s+([\d,\s]+)", s)
|
||||
if not m or not pending:
|
||||
continue
|
||||
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
|
||||
buf.extend(nums)
|
||||
if len(buf) < n_nums:
|
||||
continue
|
||||
if len(buf) != n_nums:
|
||||
util.die(f"{path}:{lineno}: expected {n_nums} components for "
|
||||
f"{pending}, got {len(buf)}")
|
||||
out[pending] = _rgbN(buf)
|
||||
order.append(pending)
|
||||
pending = None
|
||||
buf = []
|
||||
if pending:
|
||||
util.die(f"{path}: incomplete RGB block for {pending}")
|
||||
return out, order
|
||||
|
||||
|
||||
_TILESET_HEADER_RE = re.compile(r"^;\s*([A-Z][A-Z0-9_]*)\s*$")
|
||||
|
||||
|
||||
def _parse_map_palette_assignments(path):
|
||||
"""color/data/map_palette_assignments.asm: per tileset, 96 tile-graphic
|
||||
palette-group bytes (0-7, symbolic), 16 per line / 6 lines."""
|
||||
tile_groups = {}
|
||||
order = []
|
||||
current = None
|
||||
vals = []
|
||||
|
||||
def flush(lineno):
|
||||
if len(vals) != 96:
|
||||
util.die(f"{path}:{lineno}: {current} has {len(vals)} tile "
|
||||
f"groups (want 96)")
|
||||
tile_groups[current] = vals[:]
|
||||
order.append(current)
|
||||
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = _TILESET_HEADER_RE.match(s)
|
||||
if m:
|
||||
if current:
|
||||
flush(lineno)
|
||||
current, vals = m.group(1), []
|
||||
continue
|
||||
m = re.match(r"db\s+(.*)$", s)
|
||||
if not m or not current:
|
||||
continue
|
||||
for tok in m.group(1).split(","):
|
||||
tok = tok.strip()
|
||||
if tok not in _GROUP_INDEX:
|
||||
util.die(f"{path}:{lineno}: unknown palette group {tok!r}")
|
||||
vals.append(_GROUP_INDEX[tok])
|
||||
if current:
|
||||
flush("EOF")
|
||||
return tile_groups, order
|
||||
|
||||
|
||||
def _parse_map_palette_sets(path):
|
||||
"""color/data/map_palette_sets.asm: per tileset, 8 palette-constant
|
||||
names, positional (index = the palette-group 0-7 above)."""
|
||||
sets = {}
|
||||
order = []
|
||||
current = None
|
||||
vals = []
|
||||
|
||||
def flush(lineno):
|
||||
if len(vals) != 8:
|
||||
util.die(f"{path}:{lineno}: {current} has {len(vals)} palette "
|
||||
f"sets (want 8)")
|
||||
sets[current] = vals[:]
|
||||
order.append(current)
|
||||
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = _TILESET_HEADER_RE.match(s)
|
||||
if m:
|
||||
if current:
|
||||
flush(lineno)
|
||||
current, vals = m.group(1), []
|
||||
continue
|
||||
m = re.match(r"db\s+(\w+)", s)
|
||||
if m and current:
|
||||
vals.append(m.group(1))
|
||||
if current:
|
||||
flush("EOF")
|
||||
return sets, order
|
||||
|
||||
|
||||
def _parse_roof_order(path):
|
||||
"""color/data/roofpalettes.asm's RoofPalettes: dw list, positional by
|
||||
pokered map ID (matches map.def.index in the recomp project)."""
|
||||
names = []
|
||||
in_table = False
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
if s.startswith("RoofPalettes:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
m = re.match(r"dw\s+(\w+)", s)
|
||||
if m:
|
||||
names.append(m.group(1))
|
||||
continue
|
||||
if s == "":
|
||||
continue
|
||||
break # first non-dw, non-blank line (a Label:) ends the table
|
||||
if not names:
|
||||
util.die(f"{path}: RoofPalettes table not found or empty")
|
||||
return names
|
||||
|
||||
|
||||
def _parse_sprite_assignments(path):
|
||||
"""color/sprites.asm SpritePaletteAssignments: 1-based picture ID (from
|
||||
the '; 0xNN: SPRITE_NAME' comment) -> OBJ palette 0-3, or "random" for
|
||||
the "db 4" per-instance sentinel (ColorOverworldSprite). Returned keyed
|
||||
0-based (pictureId - 1), matching a recomp sprite def's
|
||||
`source = "ROM:SpriteSheetPointerTable[N]"` index."""
|
||||
assignments = {}
|
||||
in_table = False
|
||||
pending_id = None
|
||||
header_re = re.compile(r"^;\s*0x([0-9a-fA-F]+):")
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
if s.startswith("SpritePaletteAssignments:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
m = header_re.match(s)
|
||||
if m:
|
||||
pending_id = int(m.group(1), 16)
|
||||
continue
|
||||
m = re.match(r"db\s+(\w+)", s)
|
||||
if m and pending_id is not None:
|
||||
tok = m.group(1)
|
||||
if tok == "4":
|
||||
assignments[pending_id - 1] = "random"
|
||||
elif tok in _SPR_PAL:
|
||||
assignments[pending_id - 1] = _SPR_PAL[tok]
|
||||
else:
|
||||
util.die(f"{path}:{lineno}: unknown sprite palette {tok!r}")
|
||||
pending_id = None
|
||||
continue
|
||||
if s and not s.startswith(";"):
|
||||
break # next label (AnimationTileset1Palettes:) ends the table
|
||||
if not assignments:
|
||||
util.die(f"{path}: SpritePaletteAssignments not found or empty")
|
||||
return assignments
|
||||
|
||||
|
||||
def extract_gbc_world(pokered_gbc):
|
||||
"""pokered-gbc color/** -> the `world` table (see module docstring):
|
||||
real per-tile GBC BG-palette groups, per-town roof overrides, and
|
||||
overworld sprite OBJ palettes, for true overworld parity under
|
||||
COLORS=RED++ (LoadTilesetPalette / LoadTownPalette / ColorOverworldSprite)."""
|
||||
base = os.path.join(pokered_gbc, "color")
|
||||
|
||||
tile_groups, ts_order = _parse_map_palette_assignments(
|
||||
os.path.join(base, "data/map_palette_assignments.asm"))
|
||||
pal_sets, ts_order2 = _parse_map_palette_sets(
|
||||
os.path.join(base, "data/map_palette_sets.asm"))
|
||||
if set(ts_order) != set(ts_order2):
|
||||
util.die("map_palette_assignments.asm / map_palette_sets.asm "
|
||||
"tileset lists disagree")
|
||||
if len(ts_order) < 20:
|
||||
util.die(f"map_palette_assignments.asm: parsed only {len(ts_order)} "
|
||||
f"tilesets (want ~24)")
|
||||
|
||||
map_palettes, _ = _parse_rgb_table(
|
||||
os.path.join(base, "data/map_palettes.asm"),
|
||||
re.compile(r"^;\s*0x[0-9a-fA-F]+:\s*(\w+)\s*$"), 12)
|
||||
|
||||
group_colors = {}
|
||||
for ts in ts_order:
|
||||
colors = []
|
||||
for slot, const in enumerate(pal_sets[ts]):
|
||||
if const not in map_palettes:
|
||||
util.die(f"map_palette_sets.asm: {ts} slot {slot}: unknown "
|
||||
f"palette constant {const}")
|
||||
colors.append(map_palettes[const])
|
||||
group_colors[ts] = colors
|
||||
|
||||
# Only these two tilesets are ever town-roof-swapped
|
||||
# (LoadTilesetPalette: `cp 0` / `cp PLATEAU` before calling
|
||||
# LoadTownPalette) -- the ROOF slot for every other tileset is static.
|
||||
roof_group = {ts: _GROUP_INDEX["ROOF"]
|
||||
for ts in ("OVERWORLD", "PLATEAU") if ts in group_colors}
|
||||
|
||||
roof_path = os.path.join(base, "data/roofpalettes.asm")
|
||||
roof_colors, _ = _parse_rgb_table(
|
||||
roof_path, re.compile(r"^(?!RoofPalettes)(\w+):\s*$"), 6)
|
||||
roof_order = _parse_roof_order(roof_path)
|
||||
roof_by_index = {}
|
||||
for i, name in enumerate(roof_order):
|
||||
if name not in roof_colors:
|
||||
util.die(f"{roof_path}: RoofPalettes references unknown label "
|
||||
f"{name}")
|
||||
roof_by_index[i] = roof_colors[name]
|
||||
|
||||
sprite_path = os.path.join(base, "data/spritepalettes.asm")
|
||||
raw_sprite_pals, _ = _parse_rgb_table(
|
||||
sprite_path, re.compile(r"^;\s*(\d+)\s*$"), 12)
|
||||
sprite_palettes = {int(k): v for k, v in raw_sprite_pals.items()}
|
||||
if len(sprite_palettes) != 8:
|
||||
util.die(f"{sprite_path}: parsed {len(sprite_palettes)} sprite "
|
||||
f"palettes (want 8)")
|
||||
|
||||
sprite_assignment = _parse_sprite_assignments(
|
||||
os.path.join(base, "sprites.asm"))
|
||||
if len(sprite_assignment) != 72:
|
||||
util.die(f"sprites.asm: parsed {len(sprite_assignment)} sprite "
|
||||
f"palette assignments (want 72)")
|
||||
|
||||
tile_groups_out = {ts: {i: v for i, v in enumerate(vals)}
|
||||
for ts, vals in tile_groups.items()}
|
||||
|
||||
return {
|
||||
"tileGroups": tile_groups_out,
|
||||
"groupColors": group_colors,
|
||||
"roofGroup": roof_group,
|
||||
"roofByMapIndex": roof_by_index,
|
||||
"spritePalettes": sprite_palettes,
|
||||
"spriteAssignment": sprite_assignment,
|
||||
}
|
||||
|
||||
|
||||
def extract_gbc(pokered_gbc, out_path):
|
||||
"""Build the Red++ / pokered-gbc SuperPalette pack used by COLORS=RED++."""
|
||||
super_path = os.path.join(pokered_gbc, "data/super_palettes.asm")
|
||||
mon_path = os.path.join(pokered_gbc, "data/mon_palettes.asm")
|
||||
if not os.path.isfile(super_path):
|
||||
util.die(f"missing {super_path}")
|
||||
if not os.path.isfile(mon_path):
|
||||
util.die(f"missing {mon_path}")
|
||||
|
||||
palettes, order = _parse_gbc_super_palettes(super_path)
|
||||
# pret spelling vs Red++ British spelling
|
||||
if "GREYMON" in palettes and "GRAYMON" not in palettes:
|
||||
palettes["GRAYMON"] = palettes["GREYMON"]
|
||||
order.append("GRAYMON")
|
||||
# Overworld still asks for ROUTE / PALLET; Red++ dropped those SGB
|
||||
# entries (color/data handles overworld). Alias to closest town pals.
|
||||
if "ROUTE" not in palettes and "VIRIDIAN" in palettes:
|
||||
palettes["ROUTE"] = palettes["VIRIDIAN"]
|
||||
order.append("ROUTE")
|
||||
if "PALLET" not in palettes and "PEWTER" in palettes:
|
||||
palettes["PALLET"] = palettes["PEWTER"]
|
||||
order.append("PALLET")
|
||||
|
||||
mon_pals = _parse_gbc_monster_palettes(mon_path)
|
||||
if len(mon_pals) != 151:
|
||||
util.die(f"mon_palettes.asm: parsed {len(mon_pals)} species (want 151)")
|
||||
for species, pal in mon_pals.items():
|
||||
if pal not in palettes:
|
||||
util.die(f"mon_palettes.asm: unknown palette PAL_{pal} for {species}")
|
||||
for name in ("MEWMON", "GREENBAR", "YELLOWBAR", "REDBAR", "GRAYMON"):
|
||||
if name not in palettes:
|
||||
util.die(f"super_palettes.asm: PAL_{name} missing")
|
||||
|
||||
world = extract_gbc_world(pokered_gbc)
|
||||
|
||||
# write_lua stamps "Generated by tools/build_data.py"; keep that header
|
||||
# but point the source field at the gbc tree.
|
||||
util.write_lua(
|
||||
out_path,
|
||||
{"source": "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm"
|
||||
" + color/**",
|
||||
"palettes": palettes,
|
||||
"order": order,
|
||||
"pokemon": mon_pals,
|
||||
"world": world},
|
||||
header="Red++ / pokered-gbc SuperPalettes (COLORS=RED++): 4 8-bit RGB\n"
|
||||
"colors per palette (color 0 first), including per-species\n"
|
||||
"pals and the GEN_2 MonsterPalettes assignment, plus `world`\n"
|
||||
"(true overworld tile/roof/sprite GBC coloring -- see this\n"
|
||||
"file's module docstring for its shape).")
|
||||
return palettes, mon_pals
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_van = sub.add_parser("vanilla", help="extract pret/pokered SGB palettes")
|
||||
p_van.add_argument("--pokered", required=True)
|
||||
p_van.add_argument("--out-dir", default="data/generated")
|
||||
|
||||
p_gbc = sub.add_parser("gbc", help="extract pokered-gbc SuperPalettes")
|
||||
p_gbc.add_argument("--pokered-gbc", required=True)
|
||||
p_gbc.add_argument("--out", default="data/palettes_gbc.lua")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "vanilla":
|
||||
extract(args.pokered, args.out_dir)
|
||||
else:
|
||||
extract_gbc(args.pokered_gbc, args.out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python3 -m extract.palettes (from tools/) or python3 tools/extract/palettes.py
|
||||
if __package__ is None:
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
__package__ = "extract"
|
||||
from extract import util as util # noqa: F811
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -42219,6 +42219,12 @@
|
||||
}
|
||||
},
|
||||
"MtMoonB2F": {
|
||||
"1": {
|
||||
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
|
||||
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||
"won": "_MtMoonB2fSuperNerdEachTakeOneText"
|
||||
},
|
||||
"2": {
|
||||
"after": "_MtMoonB2FRocket1AfterBattleText",
|
||||
"battle": "_MtMoonB2FRocket1BattleText",
|
||||
|
||||
Reference in New Issue
Block a user