Compare commits

...

60 Commits

Author SHA1 Message Date
bryanthaboi 043f6b2426 Bugsquashingjuly26 (#264)
* big bug squash

* ok
2026-07-26 14:11:07 -04:00
bryanthaboi f7695308b7 big bug squash (#261) 2026-07-26 13:38:52 -04:00
bryanthaboi a8936e79d6 Add on-screen touch controls and assets 2026-07-25 23:16:15 -04:00
bryanthaboi f6b29e6caa evee fix and some other junk 2026-07-25 15:21:49 -04:00
bryanthaboi 3069b2e2a9 The new experience (#201)
* new launcher and save converts and pipeline

* fixing bugs
2026-07-25 12:36:53 -04:00
bryanthaboi 6625391f76 online play updates and fixes and tweaks and cheeks 2026-07-24 14:36:10 -04:00
bryanthaboi c40dcd6159 Merge branch 'online-play' 2026-07-24 13:31:07 -04:00
bryanthaboi 35f5c9513c Update BattleState.lua 2026-07-24 13:30:54 -04:00
bryanthaboi 5a0f9ab06d Online play (#156)
* new options, and hot keys and readme clean up

* oooooooooooh bazinga
2026-07-24 13:11:40 -04:00
bryanthaboi 13b58dc51e oooooooooooh bazinga 2026-07-24 13:09:00 -04:00
bryanthaboi d872010209 new options, and hot keys and readme clean up (#148) 2026-07-24 10:12:27 -04:00
bryanthaboi 6306975352 new options, and hot keys and readme clean up 2026-07-24 10:10:51 -04:00
bryanthaboi 819150f50f Bugs and stuff (#146)
* main menu scrollable when over 8 items

* buggies

* more buggies

* Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win

* more and more bugs
2026-07-24 09:26:43 -04:00
bryanthaboi bfba1f7bb7 Bugs and stuff (#144)
* main menu scrollable when over 8 items

* buggies

* more buggies

* Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win
2026-07-24 09:06:29 -04:00
bryanthaboi 8278b209b4 Squadhing som bug (#130)
* squashy squash

* squassshheee

CLOSES #128
CLOSES #114
CLOSES #113
CLOSES #111
CLOSES #110
CLOSES #109
CLOSES #107
CLOSES #106
CLOSES #105
CLOSES #90
CLOSES #103

* brock guy fix

CLOSES #39
2026-07-23 16:55:03 -04:00
bryanthaboi 1c4515eaaa squashy squash (#127) 2026-07-23 15:42:54 -04:00
bryanthaboi c04b93ce1c pokecenter fix (#126) 2026-07-23 15:17:20 -04:00
bryanthaboi 2c9970a643 Frame Cap (#124) 2026-07-23 14:35:26 -04:00
bryanthaboi 352a9a0eb8 Merge branch 'graphics-and-stutters-fixes' 2026-07-23 12:20:00 -04:00
bryanthaboi 5533785364 Create gen1recomp_cover.png 2026-07-23 12:19:47 -04:00
bryanthaboi 11f4f5570e Merge pull request #108 from bryanthaboi/graphics-and-stutters-fixes
MAJOR UPDATE
2026-07-23 11:51:59 -04:00
bryanthaboi 6239387643 renamed 2026-07-23 11:49:45 -04:00
bryanthaboi 4a50cb0728 hopefulyl a soltuion to broken end game saves 2026-07-23 11:29:58 -04:00
bryanthaboi 78aa1f3c0a add blue 2026-07-23 11:14:40 -04:00
bryanthaboi 5128b840b1 launcher spruce p2 2026-07-23 09:44:09 -04:00
bryanthaboi da14b7ee3f lauuncher spruce up part 1 2026-07-23 09:13:00 -04:00
bryanthaboi 2dbe21babf map stutter p2 2026-07-23 08:29:04 -04:00
bryanthaboi 7a85862602 graphics and stutters part 1 2026-07-23 07:59:09 -04:00
bryanthaboi 09b12a7cf0 Fixes portable windows and unlocks orientation on android
Fixes portable windows
and unlocks orientation on android
2026-07-22 15:44:14 -04:00
bryanthaboi e2f2b432ff bazinga moment 2 2026-07-22 15:42:19 -04:00
bryanthaboi 8c274d969e Merge pull request #77 from bryanthaboi/portablemodefix
PORTABLE MODE
2026-07-22 14:20:00 -04:00
bryanthaboi 4c7633b56e PORTABLE MODE 2026-07-22 14:18:39 -04:00
bryanthaboi 943ae23e73 Update build.sh 2026-07-22 13:03:53 -04:00
bryanthaboi 839cf19088 bzinga 2026-07-22 12:28:51 -04:00
bryanthaboi 5019ba2caf Merge pull request #71 from bryanthaboi/bug-squashing
another attempt
2026-07-22 10:56:20 -04:00
bryanthaboi 0b267fc536 another attempt 2026-07-22 10:55:46 -04:00
bryanthaboi 290bde1c39 Merge pull request #70 from bryanthaboi/bug-squashing
android build fix
2026-07-22 10:51:02 -04:00
bryanthaboi 7e92e54669 android build fix 2026-07-22 10:50:23 -04:00
bryanthaboi b89b895962 squashing BUGS and adding COOL stuff
squashing BUGS and adding COOL stuff
2026-07-22 10:43:06 -04:00
bryanthaboi 4311955c43 input sticky bug 2026-07-22 10:38:37 -04:00
bryanthaboi 747d9acad1 linux and android builds 2026-07-22 10:08:20 -04:00
bryanthaboi 5041125751 more bugs squashed + portable mode
CLOSES #28: PC in the beginning of the game isn't interactable (The one in your house)
CLOSES #34: Bug when calculating exp after one or many fainted team members.
CLOSES #37: No Grass Cutting
CLOSES #38: Blind TMs
CLOSES #53: Portable Mode
CLOSES #55: changing palletes with hot key
CLOSES #62: Poison status damage issue.
2026-07-22 09:45:23 -04:00
bryanthaboi 15029d811f bug squashing
# Closed issues

CLOSES #17: Incorrect Character Visuals (Only with GBC filter)
CLOSES #23: Could you allow the player to change the order of the moves
CLOSES #24: Evolution music not playing during evolution
CLOSES #26: Standing on door glitch
CLOSES #27: Battle Intro text automatically continues
CLOSES #29: Visual bug when zoomed out
CLOSES #32: Team Rocket recruiter doesn't battle with you unless you speak with him first
CLOSES #33: Developer/Debug Console
CLOSES #35: Professor Oak's introduction Inaccuracies
CLOSES #36: Missing Pokemon Dex entries when picking starter + Rival Pathing issues
CLOSES #39: Guy who stops player from skipping brock doesn't bring you to brock's gym + doesn't leave once you've beaten brock
CLOSES #40: Bill cutscene is broken
CLOSES #41: Ticket guy failing to be a Ticket guy
CLOSES #42: S.S. anne odd behavior + Missing sailing away animation
CLOSES #43: Dig Attack animation appears to be glitched
CLOSES #44: Pokeball flashing doesn't appear to be accurate
CLOSES #45: Inaccurate Cut Animation
CLOSES #46: Dugtrio i caught in diglett cave has two of the same move
CLOSES #47: Rival ignores player in Lavender Tower
CLOSES #48: Healing pad in lavender tower does not function
CLOSES #49: Incorrect dialogue with parched security guard
CLOSES #50: (Game Breaking!) Rocket grunt guarding poster refuses to move
CLOSES #51: Badges showing up as items I can deposit in PC
CLOSES #52: Visual bug on Celadon Department Store roof (Red Filter)
CLOSES #54: Visual issue on route 15 + Fuchsia City
CLOSES #56: Running animation missing
CLOSES #57: Safari Zone does not display steps while you are inside of it
CLOSES #58: (Game Breaking!) Softlock at cycling road gate
CLOSES #59: Bike visual issues
CLOSES #60: Cycling road not forcing you to get on your bike
CLOSES #61: No keycard doors in Silph Co.
CLOSES #63: Missing teleporter animation
CLOSES #64: Inaccurate spinning
CLOSES #65: Reimplement unused Silph Co. Chief and Professor Oak trainer battles
2026-07-22 08:18:13 -04:00
bryanthaboi 2c2a5a4220 LOTS OF UPDATES
LOTS OF UPDATES - SEE ALL ISSUES THIS CLOSED
2026-07-21 14:57:32 -04:00
bryanthaboi f64666c6ce discord rich presence, and MORE COLORS, video options, shiddddd so much stuff 2026-07-21 14:52:45 -04:00
bryanthaboi 3f4aaccbf5 attacks should not animate if miss or fail 2026-07-21 13:52:21 -04:00
bryanthaboi 20ad4e6c41 fix first blue battle, fossil stuff, and another color palette option 2026-07-21 13:41:34 -04:00
bryanthaboi 60b3cebdce ok the auto driver works but needs work 2026-07-21 13:19:53 -04:00
bryanthaboi 9468ffccdf solving more things and making the auto-player work almost perfectly 2026-07-21 09:55:03 -04:00
bryanthaboi 948ae8abbd Merge pull request #16 from bryanthaboi/bryanthaboi-support-1
Add support section to README
2026-07-21 07:37:17 -04:00
bryanthaboi cb22684c2a Add support section to README
Added a section for supporting the project with a 'Buy Me a Coffee' link.
2026-07-21 07:36:43 -04:00
bryanthaboi 94be169e35 Merge pull request #13 from bryanthaboi/speedrun_conv_bug_fix
work in progress bug fix via speed run
2026-07-21 05:53:01 -04:00
bryanthaboi 36188ef18a work in progress 2026-07-21 05:50:40 -04:00
bryanthaboi 7a1b4e5c45 Merge pull request #10 from bryanthaboi/CI-update
CI Update
2026-07-20 09:22:48 -04:00
bryanthaboi eba6e8d752 CI Update 2026-07-20 09:20:56 -04:00
bryanthaboi ec5a72b365 Merge pull request #3 from bryanthaboi/underground-path-warps 2026-07-19 16:43:50 -04:00
bryanthaboi 9325d56046 Fix underground path route 5-8 exit warps 2026-07-19 16:42:30 -04:00
bryanthaboi d650e605b0 Merge pull request #2 from bryanthaboi/modding-updates - big ass modding update
big ass modding update
2026-07-19 16:21:56 -04:00
bryanthaboi 47923d95b3 big ass modding update 2026-07-19 16:18:18 -04:00
bryanthaboi b5a673b252 Update README.md 2026-07-18 08:56:30 -04:00
501 changed files with 133748 additions and 5560 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Bug report
description: Something in the game is broken, wrong, or not behaving like the original.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
A screenshot is worth more than any description. If you can grab one, grab one.
If you genuinely can't, that's fine, but then the details below need to be thorough
enough that someone can find the bug without ever seeing your screen.
- type: input
id: summary
attributes:
label: One line summary
description: What's broken, in a sentence.
placeholder: Warp at the south end of the Route 5-8 underground path drops you in the wrong spot
validations:
required: true
- type: dropdown
id: os
attributes:
label: Which build are you running
options:
- macOS
- Windows
- Both
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: The release you downloaded. It's in the release title, like 0.1.4.
placeholder: 0.1.4
validations:
required: true
- type: input
id: location
attributes:
label: Where in the game
description: >
Be specific. Route, town, building, floor, menu, battle. If it's a map
transition, say which side you came in from.
placeholder: Underground path between Route 5 and Route 8, south entrance
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: Screenshot, or a detailed description if you couldn't take one
description: >
Drag the image straight into this box. If you couldn't get a screenshot,
write "no screenshot" and then describe exactly what was on screen: where
the player sprite was, what the tiles around it looked like, any text on
screen, whether anything was frozen or flickering. The more concrete, the better.
placeholder: |
Drop the image here.
Or, with no screenshot:
Screen faded out like a normal warp, then faded back in with the player
standing on the grass tile north of the Route 6 gate house instead of
inside the tunnel. Player was facing down. No text box. Music kept playing
the underground path track instead of switching to the Route 6 track.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to make it happen
description: Numbered steps, starting from a point someone else can get to.
placeholder: |
1. Start a new game, get to Cerulean
2. Go south to Route 5, enter the underground path building
3. Walk to the south end of the tunnel
4. Step onto the stairs
validations:
required: true
- type: textarea
id: expected
attributes:
label: What should have happened
placeholder: Should have come out inside the Route 8 side entrance building, facing up.
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Does it happen every time or only sometimes. Did it start after a specific
release. Anything you were doing right before it. Leave blank if nothing comes to mind.
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,88 @@
name: Feature request
description: Ask for something new, or a change to how something already works.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can
actually build without guessing at what you meant.
- type: input
id: summary
attributes:
label: One line summary
description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike
validations:
required: true
- type: textarea
id: what
attributes:
label: What do you want
description: >
Describe it properly. What is it, where does it live in the game, what does the
player see or do. If it changes something that already exists, say what it does today
and what it should do instead.
placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
foot and the player moves at bike speed. Release and you go back to walking.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing
description: >
What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer.
placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
without making the game easier.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open.
placeholder: |
- Hold B on the overworld to run
- Same speed as the bike
- Disabled anywhere the bike is disabled
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Does this change how the original game plays
description: >
Some requests are quality of life, some change the actual game. Both are fine,
it just helps to know which one you're asking for.
options:
- Quality of life, original game is untouched
- Changes how the game plays
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, how another game does it, related issues. Leave blank
if nothing comes to mind.
validations:
required: false
+163
View File
@@ -0,0 +1,163 @@
name: ci
# The ROM-free test run (21-testing-and-ci §CI).
#
# CI has no ROM and never will: data/generated/ is produced by a SHA-1
# verified import of a cartridge dump, and no ROM bytes are ever committed.
# That is why the suite is tiered -- T1 (primitives), T2 (engine invariants)
# and T4 (mod SDK) run against the committed tests/fixture_data dataset, so
# they need no ROM, no display and no assets beyond what is in the repo.
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
# data/generated/ is absent and skips it rather than failing.
#
# Runs alongside release.yml, which is untouched by this file.
on:
push:
branches: [main]
pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
headless:
name: headless suites (no ROM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
# written to Lua 5.1 semantics, so CI must run the interpreter the
# game actually ships with or it would green-light 5.4-only syntax.
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: interpreter version
run: luajit -v
- name: run every ROM-free tier
run: ./scripts/test.sh
fixture-dataset:
name: fixture dataset integrity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
# the fixture PNGs are committed (they are 8x8 placeholders, not
# ROM-derived); assert they are still readable 4-shade images rather
# than regenerating them, so a corrupted commit is caught
- name: fixture assets are valid PNGs
run: |
python3 - <<'PY'
import glob, sys
from PIL import Image
paths = sorted(glob.glob("tests/fixture_data/assets/*.png"))
if not paths:
sys.exit("no fixture assets found")
for path in paths:
with Image.open(path) as image:
image.load()
print(f"ok {path} {image.size} {image.mode}")
print(f"\n{len(paths)} fixture assets valid")
PY
# the fingerprint golden is the parity tripwire; prove it still
# matches the dataset on a clean checkout
- name: fingerprint gate
run: luajit tests/engine/gate_fingerprint.lua
- name: parity-guarantee meta-test
run: luajit tests/engine/gate_meta_coverage.lua
# Only the differ is under test here, and the job is named for that. The
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
# chunk runs after main.lua has already booted the game, and
# src/core/Data.lua has no POKEPORT_DATA_DIR branch, so no LOVE process
# can be pointed at tests/fixture_data. There is deliberately no step
# here that runs scripts/test.sh with WITH_SHOTS: it would have nothing
# to capture and nothing to diff, and a job that green-lights on skipped
# work is worse than an absent one.
shot-differ:
name: screenshot differ (capture not yet wired)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 -m pip install --upgrade pillow
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
# flags a deliberately corrupted golden and passes the clean one.
# That is the half of the pipeline this repo can actually prove.
- name: compare_shots self-test
run: |
set -e
python3 - <<'PY'
import os
from PIL import Image
os.makedirs("/tmp/g", exist_ok=True)
os.makedirs("/tmp/s", exist_ok=True)
base = Image.new("RGB", (160, 144), (255, 255, 255))
for x in range(0, 160, 8):
for y in range(0, 144, 8):
base.putpixel((x, y), (0, 0, 0))
base.save("/tmp/g/clean.png")
base.save("/tmp/s/clean.png")
base.save("/tmp/g/broken.png")
bad = base.copy()
for x in range(40, 60):
for y in range(40, 60):
bad.putpixel((x, y), (255, 0, 0))
bad.save("/tmp/s/broken.png")
PY
if python3 tools/compare_shots.py /tmp/g /tmp/s; then
echo "compare_shots passed a corrupted golden -- differ is broken"
exit 1
fi
rm /tmp/g/broken.png /tmp/s/broken.png
python3 tools/compare_shots.py /tmp/g /tmp/s
# an empty golden directory must not read as success
- name: differ refuses to pass vacuously
run: |
set -e
mkdir -p /tmp/empty-goldens /tmp/empty-shots
if python3 tools/compare_shots.py /tmp/empty-goldens /tmp/empty-shots; then
echo "compare_shots passed with no goldens"
exit 1
fi
lint:
name: mod lint (no ROM-derived content)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# the MK305 dump check key-diffs shipped tables through luajit, and
# modkit treats a missing interpreter as a fatal MK100 -- without
# this install the gate would fail instead of failing open
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
# constraint 1, enforced automatically: a committed mod that ships
# ROM-derived bytes fails the build. `lint` is the no-ROM-content
# check; `validate` is deliberately not run here because it resolves
# a mod against the fixture dataset, and a Red-content mod such as
# example_mew_starter legitimately does not resolve against it.
- name: lint every committed mod
run: |
set -e
found=0
for mod in mods/*/; do
[ -f "$mod/manifest.json" ] || continue
found=1
echo "== $mod"
python3 tools/modkit.py lint "${mod%/}"
done
if [ "$found" = "0" ]; then
echo "no committed mods to lint"
fi
+78 -10
View File
@@ -1,7 +1,7 @@
name: Release
# Builds the macOS and Windows desktop apps on the self-hosted Mac runner
# and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -14,6 +14,12 @@ name: Release
on:
push:
branches: [main]
# CI/workflow and docs-only changes don't ship anything to users, so they
# don't earn a release. A push touching these *and* real source still
# releases; only pushes confined entirely to these paths are skipped.
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
inputs:
version:
@@ -23,6 +29,8 @@ on:
permissions:
contents: write
issues: read
pull-requests: read
concurrency:
group: release
@@ -144,14 +152,20 @@ jobs:
echo "Identities available to codesign:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows
- name: Build macOS + Windows + Linux
run: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a
# login-keychain profile.
# login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization.
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
- name: Build Android
run: |
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app
run: |
set -euo pipefail
@@ -163,8 +177,8 @@ jobs:
set -a; . "$ci_dir/notary.env"; set +a
echo "::add-mask::$APPLE_APP_PASSWORD"
app=".bazinga/work/PokemonRed.app"
zip="dist/mac/PokemonRed-macos.zip"
app=".bazinga/work/gen1recomp.app"
zip="dist/mac/gen1recomp-macos.zip"
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
@@ -194,10 +208,27 @@ jobs:
outdir="dist/release"
rm -rf "$outdir"
mkdir -p "$outdir"
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love"
[ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; }
cp "$love_file" "$outdir/gen1recomp-${v}.love"
ls -lh "$outdir"
# Checksums for every staged release asset (sums file itself is
# written after this and named outside the gen1recomp-* glob, so it
# never lists itself).
(cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt)
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
@@ -206,14 +237,51 @@ jobs:
v="${{ steps.ver.outputs.version }}"
tag="${{ steps.ver.outputs.tag }}"
# Issues this release closes, per GitHub's own "closing issues" links
# (works for squash, rebase, and merge commits alike). Scans every
# commit since the previous tag so a skipped release run doesn't drop
# issues on the floor.
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
closed=""
for pr in $(git log --pretty=%H "$range" \
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
--jq '.[].number' 2>/dev/null \
| sort -un || true); do
closed+="$(gh api graphql \
-f owner="${GITHUB_REPOSITORY%/*}" \
-f name="${GITHUB_REPOSITORY#*/}" \
-F pr="$pr" \
-f query='
query($owner:String!, $name:String!, $pr:Int!) {
repository(owner:$owner, name:$name) {
pullRequest(number:$pr) {
closingIssuesReferences(first:50) { nodes { number title } }
}
}
}' \
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
done
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
notes="Download the correct version for your computer below."
if [ -n "$closed" ]; then
notes+=$'\n\n## Issues closed\n\n'"$closed"
fi
printf 'Release notes:\n%s\n' "$notes"
gh release create "$tag" \
--target "$GITHUB_SHA" \
--title "$v" \
--notes "$notes" \
"dist/release/PokemonRed-${v}-macos.zip" \
"dist/release/PokemonRed-${v}-windows.zip"
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
echo "Published release $tag"
+326
View File
@@ -0,0 +1,326 @@
# Contributing to the mod platform
Two routes
| You are... | Lane | Review bar |
|---|---|---|
| adding a mod to the gallery, or listing one in the showcase | [Lane A](#route-a--contributing-a-mod) | template + polish checklist + green `modkit validate` |
| changing the loader, a registry schema, an event/hook name, or a manifest field | [Lane B](#route-b--contributing-an-engine--mod-api-change) | RFC + backward-compat statement + parity test + generated docs |
If you are not sure which lane you are in, ask this: **could my change make
somebody else's existing mod behave differently?** If yes, it is Lane B.
---
## Route A — contributing a mod
### 1. Scaffold
```sh
python3 tools/modkit.py scaffold my_mod --profile content
```
`--profile` is one of `content`, `overhaul`, `total_conversion`. The
scaffold refuses to overwrite an existing directory, and prints the next
commands.
Or copy the gallery entry closest to your intent — that is what the gallery
is for:
| You want to... | Copy |
|---|---|
| change numbers | `mods/examples/example_balance_tweaks` |
| change art | `mods/examples/example_shiny_palette` |
| add music or cries | `mods/examples/example_jukebox` |
| add a quest, NPC or dialogue | `mods/examples/example_lost_parcel` |
| change how battles work | `mods/examples/example_weather` |
| add a screen or a tool | `mods/examples/example_dexnav` |
| build a whole new game | `mods/examples/example_mini_conversion` |
### 2. What the PR must contain
1. **A green `modkit validate`.** CI runs it; so should you.
```sh
python3 tools/modkit.py validate mods/examples/<id> --base imported
python3 tools/modkit.py lint mods/examples/<id>
```
`validate` drives the *real* loader headlessly, so a mod that passes
here does not surface load errors in game. `--base imported` folds
against the full vanilla id space; without it, rules that can only be
decided against real Red content (`MK103`, the patch-target check) are
reported as skipped rather than guessed at.
2. **A `tests/` directory** with at least one suite that loads the mod
through the headless loader and asserts its *stated effect* — not just
that it loaded.
```lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data"); Data:load()
local run = T.sdk.loadMod("mods/examples/my_mod", { data = Data })
T.eq(#run.errors, 0, "loads clean")
T.eq(Data.pokemon.PIKACHU.baseStats.speed, 120, "the patch landed")
run.release()
T.finish("my_mod")
```
Add a `.modkitignore` listing the suite so it stays out of the
distributed package — a test requiring engine modules is a
private-require finding against the shipped archive, and `pack` treats
warnings as fatal.
3. **A `README.md`** that opens with one sentence saying what the mod does,
names its persona, and gives the three commands to try it. No
prerequisites the scaffold did not already create.
4. **A `mod.card`** meeting the [§3.2 schema](#modcard):
```lua
return {
summary = "One sentence, <=100 chars.",
author = "Your handle", -- never blank; no author is anonymous by omission
tags = { "balance", "beginner" },
differences = { changed = {…}, added = {…}, known = {…} },
credits = { { who = "…", for_ = "original chiptune arrangement" } },
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
```
5. **A `CHANGELOG.md`** in keep-a-changelog format, with a heading matching
`manifest.version`. `validate` warns when the version advanced without
one.
6. **Disabled by default.** Gallery entries live in `mods/examples/`, which
the loader's one-level discovery does not walk, so a fresh install
discovers none of them and the vanilla game is unchanged.
7. **No ROM-derived bytes.** Art and audio ship as originals or as a
`transforms.lua` operating on the player's own cache. `modkit lint`
is the hard floor; see
[the legal posture](#legal-posture-non-negotiable).
CI checks 1, 2, 6 and 7 mechanically. A reviewer checks 3, 4, 5 and the
polish checklist.
### 3. Category
`manifest.category` is a closed vocabulary. An unknown value is a warning,
not a hard error, so the list can grow without breaking old mods.
| category | Meaning | Typical profile |
|---|---|---|
| `TWEAK` | Small data edits: stats, prices, learnsets, encounter tables | content |
| `BALANCE` | Systematic rebalance across many records or a ruleset | content / overhaul |
| `CONTENT` | New species / moves / items / maps / trainers | content |
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
| `AUDIO` | Music, sfx, cries | content |
| `UI` | New or modified screens, menus, overlays | content / overhaul |
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
| `TOTAL_CONVERSION` | Full re-theme; owns its own tri-ledger | total_conversion |
| `OTHER` | Fallback | any |
`GAMEPLAY` is accepted as an alias for `TWEAK`, so `example_mew_starter`
keeps validating with the value it has shipped since before the taxonomy
existed.
### 4. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies,
permissions, profile. The card is the *human-facing* one: who made this,
what it changes, what it does not do yet. It is never read by the loader's
merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load.
Two fields deserve their own note:
- **`differences`** is a self-declared tri-ledger, mirroring the discipline
the engine holds itself to. `changed` and `added` let a player see the
blast radius before installing; `known` is where you are honest about
what is rough. A card with an empty `known` on a complex mod reads as
carelessness, not polish.
- **`screenshots[].transform`** describes a screenshot by the *driver
script* that regenerates it from the player's build, rather than shipping
the pixels. That is the legal posture extended to your marketing: a
distributed mod never carries ROM-derived bytes, not even in its preview
images.
### 5. Tags
Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`,
`data-only`, `quality-of-life`, `hardcore`, `cosmetic`, `story`, `ruleset`,
`audio`, `ui`, `total-conversion`.
---
## Route B — contributing an engine / mod-API change
Changing the loader, a registry schema, an event or hook name, or a manifest
field touches the **compatibility surface** the project promises to hold
stable. Those PRs carry five obligations.
### 1. An RFC
`docs/rfcs/NNNN-<slug>.md`, covering:
- **Motivation** — the mod that cannot be written today.
- **The decision it extends or amends** — name the D-number and the plan
file, so the change is traceable to the design it modifies.
- **The exact API delta** — new registry names, new schema fields, new
event/hook names and their payload shapes and call sites.
- **A migration note for existing mods** — what an author has to do, if
anything. "Nothing" is a valid and preferred answer.
### 2. A backward-compatibility statement
Show that the v1 surface still works: `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields,
and `pokemon.before_give`.
**A change that would break a v1 mod is rejected unless it is
additive-with-alias.** `mods/example_mew_starter` is the live proof: it is
api 1, uses `category = "GAMEPLAY"`, copies a whole species record because
`patch` did not exist yet, and it must keep loading unchanged.
### 3. A parity-guarantee test
Two tests, not one:
- **The no-mod test** — vanilla behavior is unchanged with nothing
installed. A new hook with no subscriber must return the vanilla value;
a new registry must be a provable no-op when empty; a new event must not
allocate its payload when nothing wants it (`Runtime.wants(name)` /
`Runtime.wantsHook(name)` guard the hot paths).
- **The mod-API test** — the new seam, exercised through the *public* mod
API rather than by reaching into internals. If the test has to require a
private module to drive your seam, the seam is not finished.
### 4. Docs with the change
The reference pages are generated from `src/mods/Schemas.lua`, so a new
registry or a new schema field lands with its catalog entry in the same PR
and the generator runs clean:
```sh
luajit tools/gen_registry_docs.lua # in-repo default
luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout
```
The prose reference lives in the GitHub wiki; the generated pages are
written into a checkout of it, so they cannot drift from the engine.
### 5. Deprecation etiquette
**Nothing is removed.** A superseded seam is marked deprecated in the
generated reference with its replacement named, keeps firing and working,
and is listed in the deprecations page.
`pokemon.before_give` is the worked precedent: the `pokemon.give` hook
supersedes it, and it is grandfathered forever anyway.
### Review
PRs touching `src/mods/`, `src/mods/Schemas.lua`, or the event/hook catalog
need the RFC label and a green parity gate before merge.
---
## The polish checklist
Every gallery example and every community mod the guide recommends meets
this bar. `[auto]` items are checked by `modkit validate`; `[review]` items
by a human.
### Error messages
- `[auto]` No bare `error()` or `assert()` in mod callbacks. Every failure
path uses `mod.log:warn` / `mod.log:error` — the loader already prefixes
`[modid]` — **and names a remediation**:
```lua
-- no
local mew = assert(mod.content.pokemon:get("MEW"), "Mew is missing")
-- yes
if not mod.content.pokemon:get("MEW") then
mod.log:warn("MEW missing from the merged view -- is a species mod "
.. "loaded before this one? speed patch skipped")
return
end
```
- `[review]` Every registration is validated against its schema, so a typo
is a load-time message naming the field, not a nil-index crash three
screens later.
### Empty states
- `[review]` Every screen a mod adds renders a sentence when its data set
is empty — "No songs registered", "Nothing seen yet" — never a blank box.
`ListMenu` gives you this for free.
### First-run experience
- `[review]` The README opens with one sentence of what the mod does, then
the commands to try it.
- `[auto]` The mod loads clean on a fresh install — zero `Loader.errors` —
with only its declared dependencies.
- `[review]` Options have sane defaults, so the mod does something useful
before the player opens its options pane.
### Credits and honoring authors
- `[review]` `mod.card.credits` names every upstream contribution — art,
music arrangement, borrowed code — and what it was for. A mod that ports
another community work credits it and links it.
- `[auto]` `mod.card.author` (or `authors`) is present and non-empty. The
showcase and the manager both surface it, so no author is anonymous by
omission.
- `[review]` Asset provenance is honest: originals declared original,
cache-derived output produced by a declared transform, third-party assets
credited and license-compatible.
### Legal posture (non-negotiable)
- `[auto]` **No ROM-derived bytes in the packaged mod.** `modkit pack`
refuses otherwise, and `pack` runs `validate --strict`, so even warnings
block the archive.
- `[review]` A total conversion carries the TC legal callout: the Red
import still runs and supplies fallback infrastructure, the conversion
overrides on top, and it distributes recipes rather than extracted
content.
---
## Versioning etiquette
Three version numbers coexist.
**Engine version** — `src/core/Version.lua`. Major = a breaking change to
the mod-facing schemas or API; minor = new backward-compatible seams;
patch = bugfix.
**Mod API version** — the integer `modApi`, currently `2`. Bumped only on a
breaking change to the `mod` object surface. A manifest's `api` field pins
the surface the mod was written against, so an api-2 mod keeps working when
the engine ships api 3.
**Your mod's version** — the manifest `version`, semver:
| bump | when |
|---|---|
| patch | data fixes; no save-shape change, no new content ids |
| minor | new content ids, new options with defaults, new optional deps |
| major | removed or renamed content ids, a changed `mod.save` shape (needs a `mod.migrations:add(sinceVersion, fn)` entry), or a raised `game_version` floor |
Declare the engine range you target in `game_version` (a semver range, e.g.
`">=1.0.0 <2.0.0"`). The loader checks it on load; a mismatch is a clear,
mod-attributed manager error, never a silent partial load.
Every version change gets a `CHANGELOG.md` heading. `modkit validate` warns
when `manifest.version` advanced without one.
+103 -106
View File
@@ -1,141 +1,138 @@
# Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
hand-written Lua; game data and graphics are decoded from a ROM supplied by
the player.
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red ROM is the only game
content input.
```text
first boot
Pokemon Red ROM -> in-app Lua importer -> private LÖVE save directory
-> generated Lua data and PNGs
-> compact audio channel programs
-> LÖVE2D engine
```
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again.
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
## Packaged App
## Quick Start
Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
The packaged app contains neither a ROM nor pre-extracted game data.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
Music, sound effects, and cries are synthesized while the game runs from
compact Game Boy audio channel programs copied out of the verified ROM. No
WAV or OGG library is bundled or generated.
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
## Source Checkout
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
The source launchers retain an optional Python workflow for developers. Place
the ROM in the project folder and double-click `Play-Mac.command` or
`Play-Windows.bat`, or run:
## Controls
| Action | Keyboard | Controller |
|--------|----------|------------|
| Move | Arrow keys / WASD | D-pad / left stick |
| A | Z / Enter / Space | A |
| B | X / Backspace | B |
| Start | Escape | Start |
| Select | Tab / Shift | Back / Select |
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
supported out of the box.
### Hotkeys
| Key | What it does |
|-----|----------------|
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
| `5` | Cycle GBC FX |
| `F1` | Save |
| `F2` | Load |
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/run.sh
```
Windows PowerShell:
then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
```powershell
powershell -ExecutionPolicy Bypass -File scripts\setup.ps1 -Rom C:\path\red.gb
powershell -ExecutionPolicy Bypass -File scripts\run.ps1
```
## Portable Mode
The setup scripts also accept `ROM_PATH`. With no argument, they use the first
`.gb` file in the project root.
By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the app
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
running from source), then launch the game. Portable mode is desktop-only
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
runs from a read-only package.
## Developer Data Build
With `portable.txt` present:
Requirements: Python 3.10+ and Pillow.
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory.
- A ROM import writes the generated `data/generated` and `assets/generated`
cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it without asking for the ROM again
even on a different computer, as long as the same folder comes along.
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
```sh
python3 -m pip install pillow
python3 tools/build_data.py --rom "/path/to/Pokemon Red.gb" --clean
```
## Modding
This command produces the data modules and 495 PNGs in the source tree for
development and parity checks. It is not used by the packaged app.
The game ships a native mod platform: content registries, events and hooks,
per-mod saves and options, and an in-game manager. The full modding book —
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
reference — lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
## Running
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
Requires LÖVE 11.x:
## Bugs and Ideas
```sh
love .
```
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.
Controls: arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
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.
## Link Play
## More
START > LINK connects two copies directly over UDP. The host chooses HOST A
GAME and shares the shown address; the other player chooses JOIN A GAME.
The default port is 7777 and can be overridden with `POKEPORT_LINK_PORT`.
## Save Editor
Edit party, boxes, items, events, map location, and Pokédex flags without
playing through the game. Close the game first, then from the repo root:
```sh
love . --editor
# or
POKEPORT_EDITOR=1 love .
# open a specific save
love . --editor --save "/path/to/save.lua"
```
By default it loads the game's LÖVE save (`save.lua`):
- macOS: `~/Library/Application Support/LOVE/pokemon-love2d/save.lua (or without the LOVE in a built version)`
- Linux: `~/.local/share/love/pokemon-love2d/save.lua`
- Windows: `%APPDATA%\love\pokemon-love2d\save.lua`
If that file is missing or you want another copy, use **Open...**, drop a
`save.lua` onto the window, or pass `--save`. Each write makes a
`save.lua.bak-YYYYMMDD-HHMMSS` backup first.
See `tools/save-editor/README.md` for headless tests.
## Layout
```text
tools/ ROM decoder, save editor, and developer verification tools
data/generated/ generated Lua game data (gitignored)
data/scripts/ hand-ported map behavior
assets/generated generated graphics and compact audio cache (gitignored)
src/ hand-written LÖVE engine
scripts/ setup, run, and packaging helpers
mobile/ Android and iOS build trees
tests/ headless behavior and parity suites
docs/ architecture, behavior notes, and platform docs
```
See `docs/architecture.md` for runtime details and
`docs/behavior-porting-notes.md` for formula provenance.
## Delete generated files (mac)
```sh
rm -rf data/generated assets/generated \
"$HOME/Library/Application Support/LOVE/pokemon-love2d/data/generated" \
"$HOME/Library/Application Support/LOVE/pokemon-love2d/assets/generated"
rm -f "$HOME/Library/Application Support/LOVE/pokemon-love2d/rom-cache.complete"
```
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
— START > LINK connects two copies directly over UDP.
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game.
- `docs/architecture.md` — runtime details;
`docs/behavior-porting-notes.md` — formula provenance.
## Special Thanks
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.
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

+9
View File
@@ -0,0 +1,9 @@
# On-screen touch control art
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
Those Awesome Guys), public domain under CC0:
https://thoseawesomeguys.com/prompts/
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
d-pad (neutral + per-direction highlight), A, B, plus (START) and
minus (SELECT).
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+34 -7
View File
@@ -1,13 +1,16 @@
function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
for _, a in ipairs(arg) do
if a == "--editor" then editor = true end
if a == "--developer" then developer = true end
end
end
-- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer
if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
@@ -16,9 +19,17 @@ function love.conf(t)
t.window.height = 800
else
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
t.window.title = "Pokemon Red (Gen 1 Recompilation Project)"
t.window.width = 160 * 4
t.window.height = 144 * 4
-- Version.lua has zero requires, so it is loadable this early; fall
-- back to the plain title if the source is not mounted yet
local ok, Version = pcall(require, "src.core.Version")
t.window.title = ok and Version.title()
or "gen1recomp"
-- Open at the launcher's design size (the split-screen ROM selector is
-- laid out for 1024x768). The window is resizable and the 160x144 game
-- canvas letterboxes into whatever size it ends up, so this only sets the
-- starting size, not the game's resolution.
t.window.width = 1024
t.window.height = 768
end
t.version = "11.5"
t.window.vsync = 1
@@ -30,14 +41,30 @@ function love.conf(t)
local osName = love._os
local mobile = osName == "Android" or osName == "iOS"
if mobile then
-- On Android/iOS, width/height aspect picks portrait vs landscape
-- (fullscreen alone is not enough). Use a tall portrait size; the
-- OS then resizes to the real display. highdpi is required for
-- Retina iOS (Android always behaves as highdpi).
-- resizable is what unlocks orientation. SDL's Android backend, given no
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
-- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
-- display and rotations resize again. highdpi is required for Retina iOS
-- (Android always behaves as highdpi).
t.window.width = 1080
t.window.height = 1920
t.window.fullscreen = true
t.window.highdpi = true
-- Android only (irrelevant on iOS): puts the save directory under the
-- app's external-files folder, which is readable/writable via USB or a
-- file manager with no runtime permission, so RomImporter can ask the
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
else
t.window.resizable = true
end
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
-- Celadon Chief House (scripts/CeladonChiefHouse.asm). The CHIEF is a
-- talk-only NPC in the original ROM, and ChiefData (data/trainers/
-- parties.asm) is an empty, unreferenced trainer entry. After the HALL
-- OF FAME (EVENT_BEAT_CHAMPION_RIVAL) the CHIEF puts up a fight, using a
-- reconstructed party for the otherwise-unused OPP_CHIEF class.
return {
talk = {
TEXT_CELADONCHIEFHOUSE_CHIEF = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "check_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 4
{ "jump_if_true", 12 }, -- 5
{ "show_text", "So you've come to\nshut down my\noperation?\f"
.. "TEAM ROCKET's\nCHIEF won't go\ndown so easy!" }, -- 6
{ "start_battle", "trainer", "OPP_CHIEF", 1 }, -- 7
{ "jump_if_false", "end" }, -- 8
{ "set_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 9
{ "show_text", "Gah! Even the\nCHIEF is no match\nfor you!\f"
.. "TEAM ROCKET is\nfinished for\ngood!" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_CeladonChiefHouseChiefText" }, -- 12
},
},
}
+2 -3
View File
@@ -1,9 +1,8 @@
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
--
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
-- omitted here.
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
-- data/scripts/story4.lua.
return {
COPYCATS_HOUSE_2F = {
+5 -17
View File
@@ -1,14 +1,11 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
-- guide and SUPER_NERD2 garden nerd.
--
-- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; that part is already covered on this map by
-- story5.lua's onStep gate (walks the player back a step at the
-- east exit before EVENT_BEAT_BROCK). Here we only port the real
-- YES/NO-branched flavor text these NPCs speak when talked to.
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- YES/NO-branched flavor text is here.
local M = {}
@@ -60,15 +57,6 @@ M.PEWTER_CITY = {
end)
end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
},
}
+22
View File
@@ -0,0 +1,22 @@
-- Red's House 2F (pokered data/events/hidden_events.asm +
-- engine/events/hidden_objects PrintRedSNESText).
--
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
local TextBox = require("src.render.TextBox")
return {
REDS_HOUSE_2F = {
-- hidden_events.asm:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
onInteract = function(game, ow, fx, fy)
if fx ~= 3 or fy ~= 5 then return false end
local text = game.data.text._RedBedroomSNESText
or "{PLAYER} is\nplaying the SNES!"
game.stack:push(TextBox.new(game, text))
return true
end,
},
}
+16 -12
View File
@@ -5,19 +5,23 @@ return {
SILPH_CO_9F = {
talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
-- "You look tired..." then "Don't give up!"; after the event, just
-- says thanks. Nurse texts are not in data/generated/text.lua, so
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
-- up!"; after the event, just says thanks. Nurse texts are not in
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
-- strings are used as literals.
TEXT_SILPHCO9F_NURSE = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 8 },
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
{ "heal_party" },
{ "show_text", "Don't give up!" },
{ "jump", 9 },
{ "show_text", "Thank you so\nmuch!" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
{ "heal_party" }, -- 5
{ "fade", "out", "white" }, -- 6
{ "wait", 3 }, -- 7 Delay3
{ "fade", "in", "white" }, -- 8
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
},
},
},
+10 -32
View File
@@ -1,22 +1,18 @@
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN.
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2 and GIRL.
--
-- Not ported here (already handled elsewhere / not talk-reachable):
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED --
-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript /
-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the
-- gates themselves are already implemented via story5.lua's onStep
-- chain (viridianOldManStep / viridianGymLock) for this map.
-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_
-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full
-- scripted-battle cutscene outside this task's Commands vocabulary
-- (no static_battle-style "battle a scripted old-man WEEDLE" command
-- exists); we port the real YES/NO branch text he speaks but the
-- "yes" branch here just shows the "I'll show you how" line rather
-- than actually starting the minigame, since that machinery isn't
-- ported to this map yet.
-- * TEXT_VIRIDIANCITY_OLD_MAN (the walking man at (17,5)) and
-- TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY (the sleeper at (18,9)) -- both
-- live in data/scripts/story.lua, which owns this map's onStep gate
-- and can reach the `old_man_demo` command for the real catch
-- tutorial. Keep them there: story.lua loads BEFORE this file, so a
-- duplicate here would silently win the merge.
-- * TEXT_VIRIDIANCITY_GYM_LOCKED -- a step-triggered blocking text
-- (ViridianCityCheckGymOpenScript), implemented by story5.lua's
-- onStep chain (viridianGymLock -> viridianOldManStep) for this map.
local M = {}
@@ -91,24 +87,6 @@ M.VIRIDIAN_CITY = {
end
end,
-- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had
-- his coffee, he asks (YES/NO) whether you want to learn how to
-- catch Pokemon. YES leads into the catch-training minigame
-- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported --
-- see file header); NO just brushes you off ("Time is money...").
TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityOldManHadMyCoffeeNowText
or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes)
if yes then
push(game, t._ViridianCityOldManKnowHowToCatchPokemonText
or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done)
else
push(game, t._ViridianCityOldManTimeIsMoneyText
or "Time is money...\nGo along then.", done)
end
end)
end,
},
}
+1
View File
@@ -28,6 +28,7 @@ local files = {
"data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f",
+26 -27
View File
@@ -5,18 +5,26 @@
-- onBoulderMoved = fn } where a script is a list of { "command", args... }
-- rows executed by src/script/ScriptRunner.lua. Every hand-ported script
-- cites the pokered source it was ported from.
--
-- The modules land in src/script/MapScripts.lua as the engine's base
-- contribution: attachBase keeps the historical merge (talk tables merge
-- per TEXT constant, other hooks are replaced by later files, so
-- different files can each add NPCs to the same map), and mod
-- contributions from the map_scripts registry compose on top of it.
local registry = {
PALLET_TOWN = require("data.scripts.pallet_town"),
OAKS_LAB = require("data.scripts.oaks_lab"),
REDS_HOUSE_1F = require("data.scripts.reds_house"),
CELADON_MANSION_ROOF_HOUSE = require("data.scripts.celadon_eevee"),
}
local MapScripts = require("src.script.MapScripts")
-- story-critical scripts, one table per map. Later files MERGE into
-- earlier ones: talk tables merge per TEXT constant, other hooks
-- (onEnter, onVictory, ...) are replaced, so different files can each
-- add NPCs to the same map.
for _, mapEntry in ipairs({
{ "PALLET_TOWN", "data.scripts.pallet_town" },
{ "OAKS_LAB", "data.scripts.oaks_lab" },
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
}) do
MapScripts.attachBase(mapEntry[1], require(mapEntry[2]))
end
-- story-critical scripts, one table per map, in the order the old merge
-- loop required them
for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
"data.scripts.story3", "data.scripts.story4",
"data.scripts.story5", "data.scripts.story6",
@@ -24,33 +32,24 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
"data.scripts.safari", "data.scripts.seafoam",
"data.scripts.gyms" }) do
for mapId, mod in pairs(require(file)) do
local existing = registry[mapId]
if not existing then
registry[mapId] = mod
else
for k, v in pairs(mod) do
if k == "talk" and existing.talk then
for textConst, script in pairs(v) do
existing.talk[textConst] = script
end
else
existing[k] = v
end
end
end
MapScripts.attachBase(mapId, mod)
end
end
local M = {}
function M.get(mapId)
return registry[mapId]
return MapScripts.get(mapId)
end
-- script to run when the player talks to an object with this TEXT_ constant
function M.talkScript(mapId, textConst)
local mod = registry[mapId]
return mod and mod.talk and mod.talk[textConst] or nil
return MapScripts.talkScript(mapId, textConst)
end
-- attribution for that script's run: the owning mod's source, nil for base
function M.talkSource(mapId, textConst)
return MapScripts.talkSource(mapId, textConst)
end
return M
+193 -85
View File
@@ -6,12 +6,13 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Rival (object 1): before starter -> "gramps isn't around"; with
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
-- afterwards he gloats or sulks and marches out of the lab
-- (OaksLabRivalBattleEndScript).
-- afterwards HealParty + flag always, then he gloats or sulks and
-- marches out (OaksLabRivalEndBattleScript). A loss does not black out.
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
@@ -19,72 +20,137 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall)
return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 19 }, -- 2
{ "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 19 }, -- 4
{ "ask", askText }, -- 5
{ "jump_if_false", 20 }, -- 6
{ "give_pokemon", species, 5 }, -- 7
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8
{ "set_flag", choseFlag }, -- 9
{ "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
-- forceOwned is that bypass without mutating save.pokedex.owned.
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
{ "hide_object", "OAKS_LAB", ownBall }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12
{ "face_object", 1, "up" }, -- 13
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
{ "face_object", 1, "up" }, -- 14
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 16
{ "jump", 20 }, -- 17
{ "jump", 20 }, -- 18 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
}
end
return {
talk = {
-- Oak: accepts the parcel and hands over the Pokédex, then (once the
-- player has beaten the Route 22 rival) hands over the real POKé
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
-- recall / dex-rating / "pokemon can fight" / "around the world"
-- branches -- see docs/known-differences.md)
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- (DisplayDexRating) is still skipped.
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 12 }, -- 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)
{ "face_player" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "around_world" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_false", "pre_lab_battle" },
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
-- when the player stands below Oak (the usual desk talk).
{ "place_npc", 1, 4, 7, "up" },
{ "move_npc_to", 1, 4, 3 },
{ "play_music", "Music_OaksLab" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakIHaveARequestText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
{ "face_object", 1, "right" },
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", 1, 4, 7 },
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "play_music", "Music_OaksLab" },
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
{ "jump", "end" },
{ "label", "raise_young" },
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" },
{ "jump", "end" },
{ "label", "pre_lab_battle" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_true", "can_fight" },
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" },
{ "jump", "end" },
{ "label", "can_fight" },
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
{ "jump", "end" },
{ "label", "around_world" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
{ "jump", "end" },
{ "label", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -97,45 +163,76 @@ return {
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
-- Talking to the rival only ever prints a line: the lab battle is a
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
-- see onStep below), never a talk action. The handler used to fall
-- through from the "looks stronger" taunt straight into start_battle,
-- so talking to Blue at the table launched the rival fight before the
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
-- OaksLabText8 branches text only:
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
-- line; normally unreachable since the rival is hidden after the
-- lab battle in OaksLabRivalEndBattleScript)
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 20 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 18 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
{ "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)
{ "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", "pre_starter" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_true", "after_battle" },
-- has a starter, has not fought yet: just the taunt. The battle is
-- the coordinate trigger in onStep, not this talk (#219)
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "jump", "end" },
{ "label", "after_battle" },
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
{ "jump", "end" },
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
-- Oak has already escorted you in (three balls on the table) -> he
-- waves you on to choose; not yet escorted (very early game) -> the
-- "Gramps isn't around" line. #218
{ "label", "pre_starter" },
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
{ "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
{ "jump", "end" },
{ "label", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
},
},
-- Saves that got the Pokédex before #106 never wrote objectToggles for
-- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
end,
-- Oak stops you leaving without a starter; the rival stops you on
-- the way out for the first battle (scripts/OaksLab.asm
-- OaksLabScript8 / OaksLabRivalChallenge)
onStep = function(game, ow, x, y)
local flags = game.save.flags
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
-- Oak stops you leaving without a starter at the bookshelf row (cell
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
-- challenge just below uses (scripts/OaksLab.asm, both gated on
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
-- corridor length later on the exit mat. At y>=6 only the x=4,5
-- corridor is walkable and the up-push keeps the player above y=7, so
-- this only ever fires at y=6 in the corridor -- matching the rival
-- trigger's shape. (#232)
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
and y == 11 and (x == 4 or x == 5) then
and y >= 6 then
ow.runner:run({
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 },
@@ -171,10 +268,21 @@ 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 })
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
ow.runner:run(rows, { npc = rival })
+30 -6
View File
@@ -10,15 +10,39 @@
-- The girl, fisher and the four signs resolve automatically through the
-- extracted text pointers (no script needed).
-- Once the HALL OF FAME has been reached (EVENT_BEAT_CHAMPION_RIVAL, set
-- alongside Commands.record_hall_of_fame) Oak offers the ProfOakData
-- battle (data/trainers/parties.asm) -- three teams picked by the
-- player's starter, mirroring the rival's type-advantage lineup, that go
-- unused in the original ROM.
return {
talk = {
TEXT_PALLETTOWN_OAK = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4
{ "jump", 7 }, -- 5
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 6
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_false", 20 }, -- 3
{ "check_flag", "EVENT_BEAT_PROF_OAK" }, -- 4
{ "jump_if_true", 20 }, -- 5
{ "show_text", "OAK: So you want\nto test your\nskills on me?\f"
.. "Very well! Let\nme show you what\na real trainer\ncan do!" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_PROF_OAK", 3 }, -- 9 CHARIZARD
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_PROF_OAK", 2 }, -- 13 VENUSAUR
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_PROF_OAK", 1 }, -- 15 BLASTOISE
{ "jump_if_false", "end" }, -- 16
{ "set_flag", "EVENT_BEAT_PROF_OAK" }, -- 17
{ "show_text", "OAK: Impressive!\nYou truly are a\nPOKéMON MASTER!" }, -- 18
{ "jump", "end" }, -- 19
{ "check_flag", "EVENT_GOT_STARTER" }, -- 20
{ "jump_if_true", 24 }, -- 21
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" }, -- 22
{ "jump", "end" }, -- 23
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 24
},
},
}
+15 -7
View File
@@ -1,15 +1,23 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
-- "you should rest" / "looking great" dialogue. The intro "wake up"
-- branch is tied to the unported intro cutscene, so the heal path is used.
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
return {
talk = {
TEXT_REDSHOUSE1F_MOM = {
{ "face_player" },
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
{ "heal_party" },
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
{ "jump", "end" }, -- 5
-- RedsHouse1FMomHealScript
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
{ "heal_party" }, -- 8
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
},
},
}
+596 -119
View File
@@ -10,6 +10,31 @@ local M = {}
-- -------------------------------------------------------------------
M.VIRIDIAN_MART = {
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
-- script, not a talk. Entering with a starter and no parcel runs
-- ViridianMartDefaultScript -- the clerk calls out, then
-- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it.
--
-- The talk branch below is kept as the fallback for a save that reaches
-- the counter without this having fired.
onEnter = function(game, ow)
local f = game.save.flags
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
if not f.EVENT_GOT_STARTER then return end
ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
})
end,
talk = {
TEXT_VIRIDIANMART_CLERK = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
M.VIRIDIAN_CITY = {
talk = {
-- the old man napping on the north path (scripts/ViridianCity.asm)
-- after his coffee, the old man offers the catch tutorial: not in a
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
-- only ever grumbles and shoves you back down -- he never wakes,
-- moves or hides. The coffee ask and the catch tutorial belong to
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
{ "jump_if_true", 5 }, -- 2
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
{ "jump", 14 }, -- 4
{ "face_player" }, -- 5
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
{ "jump_if_true", 12 }, -- 7 (in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
{ "old_man_demo" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
{ "move_player", "down", 1 }, -- 2
},
-- The walking old man at (17,5), shown once the Pokédex swaps him in
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
-- off, NO leads into the catch tutorial: he explains, demos a catch
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
TEXT_VIRIDIANCITY_OLD_MAN = {
{ "face_player" }, -- 1
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
{ "old_man_demo" }, -- 5
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
{ "jump", 9 }, -- 7
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
},
},
-- the sleeping old man lies across the Route 22 path (18,9): until
-- he moves you can't slip past on either side (scripts/ViridianCity
-- blocks the whole corridor, not just his tile)
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
-- and the girl (17,9). With the Pokédex the check returns immediately
-- and you simply walk past at x=19.
onStep = function(game, ow, x, y)
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
if gone then return false end
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
if y <= 8 and x >= 17 and x <= 19 then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end
return false
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
if x ~= 19 or y ~= 9 then return false end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end,
}
@@ -102,26 +133,145 @@ M.BLUES_HOUSE = {
M.BILLS_HOUSE = {
talk = {
TEXT_BILLSHOUSE_BILL_POKEMON = {
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
{ "jump_if_true", 11 }, -- 2
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
-- the cell-separator PC throws its switch
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH)
{ "play_sound", "Switch" }, -- 5
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6
-- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
-- (NO only adds "No, you gotta help!" before rejoining the YES
-- path), then the "get in the TELEPORTER" line and the monster
-- walking into the cell-separator machine
-- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
-- when they stand in the way facing down), where it is hidden and
-- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
-- OverworldState.billsHousePC for the separator itself.
TEXT_BILLSHOUSE_BILL_POKEMON = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local t = game.data.text
local function toMachine()
game.stack:push(TextBox.new(game,
t._BillsHouseBillUseSeparationSystemText
or "When I'm in the\nTELEPORTER, run\nthe Cell\nSeparation System!", function()
local function entered()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
done()
end
if ow.player.facing == "down" then
-- the player is standing on his straight path: walk around
-- (.PokemonWalkAroundPlayerMovement)
ow:scriptMove(npc, "right", 1, function()
ow:scriptMove(npc, "up", 2, function()
ow:scriptMove(npc, "left", 1, function()
ow:scriptMove(npc, "up", 1, entered)
end)
end)
end)
else
ow:scriptMove(npc, "up", 3, entered)
end
end))
end
game.stack:push(TextBox.new(game,
t._BillsHouseBillImNotAPokemonText or "Hey! I'm not a\nPOKéMON!",
nil, { choice = function(yes)
if yes then
toMachine()
else
game.stack:push(TextBox.new(game,
t._BillsHouseBillNoYouGottaHelpText
or "No! You gotta\nhelp me!", toMachine))
end
end }))
end,
-- BillsHouseBillSSTicketText (human Bill after the separation)
TEXT_BILLSHOUSE_BILL_SS_TICKET = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_BillsHouseBillThankYouText" }, -- 4
-- pokered gives first (GiveItem fills wStringBuffer), then prints
-- the received text that reads it (scripts/BillsHouse.asm; the
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the
-- sound_get_key_item jingle plays like BillsHouse.asm:196)
{ "give_item", "S_S_TICKET", 1, false }, -- 7
{ "show_text", "_SSTicketReceivedText" }, -- 8
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
{ "give_item", "S_S_TICKET", 1, false }, -- 5
{ "show_text", "_SSTicketReceivedText" }, -- 6
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
-- The two Cerulean guards are a SWAP PAIR, not scenery
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
-- house's south door at (27,11), and that house is one of the two
-- ways through the fence that splits Cerulean in half (the badge
-- house is the other). Leaving GUARD2 up forever severs the city --
-- the gym/mart half can never reach the Route 5 exit.
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
-- CeruleanHideRocket), so either route opens the path.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 12
},
TEXT_BILLSHOUSE_BILL_CHECK_OUT_MY_RARE_POKEMON = {
{ "face_player" }, -- 1
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 2
},
},
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_GOT_SS_TICKET
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
flags.EVENT_MET_BILL = true
flags.EVENT_MET_BILL_2 = true
end
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
-- load still matches the toggles if the pool was rebuilt.
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
end,
}
-- -------------------------------------------------------------------
-- Route 25 outside Bill's house (scripts/Route25.asm
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
-- -------------------------------------------------------------------
M.ROUTE_25 = {
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
if not flags.EVENT_MET_BILL_2 then
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
return
end
if not flags.EVENT_GOT_SS_TICKET then return end
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end,
}
-- -------------------------------------------------------------------
@@ -142,17 +292,63 @@ M.VERMILION_CITY = {
game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2
end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp. Stepping onto it facing down always runs the
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
-- ticket while the ship is still docked lets the player continue --
-- otherwise they are walked back up. The sailor himself never hides.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local Flags = require("src.script.Flags")
local t = game.data.text
local TextBox = require("src.render.TextBox")
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
if shipLeft then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
-- (walk back). Returning true while the box is up also blocks the
-- dock warp on this step.
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
if hasTicket then
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")))
return true
end
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end,
talk = {
-- the sailor guarding the dock gangway
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
-- flashing the ticket just lets you through -- he never hides, and
-- once the ship has sailed he only reports it gone
TEXT_VERMILIONCITY_SAILOR1 = {
{ "face_player" }, -- 1
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2
{ "check_item", "S_S_TICKET" }, -- 3
{ "jump_if_false", 8 }, -- 4
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6
{ "jump", 9 }, -- 7
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
{ "jump", 12 }, -- 8
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
},
},
}
@@ -175,18 +371,21 @@ M.SS_ANNE_2F = {
M.SS_ANNE_CAPTAINS_ROOM = {
talk = {
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
{ "jump_if_true", 9 }, -- 2
{ "jump_if_true", 10 }, -- 2
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
{ "play_once", "Music_PkmnHealed" }, -- 4
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
-- fills wStringBuffer; the received text reads it)
{ "give_item", "HM_CUT", 1, false }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
{ "jump", 10 }, -- 8
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
{ "give_item", "HM_CUT", 1, false }, -- 6
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
},
},
}
@@ -196,7 +395,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
-- MrFujisHouse.asm; the teleport back to his house is a warp)
-- -------------------------------------------------------------------
-- scripts/PokemonTower7F.asm: after each Rocket loses,
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
-- prints its AfterBattle text (DisplayTextID), then
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
-- (HideObject). Without this the beaten grunts stood in the corridor
-- forever (#200).
local POKEMON_TOWER_7F_ROCKETS = {
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
after = "_PokemonTower7FRocket1AfterBattleText" },
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
after = "_PokemonTower7FRocket2AfterBattleText" },
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
after = "_PokemonTower7FRocket3AfterBattleText" },
}
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
-- at the engagement (both the talk and the sight walk-up leave the player on
-- the tile the vanilla table indexes). Each list is that entry's
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
local POKEMON_TOWER_7F_EXITS = {
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
["10,11"] = { "down", "right", "down", "down", "down", "down" },
["11,11"] = { "down", "down", "down", "down", "down" },
["12,11"] = { "down", "down", "down", "down", "down" },
},
[2] = { -- ROCKET2 @ (12,9), faces LEFT
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
["11,9"] = { "down", "down", "down", "left", "down", "down" },
["10,9"] = { "down", "down", "down", "down", "down" },
["9,9"] = { "down", "down", "down", "down", "down" },
},
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
["10,7"] = { "down", "down", "down", "down", "down" },
["11,7"] = { "down", "down", "down", "down", "down" },
["12,7"] = { "down", "down", "down", "down", "down" },
},
}
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
-- side) safe -- it still clears the corridor and despawns the grunt.
local POKEMON_TOWER_7F_EXIT_FALLBACK =
{ "down", "down", "down", "down", "down" }
M.POKEMON_TOWER_7F = {
-- Repair saves that beat a grunt before the exit walk was ported: in
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
-- still gone on re-entry. hide_object below writes objectToggles for new
-- wins; this hides any grunt whose BEAT flag is already set.
onEnter = function(game, ow)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
if game.save.flags[r.beat] then
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
end
end
end,
-- runVictoryHook fires after every trainer win on the map (both the sight
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
-- this walks off whichever grunt was just beaten but is still standing.
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
-- walk, despawn.
onVictory = function(game, ow)
if ow.runner:isRunning() then return end
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
local npc = ow:npcByIndex(r.index)
if npc and game.save.flags[r.beat] then
local key = ow.player.cellX .. "," .. ow.player.cellY
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
or POKEMON_TOWER_7F_EXIT_FALLBACK
ow.runner:run({
{ "show_text", r.after }, -- DisplayTextID
{ "walk_npc", r.index, dirs }, -- MoveSprite
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
}, { npc = npc })
return
end
end
end,
talk = {
TEXT_POKEMONTOWER7F_MR_FUJI = {
{ "face_player" }, -- 1
@@ -208,7 +493,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 +638,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
@@ -363,7 +702,8 @@ M.SILPH_CO_11F = {
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch.
-- drops a boulder down to the 2F switch, and also dungeon-warps the
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- -------------------------------------------------------------------
local function boulderAt(ow, x, y)
@@ -429,6 +769,18 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
}
-- -------------------------------------------------------------------
@@ -441,6 +793,16 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -449,43 +811,69 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
}
M.CHAMPIONS_ROOM = {
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
if ow.player.cellY < 7 then return end
local rival
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
break
end
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
talk = {
TEXT_CHAMPIONSROOM_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 24 }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 24 }, -- 6
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
{ "show_text", "_ChampionsRoomOakText" }, -- 10
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 13
{ "face_object", 2, "down" }, -- 14
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 16
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 22
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
},
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
},
}
@@ -515,6 +903,29 @@ M.HALL_OF_FAME = {
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
if toggles then toggles.HALLOFFAME_OAK = nil end
-- Make the Hall of Fame recording machine interactable. The console
-- juts from the north wall as the two solid cells (4,1) and (5,1); a
-- sign on each lets the player face the machine (from below, or from
-- either side) and press A to run the TEXT_HALLOFFAME_PC talk script.
-- maps.lua is generated from the ROM and gitignored, so the sign is
-- injected here (a tracked script) rather than baked into map data.
local def = ow.map.def
def.signs = def.signs or {}
local present = false
for _, s in ipairs(def.signs) do
if s.text == "TEXT_HALLOFFAME_PC" then present = true break end
end
if not present then
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 4, y = 1 })
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 5, y = 1 })
end
-- the live Map instance built its signAt lookup from def.signs before
-- this hook ran, so rebuild it (idempotent) to pick up the injection
ow.map.signAt = {}
for _, s in ipairs(def.signs) do
ow.map.signAt[s.y * ow.map.widthCells + s.x] = s
end
if not game.save.pendingHallOfFame then return end
game.save.pendingHallOfFame = false
ow:queueScript({
@@ -528,6 +939,21 @@ M.HALL_OF_FAME = {
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
})
end,
talk = {
-- The recording machine doubles as a "warp home" PC: a YES/NO prompt
-- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM.
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away
{ "heal_party" }, -- 3
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
},
},
}
-- -------------------------------------------------------------------
@@ -538,35 +964,86 @@ M.HALL_OF_FAME = {
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "jump", 10 }, -- 9
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 13 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", "end" }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, { "right", "down", "down", "down",
"down", "down", "down" } }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
{ "jump", "end" }, -- 12
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13
},
},
}
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
-- at (15,5) is the ON_LEFT case.
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
{ "right", "down", "down", "right", "down", "down", "right", "right" }
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
{ "down", "down", "right", "right", "right", "right", "down", "down" }
local function pokemonTower2FRivalScript(playerX)
local exitDirs = (playerX == 15)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
}
end
M.POKEMON_TOWER_2F = {
rivalScript = pokemonTower2FRivalScript,
talk = {
TEXT_POKEMONTOWER2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
},
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
{ npc = npc, onDone = done })
end,
},
-- Saves that beat him before the exit walk was ported still have the
-- flag but a visible rival; hide on enter like BillsHouse repairs.
onEnter = function(game, ow)
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
end,
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return false end
if not ((x == 15 and y == 5) or (x == 14 and y == 6)) then return false end
if ow.runner:isRunning() then return false end
local rival = ow:npcByIndex(1)
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
return true
end,
}
-- -------------------------------------------------------------------
+290 -78
View File
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
-- (engine/items/inventory.asm), which walks the same three item ids and
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
local function takeGuardDrink(game)
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
return true
end
end
return false
end
local function saffronGate(guardText, triggers, horizontal)
return {
talk = {
@@ -289,18 +306,15 @@ local function saffronGate(guardText, triggers, horizontal)
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
return
end
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done))
return
end
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"You can go on\nthrough!"), done))
end))
return
end
game.stack:push(TextBox.new(game,
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
@@ -317,6 +331,31 @@ local function saffronGate(guardText, triggers, horizontal)
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- Stepping on the trigger WITH a drink hands it over right here.
--
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
-- `farcall RemoveGuardDrink` before it decides anything: the coord
-- trigger itself takes the drink and sets
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
-- gets the thirsty line and the walk-back. We had the removal on the
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
-- bag was turned away and the four gates stayed shut unless you
-- happened to talk to him -- which vanilla never requires.
--
-- Saffron is the middle of the map, so this sealed it: every route
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
-- short way) was unreachable, and the bot could not get to Lavender
-- for the POKE_FLUTE at all.
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"You can go on\nthrough!")))
end))
return true
end
local back
if horizontal then
back = ow.player.facing == "left" and "right" or "left"
@@ -324,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
back = ow.player.facing == "up" and "down" or "up"
end
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!",
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
function()
ow:scriptMove(ow.player, back, 1)
end))
@@ -386,40 +425,112 @@ M.BIKE_SHOP = {
-- the Cinnabar lab (the wait is skipped).
-- -------------------------------------------------------------------
local function mtMoonFossil(itemId, otherName)
-- 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
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then
-- UP (MoveRight falls through into MoveUp); near-helix walks UP only.
local function mtMoonNerdWalk(px, py, itemId)
if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then
return { "right", "up" }
end
if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then
return { "up" }
end
return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" }
end
-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd +
-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the
-- other, "All right. Then this is mine!", hide the other.
local function mtMoonFossil(itemId, otherName, gotFlag)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
if game.save.flags.EVENT_GOT_A_FOSSIL then
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
local t = game.data.text
local flags = game.save.flags
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
done()
return
end
game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
game.save.flags.EVENT_GOT_A_FOSSIL = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
local name = game.data.items[itemId].name
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local want = (itemId == "DOME_FOSSIL")
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
if not yes then done() return end
if not require("src.inventory.Bag").add(game.save, itemId, 1) then
game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
end))
t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
done))
return
end
local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
function()
local Commands = require("src.script.Commands")
Commands.hide_object(
{ save = game.save, overworld = ow, game = game },
"MT_MOON_B2F", npc.def.name)
flags[gotFlag] = true
ow.runner:run({
{ "walk_npc", 1, dirs },
{ "text_opts", { auto = true } },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end))
end }))
end
end
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"),
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
},
}
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
-- Y50, once; declining at the rope walks you back out.
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
-- the exhibit rope they crossed heading north (#151); the museum floor has no
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -454,7 +565,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "down", 1)
end)
return true
end
@@ -646,73 +757,174 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- -------------------------------------------------------------------
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
-- step; the fee is ¥100 plus ¥100 per level gained.
--
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
local function fillDaycareText(s, subs)
s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
-- extractor NUM spans may include flags after a comma; keep the name
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
return tostring(subs[name] or "0")
end)
return s
end
M.DAYCARE = {
talk = {
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local dc = game.save.daycare
local playerName = game.save.player and game.save.player.name or "RED"
local function monName(mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or (def and def.name) or mon.species
end
if dc and dc.mon then
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local Party = require("src.pokemon.Party")
local mon = dc.mon
local def = game.data.pokemon[mon.species]
mon.exp = mon.exp + (dc.steps or 0)
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
local fee = 100 + (newLevel - mon.level) * 100
local grew = newLevel > mon.level
mon.level = newLevel
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
or "Back already?"
game.stack:push(TextBox.new(game,
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes and game.save.money >= fee then
-- Apply deferred step-exp once (OverworldController only bumps
-- daycare.steps). Clearing prevents double-count on re-talk.
mon.exp = (mon.exp or 0) + (dc.steps or 0)
dc.steps = 0
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
-- must survive a declined retrieve. Fall back to mon.level for
-- older saves that predate the field.
if dc.depositLevel == nil then dc.depositLevel = mon.level end
local startLevel = dc.depositLevel
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
if newLevel >= 100 then
newLevel = 100
if def then
mon.exp = Growth.expForLevel(def.growthRate, 100)
end
end
local levelsGrown = math.max(0, newLevel - startLevel)
local fee = 100 + levelsGrown * 100
local name = monName(mon)
local subs = {
player = playerName,
wNameBuffer = name,
wDayCareMonName = name,
wDayCareNumLevelsGrown = levelsGrown,
wDayCareTotalCost = fee,
}
local statusText = levelsGrown > 0
and (t._DaycareGentlemanMonHasGrownText
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
or (t._DaycareGentlemanMonNeedsMoreTimeText
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNoRoomForMonText
or "You have no room\nfor this POKéMON!", done))
return
end
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanOweMoneyText
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
subs),
nil, { choice = function(yes)
if not yes then
-- .leaveMonInDayCare: revert any transient level bump
mon.level = startLevel
game.stack:push(TextBox.new(game,
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
.. (t._DaycareGentlemanComeAgainText or "come again."),
done))
return
end
if (game.save.money or 0) < fee then
mon.level = startLevel
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNotEnoughMoneyText
or "Hey, you don't\nhave enough ¥!", done))
return
end
game.save.money = game.save.money - fee
mon.level = newLevel
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare
local Pokemon = require("src.pokemon.Pokemon")
Pokemon.learnMovesFromDayCare(
game.data, mon, def, startLevel, newLevel)
end
table.insert(game.save.party, mon)
game.save.daycare = nil
game.stack:push(TextBox.new(game,
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
else
game.stack:push(TextBox.new(game,
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
or "Come again!", done))
end
end))
t._DaycareGentlemanHeresYourMonText
or "Thank you! Here's\nyour POKéMON!", function()
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanGotMonBackText
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
subs), done))
end))
end }))
end))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
"You only have one\nPOKéMON with you!", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
game.save.daycare = { mon = mon, steps = 0 }
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWillLookAfterMonText or
"Fine, I'll look\nafter it a while!", done))
end,
}))
end))
end))
t._DaycareGentlemanIntroText
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeAgainText or "come again.", done))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanOnlyHaveOneMonText
or "You only have one\nPOKéMON with you.", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
function()
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
local name = monName(mon)
-- depositLevel = wDayCareMonBoxLevel at deposit time
game.save.daycare = {
mon = mon, steps = 0, depositLevel = mon.level,
}
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanWillLookAfterMonText
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
{ player = playerName, wNameBuffer = name }),
function()
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeSeeMeInAWhileText
or "Come see me in\na while.", done))
end))
end,
}))
end))
end }))
end,
},
}
+410 -90
View File
@@ -61,9 +61,61 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
-- -------------------------------------------------------------------
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
-- true from onStep so wild encounters are skipped the same way).
-- -------------------------------------------------------------------
local TOWER_5F_PURIFIED = {
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
}
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
local TOWER_5F_HEAL = {
{ "heal_party" },
{ "fade", "out", "white" },
{ "wait", 3 },
{ "wait", 3 },
{ "fade", "in", "white" },
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
}
M.POKEMON_TOWER_5F = {
onStep = function(game, ow, x, y)
if not TOWER_5F_PURIFIED[x * 256 + y] then
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
return false
end
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
return true
end
if ow.runner and ow.runner:isRunning() then return false end
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
if ow.runner then
ow.runner:run(TOWER_5F_HEAL)
elseif ow.queueScript then
ow:queueScript(TOWER_5F_HEAL)
end
return true
end,
}
-- -------------------------------------------------------------------
-- 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 +123,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))
@@ -136,18 +194,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
-- (warp id, map id): point every exit warp at the picked floor's
-- elevator-door warp (the reciprocal warp found while building the
-- menu). The car map's def is shared generated data, but its own
-- warps are only ever read from inside the car, and this rewrite runs
-- on every ride before the walk-out fires, so it is self-correcting.
for _, w in ipairs(m.def.warps) do
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
-- warps are only read from inside the car; rides rewrite them again.
local function elevatorSetExit(ow, floor)
if not floor then return end
for _, w in ipairs(ow.map.def.warps) do
w.destMap = floor.map
w.destWarp = floor.warpIdx
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
@@ -185,37 +245,68 @@ local function elevatorWalkOut(ow, floor)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
return floors
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
local exitFloor = floors[1]
if fromMapId then
for _, f in ipairs(floors) do
if f.map == fromMapId then exitFloor = f break end
end
end
elevatorSetExit(ow, exitFloor)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
return {
onEnter = function(game, ow)
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) — #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
@@ -248,7 +339,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
end,
}))
end,
@@ -260,6 +352,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
-- objects; without this talk side-effect the key never appears (#90,
-- #105).
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._RocketHideoutB4FRocket3AfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!",
function()
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
-- after the win reveals the ball; later talks only reprint.
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
if ow:trainerDefeated(npc)
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain...", done))
return
end
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
or "So! I must say, I\nam impressed you\ngot here!"
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
or "WHAT!\nThis cannot be!"
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
done()
return
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end))
end
ow:pushBattle(battle)
end))
end,
},
}
-- -------------------------------------------------------------------
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -272,14 +456,23 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster
if not poster then return end
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
if poster then
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
end
-- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats)
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
-- never hid him; clear the tile if he is already marked defeated
local rocketId = "GAME_CORNER_obj_11"
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
end,
talk = {
-- the poster bg event: pressing A reveals the hidden switch
@@ -303,6 +496,48 @@ M.GAME_CORNER = {
done()
end))
end,
-- the grunt guarding the poster (GameCornerRocketText /
-- GameCornerRocketBattleScript / GameCornerRocketExitScript): after
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
local Commands = require("src.script.Commands")
local function hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
-- already beaten: hide anyway so pre-#50 saves that only have
-- defeatedTrainers (no objectToggles hide) clear the poster tile
if ow:trainerDefeated(npc) then
hideRocket()
done()
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
-- ApplyMovementData walks the grunt one tile UP into the poster
-- (the hideout's secret entrance at 9,4) before HideObject, so
-- he leaves the floor rather than popping out of existence on
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
-- ignores collision, so we despawn + unfreeze (done) only once
-- the step lands.
ow:scriptMove(npc, "up", 1, function()
hideRocket()
done()
end)
end))
end)
end,
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -349,53 +584,93 @@ M.GAME_CORNER = {
},
}
-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm)
local PRIZES = {
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
}
local RED_PRIZES = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local BLUE_PRIZES = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(PRIZES) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items, { label = label, right = tostring(p.cost), value = p })
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
end
M.GAME_CORNER_PRIZE_ROOM = {
@@ -411,18 +686,63 @@ M.GAME_CORNER_PRIZE_ROOM = {
-- and the player steps off the dock, the ship sets sail.
-- -------------------------------------------------------------------
-- the ship's hull/deck blocks (block cols 5-8, rows 1-2) and the water
-- that replaces them once she sails (the surrounding blocks of each row)
local DOCK_SHIP_BLOCKS = {
{ bx = 5, by = 1, water = 1 }, { bx = 6, by = 1, water = 1 },
{ bx = 7, by = 1, water = 1 }, { bx = 8, by = 1, water = 1 },
{ bx = 5, by = 2, water = 13 }, { bx = 6, by = 2, water = 13 },
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
M.VERMILION_DOCK = {
onEnter = function(game, ow)
if game.save.flags.EVENT_SS_ANNE_LEFT then
local Flags = require("src.script.Flags")
local f = game.save.flags
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
ow.map:setBlock(b.bx, b.by, b.water)
end
ow.map.renderer:rebuild()
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 19, 30, "up")
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end))
elseif game.save.flags.EVENT_GOT_HM01 then
game.save.flags.EVENT_SS_ANNE_LEFT = true
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
-- drift off the funnel, the horn blows, the ship is erased to
-- open water, and the player is walked off the dock into the
-- city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
local Music = require("src.core.Music")
Music.stop()
Music.play(game.data, "Music_Surfing")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
end
end,
}
+205 -19
View File
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
-- -------------------------------------------------------------------
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
-- is ever hidden (Gen1 removes just the chosen ball).
local function dojoBall(species, ownBall, otherBall, askKey)
return function(game, ow, npc, done)
local t = text(game)
local flags = game.save.flags
-- Already took one prize: the OTHER ball is still on the mat, so
-- talking to it now gives the "Better not get greedy..." refusal
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
-- not a silent no-op (#197).
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
done()
push(game, t._FightingDojoBetterNotGetGreedyText
or "Better not get\ngreedy...", done)
return
end
if not flags.EVENT_BEAT_KARATE_MASTER then
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.give_pokemon(ctx, species, 30)
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
-- just the picked object's index) and routes to the greedy line above
-- when talked to (#197).
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
end)
end
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
"_FightingDojoHitmonchanPokeBallText"),
},
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
-- bg_events (signs = {}), so wire the poster read here, the same
-- facing-up + coord shape the mansion switches use. Reachable only once
-- a claimed prize frees the ball cell below the poster (#197).
onInteract = function(game, ow, fx, fy)
if ow.player.facing ~= "up" then return false end
if fy == 0 and (fx == 4 or fx == 5) then
push(game, text(game)._EnemiesOnEverySideText
or "Enemies on every\nside!")
return true
end
return false
end,
}
-- -------------------------------------------------------------------
@@ -212,30 +236,31 @@ M.COPYCATS_HOUSE_2F = {
talk = {
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
local t = text(game)
local Bag = require("src.inventory.Bag")
if game.save.flags.EVENT_GOT_TM31 then
push(game, t._CopycatsHouse2FCopycatWhirlingText
or "Huh? Huh? Huh?", done)
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
return
end
if (game.save.inventory.POKE_DOLL or 0) > 0 then
ask(game, t._CopycatsHouse2FCopycatPokeDollText
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
if not yes then done() return end
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1
if game.save.inventory.POKE_DOLL == 0 then
game.save.inventory.POKE_DOLL = nil
end
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then
push(game, "You don't have\nroom for TM31!", done)
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
if (game.save.inventory.POKE_DOLL or 0) <= 0 then
done()
return
end
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
if not Bag.add(game.save, "TM_MIMIC", 1) then
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
return
end
game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true
push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
end)
return
end
push(game, t._CopycatsHouse2FCopycatText
or "I like to mimic\npeople!", done)
end)
end,
},
}
@@ -457,6 +482,25 @@ M.ROUTE_24 = {
battleOrDone()
end,
},
-- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the
-- player stands on (10,15) in front of the recruiter while
-- EVENT_GOT_NUGGET is unset (dbmapcoord 10,15), independent of facing
-- or input -- he has no trainer header, so sight never engages him.
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_GOT_NUGGET then return false end
if not (x == 10 and y == 15) then return false end
if ow.runner:isRunning() or ow.engaging then return false end
local recruiter
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "ROUTE24_COOLTRAINER_M1" then
recruiter = npc
break
end
end
if not recruiter then return false end
ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter)
return true
end,
}
-- -------------------------------------------------------------------
@@ -626,11 +670,153 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
}
end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
}
-- -------------------------------------------------------------------
-- 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
-- landing on (6,11). Vanilla WalkToLance_RLEList is
-- up 12 / left 12 / down 7 / left 6 and skips collision
-- through void tiles; our camera follows that literally
-- and briefly shows the empty upper chamber (reads as
-- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
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)
local i = 0
local function step()
i = i + 1
local seg = LANCE_WALK_IN[i]
if not seg then
-- 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)
return
end
ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
end
M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
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)
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
ow:engageTrainer(lance, function()
if not ow:trainerDefeated(lance) then return end
local after = text(game)._LancesRoomLanceAfterBattleText
if after then push(game, after) end
end)
return true
end
if (x == 5 or x == 6) and y == 11 then
lanceLockDoor(game, ow)
return false
end
if x == 24 and y == 16 then
lanceWalkIn(game, ow)
return true
end
return false
end,
}
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
return M
+237 -23
View File
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
return false
end
-- coordinate block: show a line and push the player back one step
-- coordinate block: show a line and shove the player back one step. pokered
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
local function stepGate(opts)
return function(game, ow, x, y)
if not inCoords(opts.coords, x, y) then return false end
if not opts.blocked(game) then return false end
require("src.core.Sound").play(game.data, "Denied")
push(game, text(game)[opts.text] or opts.fallback,
function() ow:scriptMove(ow.player, opts.push, 1) end)
push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
end
end)
return true
end
end
@@ -259,23 +269,183 @@ M.CINNABAR_ISLAND = {
end,
}
-- Pewter's youngster stops you leaving east before Brock is beaten
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the
-- original escorts you to the gym, we walk you back a step)
-- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
-- youngster walks RLEList_PewterGymGuy while the player plays the
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
-- the building.
local pewterEscort = {}
-- RLEList_PewterGymGuy (NPC directions play forward)
pewterEscort.guySteps = {
"down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"down", "down", "down", "down", "down",
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
for i = #pewterEscort.guySteps, 1, -1 do
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
end
pewterEscort.guyReturnSteps = ret
end
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
pewterEscort.playerRle = {
"NO",
"right", "right",
"down", "down", "down", "down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
}
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
-- the NPC, so eight of them ≈ one guy head-start step)
pewterEscort.preambles = {
["34,16"] = { "left", "down", "down", "right" },
["35,17"] = { "left", "down", "right", "left" },
["37,18"] = { "left", "left", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
["37,19"] = { "left", "left", "up", "left" },
["36,17"] = { "left", "down", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
}
-- Realized player path for a trigger tile: PewterGuys overwrites the
-- last RLE byte and appends the preamble, then simulated joypad plays
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
-- trailing end-of-list NO is dropped (one-frame pause).
function pewterEscort.playerPlan(x, y)
local pre = pewterEscort.preambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
local function pewterGymEscort(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
local x, y = ow.player.cellX, ow.player.cellY
local plan = pewterEscort.playerPlan(x, y)
local Music = require("src.core.Music")
local t = text(game)
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
-- PewterGuys only has entries for five tiles; an unmatched talk tile
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
-- failed coords lookup would refuse to arm the walk.
if not plan then
push(game, follow)
return
end
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
local guySteps = pewterEscort.guySteps
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
end
tick()
end
local function afterWalk()
if guy then guy.facing = "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = guySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
local function beginWalk()
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, guySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
end
push(game, follow, beginWalk)
end
M.PEWTER_CITY = {
escort = pewterEscort,
-- PewterCityYoungsterText: talking also arms the gym escort script
talk = {
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
pewterGymEscort(game, ow)
if done then done() end
end,
},
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false
end
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function()
ow:scriptMove(ow.player, "left", 1)
end)
end)
pewterGymEscort(game, ow)
return true
end,
}
@@ -297,6 +467,19 @@ end
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
-- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too.
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
local function route22ExitDirs(n, py)
if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" }
end
if py == 5 then
return { "right", "right", "down", "down", "down", "down", "down" }
end
return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" }
end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
return {
{ "show_object", "ROUTE_22", objName }, -- 1
@@ -308,7 +491,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "move_npc_to", objIndex, 25, 5 }, -- 10
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11
}
end
@@ -335,6 +518,14 @@ M.ROUTE_22 = {
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
-- cave guard who steps aside once you are Champion.
-- Post-battle: Bill text then CeruleanCityMovement3/4 into town.
local function ceruleanRivalExitDirs(px)
if px == 20 then
return { "right", "down", "down", "down", "down", "down", "down" }
end
return { "left", "down", "down", "down", "down", "down", "down" }
end
local function ceruleanRivalScene(px, py)
return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
@@ -342,32 +533,43 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "move_npc_to", 1, 20, 2 }, -- 9
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
}
end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
{ "jump_if_true", 15 }, -- 3
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 16 }, -- 8
{ "jump_if_false", "end" }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
{ "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour — the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
}
M.CERULEAN_CITY = {
@@ -444,10 +646,22 @@ local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end
if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it
-- (Route16Gate1FDefaultScript's wCoordIndex-1). Forcing a fixed one
-- tile up from the counter-adjacent row shoved the player onto the
-- impassable desk and boxed them in.
local closestY = coords[1][2]
for _, c in ipairs(coords) do
if c[2] < closestY then closestY = c[2] end
end
local dist = y - closestY
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
ow:scriptMove(ow.player, "up", 1)
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
end
end)
end)
return true
+100 -12
View File
@@ -2,27 +2,111 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- 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).
--
-- `dialogue` is the end-battle + post-battle text chain each gym leader
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
-- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won — checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub.
local function range(prefix, first, last)
local t = {}
for i = first, last do
t[#t + 1] = prefix .. i
end
return t
end
return {
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
-- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE" },
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM" },
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText",
"_CeruleanGymMistyReceivedTM11Text",
} },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT" },
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText",
"_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText",
} },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN" },
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText",
"_CeladonGymReceivedTM21Text",
"_TM21ExplanationText",
} },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC" },
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText",
"_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText",
} },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE" },
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText",
"_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText",
} },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST" },
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText",
"_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText",
} },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE" },
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText",
"_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
@@ -30,8 +114,12 @@ return {
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" },
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
-- concede + prize offer he speaks after the win (his header supplies the
-- "Hwa! Arrgh! Beaten!" won line first; #197).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+34 -1
View File
@@ -6,4 +6,37 @@ work for true parity. Faithfully-ported behavior is documented in
docs/behavior-porting-notes.md; deliberate additions beyond the original
are in docs/new-features.md.
None currently.
## Reimplemented unused Prof. Oak and Rocket Chief battles
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
both battles are unreachable in the real game. This project makes them
fightable after the Hall of Fame:
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
is set, using `ProfOakData`'s three starter-matched teams (the team is
picked by the type that counters your starter, mirroring the rival).
- The Celadon Game Corner Chief battles you in his house post-game.
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
reconstructed party.
This is an intentional divergence: neither battle can be triggered in the
original game.
## Reimplemented unused Silph Co. card-key doors
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
door block at those coordinates, so the card key check is dead code in
the original game. This project stamps the closed door block (`$54`/`$5f`
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
map load, and swaps it for the open block once that door's
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
Rocket grunt, as in the original's unused design).
This is an intentional divergence: the doors are not visible or
functional in the original game. The door layout lives in
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
it straight through on ROM import.
+163
View File
@@ -0,0 +1,163 @@
# Launcher
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import
On Android, `love.system.pickFile()` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red.
## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
centered pill in a reserved band just above the footer, on every tab. That
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
## Save slot model
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
abstraction (`persistFs`) every other save/options call uses, so portable
mode (an `io.*` filesystem used when `portable.txt` marks the install)
keeps working unchanged.
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
`slot%d+`; `createSlot` allocates one past the highest existing number so a
reused id can never collide with a lingering file.
- **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
- **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name.
Resolved once per version per process (`ensureVersionSlots`, cached in
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
legacy migration may create one; otherwise the flat legacy path is used
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
- **Legacy migration.** One-time per version, lazy on first
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
file exists and no `saves/<version>/` registry does, its main + `.bak` are
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
originals removed and `slot1` registered as active. A copy that fails to
verify leaves the originals in place; migration never loses data.
The launcher-facing API:
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
every registered slot. `name` is the save's player name, or `nil` for an
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
title screen's `ContinueInfo` shows) or `nil`. The pure part,
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
it as active, and updates the process cache so the very next save/load
lands there. The launcher calls this the moment a slot row is clicked
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
## Launcher mod manager
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
before `Game:load`, so **it never loads a mod's entry chunk**; only
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
`Loader:_discover` finds mods without running them. The real loader
(`src/mods/Loader.lua`) still owns the actual load at boot.
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
the zip root, or inside one top-level folder), validates its manifest, and
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
duplicate of an already-installed mod id, and accepts either an external
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
## Import / Export save
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
nothing to export, ROM not imported yet) surfaces as a red notice line on the
card. Nothing raises and nothing silently no-ops.
`SaveFileIO` is love-free enough to unit-test through the same in-memory
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
## Responsiveness
Every measurement derives from `love.graphics.getDimensions()` each frame
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
column, slot card below Play, when the window is too narrow for both
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
+140 -44
View File
@@ -1,58 +1,154 @@
# Native modding
The game has a built-in Lua mod runtime. Mods are installed under the LÖVE
save directory in `mods/<id>/` and are loaded after the verified ROM data has
been imported but before the title screen is created.
The modding book lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
## Minimal mod
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
— install a mod, write a first one, enable and disable it.
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
— twelve dependency-ordered rungs, each a runnable mod.
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
— task-sized recipes.
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
— every registry, generated from `src/mods/Schemas.lua`.
```text
mods/example_mod/
├── manifest.json
└── main.lua
Regenerate the reference straight into a wiki checkout:
```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
`manifest.json`:
## Rendering pipelines
```json
{
"id": "example_mod",
"name": "Example Mod",
"version": "1.0.0",
"entry": "main.lua",
"priority": 0,
"dependencies": [],
"optional_dependencies": [],
"conflicts": []
}
```
Most registries hand the engine *content*. `render_pipelines` hands it
*drawing*: a pipeline is a display mode a mod owns, which may replace the
overworld's world pass with geometry of its own and/or post-process the
finished image. `mods/voxel_world` is the worked example — a 3D diorama
overworld plus a tilt-shift miniature pass, in about 120 lines of glue over
its renderer.
`main.lua`:
A record declares what the mode *is*; the engine
(`src/render/Pipelines.lua`) supplies everything about *being a display
mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey,
persistence in `save.options.pipelines`, and the rule that a world pipeline
and the engine's own TILT are mutually exclusive.
```lua
return function(mod)
mod.log:info("hello from a native mod")
mod.content.pokemon:override("PIKACHU", {
name = "PIKACHU",
types = { "ELECTRIC" },
base_stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 },
})
mod.events:on("battle.start", function(context)
context.mod_message = "A native mod changed this battle."
end)
end
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA", -- options row label
levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON
hotkey = "6", -- checked after the engine's keys
priority = 20, -- highest eligible wins the world
available = function() return Renderer3D.ok() end,
update = function(dt, level) Camera.ease(dt, level) end,
drawWorld = function(ctx) return renderScene(ctx) end,
})
```
Mods should use registries and events instead of requiring private engine
modules. Registries currently cover Pokémon, moves, items, maps, tilesets,
encounters, trainers, sprites, music, audio, text, scripts, and UI.
Three draw stages, each optional; a record needs at least one:
Enablement is stored in the normal persistent `options.lua` file alongside
audio, display, and battle settings, so starting a new game does not disable
the selected mods. Changes take effect after restarting the game.
| stage | signature | runs |
| --- | --- | --- |
| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass |
| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites |
| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike |
The loader deliberately does not import or execute arbitrary ROM-hack patches.
The supported content source remains the verified base Pokémon Red ROM plus
native mods.
`worldPresent` is the one to reach for when an effect must leave dialog
boxes and menus crisp — a depth-of-field or colour grade on the world only.
`present` is for effects that genuinely own the screen, like a CRT curve.
`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view),
`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and
`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it
with your own projection and the engine draws every active field effect
(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing
rod, Rock Tunnel darkness) at its correct anchor under your camera. There
is exactly one copy of each effect, so a new engine effect works in your
pipeline without you touching anything.
Three rules worth knowing:
- **`gate` governs input, never the draw.** It decides whether the player
may *change* the mode (default: free-roam overworld only). A mode that
stopped rendering during a warp would flash the flat 2D world every time
the player walked through a door.
- **`available` is re-read every frame** and is the only thing that decides
whether the mode can render at all. Answer `false` on a headless run or a
driver with no depth canvas and the engine silently keeps the vanilla 2D
path — which is why shipping a pipeline enabled is safe.
- **A callback that throws retires its pipeline**, attributed to your mod in
the manager's error feed, and the frame falls back to 2D. A broken
renderer costs the player a display mode, never the game.
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
the Game Boy did. A mod can override either, per species or per image.
Per species, on the `pokemon` record:
```lua
-- MEW's back pic renders 1.5x; its front pic is untouched
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
```
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
both take a number in `0.25 .. 4.0`.
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
exactly as the data references it:
```lua
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})
```
An image-level entry beats the species scale for that one pic, and it is
the only way to scale a pic that is not species-keyed — the player's
trainer back sprite, held on screen until "Go!", is a bare image path.
The resolution order at draw time is **image-level → species-level →
default** (1x front, 2x back).
- **The pic stays grounded at every scale.** The player pic keeps its feet
flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge
and horizontal centre pinned in its 7×7 slot. A larger pic grows upward
and outward from that anchor, never off the shelf.
- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon`
ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
`--developer` on the command line:
```sh
love . --developer
```
While developer mode is active:
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
`data` and `mods` in scope. Press `` ` `` again to close it.
- `F5` hot-reloads mods and asset caches without restarting.
The console understands these verbs (anything else is evaluated as Lua):
- `warp MAP [x y]` — teleport to a map (default cell 5,5).
- `give ID [n|level]` — add an item (count) or a Pokémon (level).
- `flag NAME [on|off]` — read or set an event flag.
- `party` — dump the current party.
- `mods` — list loaded mods and their state.
- `reload` — hot-reload mods (same as `F5`).
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+80 -21
View File
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
## Survey zoom
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world
pixel (full survey) and 2× the window fit scale (close-up), in crisp
integer steps. This has no Game Boy equivalent:
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
the window fit scale (close-up), in crisp integer steps. This has no Game
Boy equivalent:
- Connected maps render their full bodies, and their NPCs appear as
visual-only "ghosts", they wander but have no sight lines, triggers,
dialogue, or collision until the map is actually entered.
- Menus, text boxes, and battles draw at normal scale on top of the
zoomed world. Zoom input is ignored while a script, menu, or battle is
active; the zoom level persists across warps and is never saved.
- Beyond the border ring the border block repeats indefinitely (interiors
stay black, seaside towns stay water, except OVERWORLD-tileset maps,
whose beyond-edge space fills with the solid tree wall instead of the
per-map border block), and each visible map area is colorized with its
own SGB palette (the original recolored the whole screen per map).
active; the zoom offset is persisted as `save.options.zoom` (default
`0` = FIT) and survives New Game via `options.lua`.
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
- Beyond the border ring the void fill repeats indefinitely (see VOID
FILL below); interiors keep their own border block. Each visible map
area is colorized with its own SGB palette (the original recolored the
whole screen per map).
- Neighbor maps load two connection hops out so corner-adjacent maps
don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script
position, like the original's per-entry sprite init).
## VOID FILL
The Options **VOID FILL** row picks what paints the infinite beyond-edge
space on OVERWORLD-tileset maps during survey zoom:
- **TREES** (default): solid tree wall block `$0F`.
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
- **BLACK**: solid black.
Other tilesets are unchanged (house/cave borders stay as authored).
Persisted as `save.options.voidFill`.
## Tilt mode
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
@@ -42,8 +57,10 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the screen-anchored FX attached to them (heal machine glow, emote
bubbles, the fishing rod, the FLY bird). An earlier revision tried
and the standing FX attached to them (emote bubbles, the fishing rod,
the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building
@@ -68,19 +85,31 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the global shade-remap
display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**:
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
The first three are the real colorizations; the rest are DMG-shade novelties:
- **GBC** (default): current SGB / GBC zone palettes.
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
red BG palette + one green OBJ palette, every map, no per-map variation
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
The player/NPCs stay green over the red terrain via the OBP bake +
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`).
- **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays.
- **GBC INV**: each SGB zone palette with shade order reversed.
- **SGB INV**: each SGB zone palette with shade order reversed.
- **CLASSIC**: original Game Boy pea-soup greens
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
The transform is applied centrally in `PaletteFX.sendColors`, so it covers
overworld, menus, battles, and tilt upright billboards. Persisted as
`save.options.colors`.
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
## GBC FX
@@ -125,13 +154,43 @@ Nidorino-vs-Gengar attract scene) mirror the original.
Options persist in a standalone `options.lua` (separate from the game
progress `save.lua`), so audio/display/battle preferences survive New Game
and aren't wiped when a save slot is cleared. Changing a row in the Options
menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
flushes the live options. Old saves that still embed an `options` table are
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 (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; 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`
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
`-`/`=` step one level and save
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
## Battle transition cascade + white battle letterbox
Into-battle wipes still run the original eight styles inside the classic
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
center) over the frame, using Xelu's CC0 controller prompts
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
finger does, sliding on the d-pad changes direction without lifting, and
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
appears while no controller is being used: the first gamepad button or
stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
+126
View File
@@ -0,0 +1,126 @@
# Updater
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
baked into the executable, but that bundled copy is only ever the *fallback*.
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
looks in the save directory's `updates/` folder for a downloaded
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
engine version and runnable on this shell. If one qualifies, it is mounted
over `/` (so its files win over the fused source for every subsequent
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
run as if they had shipped in the executable. A dev/source checkout is never
fused, so `Boot.run` no-ops there and the working tree always runs itself.
The pieces are deliberately layered so the risky part is small. `Boot.select`
is a pure function (no `love.*` calls) that, given probed candidates and the
bundled `engine`/`shell`, decides what to run and what stale payloads to
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
orchestrates the crash guard, enumeration, selection, and the mount +
chainload, with full rollback on any failure. Checking for and fetching a
new payload is a separate, slower path: `src/update/Check.lua` is a thin
main-thread state machine the launcher screen polls, while the curl calls,
JSON parsing, and sha256 verification run on a background `love.thread`
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched):
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
reports itself up to date (it never chases a release, and it never counts
as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable
implements.
- `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped
native shell cannot provide, for example a LOVE version bump, a new required
system binary, or a change to `love.run` itself (see Known limitations
below). An older shell refuses to chainload a payload whose `minShell`
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
rather than deleting it, in case a future shell upgrade can run it, and
`Check`'s worker reports `needs_full` so the player is pointed at a full
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-android.apk`) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
`Check.parseRelease`).
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
filenames) covering at least the `.love` payload. `Check.parseSums`
tolerates a leading `*` binary marker and a `./` prefix but expects the
filename otherwise to match the asset name exactly.
A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
## Save-directory layout
Under the save directory (identity `pokemon-love2d`):
```
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker
```
`pending.txt` holds the filename of the payload currently being chainloaded.
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
on both a successful handoff and a clean rollback. If it is still present the
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
named payload is distrusted: it and the marker are deleted before candidates
are enumerated. Boot may still fall back to an older valid payload, or to the
bundled game, in that case.
## Update flow
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
and probe every `updates/*.love`, pick the highest engine that is
strictly newer than the bundled one and whose `minShell` this shell
satisfies, delete stale payloads, chainload the winner (or run the
bundled game if none qualifies).
2. **Check** (launcher screen): `Check.start()` kicks off an async check
against the GitHub releases API; safe to call every frame, it is a no-op
once a check is in flight or has reached a terminal state. `Check.state()`
reports `idle | checking | uptodate | available | downloading | ready |
needs_full | error` plus the latest version and download progress.
3. **Download + verify**: on `available`, `Check.download()` tells the
worker to fetch the payload, polling the growing `.part` file for
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
the payload's sha256, and probes it with `Boot.probePayload` to gate its
`minShell` against this shell's `shell`. A verified, runnable payload is
renamed into place and reported as `ready`; anything else reports
`error` or `needs_full` and leaves `updates/` clean.
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
player relaunches; the next launch's Boot step (1) is what actually
mounts and runs it. There is no in-session hot-swap.
## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop
already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua`
shells out to curl for both the release check and the download; curl is
absent on Android, so `Check` degrades to `status = "error"` there (the
launcher UI hides on that status) and the player is directed to the
releases page via `Check.releaseUrl()` instead.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
checkout is always "the game" itself; updating it means pulling the repo.
+224 -25
View File
@@ -13,7 +13,37 @@ 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
local function bootGame()
-- --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"))
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
-- mouse stand in for a finger, so the overlay can be exercised on desktop
-- (see src/core/TouchControls.lua).
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
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(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
-- overlay its extracted cache BEFORE anything requires generated data, so
-- data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end
Game = require("src.core.Game")
Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then
@@ -24,15 +54,29 @@ 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)
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
local Boot = require("src.update.Boot")
if Boot.run(args) then return end
local savePath
for i, a in ipairs(args or {}) do
if a == "--editor" then
editorMode = true
elseif a == "--developer" then
_G.POKEPORT_DEV_MODE = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then
speedOverride = tonumber(args[i + 1])
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
@@ -47,43 +91,82 @@ function love.load(args)
end
local RomImporter = require("src.import.RomImporter")
if os.getenv("POKEPORT_FORCE_IMPORT") == "1" or not RomImporter.isReady() then
Importer = RomImporter.new(function()
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame()
end)
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
if importPath then Importer:startPath(importPath) end
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
-- Red); the launcher's per-column choice does not apply to them.
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
local ready = RomImporter.isReady(scriptedVersion)
-- Scripted / headless runs have to reach the game with no human pressing
-- Play: an autopilot, a frame driver, an import-only build step, or an
-- explicit ROM path all bypass the interactive launcher and keep today's
-- import-then-boot (or boot-straight-in) behavior.
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
if scripted then
if forceImport or not ready then
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
-- passes it to onComplete; boot that version.
Importer = RomImporter.new(function(version)
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame(version or scriptedVersion)
end)
if importPath then Importer:startPath(importPath) end
return
end
bootGame(scriptedVersion)
return
end
bootGame()
-- Interactive: the launcher always runs. Red and Blue are each live: a
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport })
end
function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
-- once per Game:update, so they must keep a 1:1 relationship with the
-- logic step. Fast-forwarding them by scaling the step inside
-- Game:update would run N steps per observation: a held direction walks
-- through all N, the player slides past the waypoint, and the script
-- re-plans from an overshot cell. So iterate the whole act+step loop
-- instead -- same script, just more of it per rendered frame.
local iterations = scriptedIterations()
if autopilot then
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
for _ = 1, iterations do
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
end
return
end
if driverCo then
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
for _ = 1, iterations do
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
return
end
Game:update(dt)
@@ -139,6 +222,31 @@ function love.gamepadaxis(joystick, axis, value)
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickremoved(joystick)
if editorMode then return end
if Importer then return end
Game:joystickremoved(joystick)
end
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
-- direction's key-up can be delivered to the OS instead of the game while
-- unfocused, so reset input on either transition rather than trust it.
function love.focus(f)
if editorMode then return end
if Importer then
if Importer.focus then Importer:focus(f) end
return
end
Game:focus(f)
end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode then return end
if Importer then return end
Game:visible(v)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if Importer then return Importer:mousepressed(x, y, 1) end
@@ -171,6 +279,9 @@ function love.mousepressed(x, y, button)
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
end
end
function love.mousereleased(x, y, button)
@@ -178,6 +289,16 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchreleased("mouse", x, y)
end
end
function love.mousemoved(x, y)
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
end
end
function love.textinput(text)
@@ -191,6 +312,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)
@@ -199,3 +323,78 @@ function love.filedropped(file)
end
if Importer then Importer:filedropped(file) end
end
local function pacingEnabled()
if os.getenv("POKEPORT_AUTOPILOT") then return false end
if os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true
end
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
if love.timer then love.timer.step() end
local FrameCap = require("src.core.FrameCap")
local paced = pacingEnabled()
-- The deadline the next present() should not beat. Carried forward one
-- budget per frame so pacing stays even instead of drifting with the
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
return function()
-- process events
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
else
love.timer.sleep(0.001)
end
end
end
end
+21 -22
View File
@@ -27,25 +27,28 @@ refresh is safe, just rebuild.
## Build
```bash
# Debug APK (default Android debug keystore)
# Build the APK
scripts/build_android.sh
# Release APK (signing is manual, see below)
scripts/build_android.sh --release
# Build the APK, setting app.version_name/app.version_code to match a release
scripts/build_android.sh --version 0.2.5
# Zip game.love + branding only (no Android SDK required)
scripts/build_android.sh --package-only
```
Or via `scripts/build.sh android`.
Or via `scripts/build.sh android [--version X.Y.Z]`.
The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`.
The current importer has desktop file pickers only. A production Android
release still needs a Storage Access Framework handoff that passes the chosen
ROM to LÖVE; the APK packaging itself is data-free.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
@@ -59,12 +62,10 @@ Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`.
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
Build task: `assembleEmbedNoRecordDebug`.
- Debug task: `assembleEmbedNoRecordDebug`
- Release task: `assembleEmbedNoRecordRelease`
APKs land under `app/build/outputs/apk/embedNoRecord/{debug,release}/`.
`scripts/build_android.sh` also copies the built APK(s) to `dist/android/{debug,release}/`.
The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
`scripts/build_android.sh` also copies it to `dist/android/debug/`.
### Payload path
@@ -79,17 +80,15 @@ scripts, tests, and mobile build sources are excluded.
| `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red |
| `app.orientation` | `portrait` |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
## Releases
`.github/workflows/release.yml` builds the APK with `--version` set to the
release version and publishes it alongside the macOS/Windows/Linux builds as
`PokemonRed-<version>-android.apk`.
## Signing
- **Debug**: default Android debug keystore (no setup).
- **Release**: out-of-band. Create a keystore yourself and wire it into
`app/build.gradle`, **do not commit keystores or passwords**.
Example (placeholder only):
```bash
keytool -genkey -v -keystore /path/to/pokemonred-release.jks \
-alias pokemonred -keyalg RSA -keysize 2048 -validity 10000
```
Signed with the default Android keystore (no setup required).
+3 -1
View File
@@ -6,7 +6,9 @@
#app.name=LÖVE for Android
app.application_id=com.theboisclub.pokemonred
app.orientation=portrait
# fullUser: allow every orientation the player's device permits (portrait and
# landscape), honouring their auto-rotate lock. Was "portrait" (locked).
app.orientation=fullUser
app.version_code=32
app.version_name=11.5a
@@ -1,4 +1,8 @@
*.[oa]
# Prebuilt vendored static libs Android.mk links against directly (not
# built by the Gradle/NDK build itself) -- must stay tracked or a fresh
# checkout fails ndk-build with "LOCAL_SRC_FILES points to a missing file".
!android/*/libluajit.a
*.so
*.obj
*.lib
@@ -36,6 +36,7 @@ demos
*~
Makefile*
config*
!src/common/config.h
libtool
/platform/unix/ar-lib
/platform/unix/compile
@@ -183,6 +183,18 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}
bool showFilePicker()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
/*
* Helper functions for the filesystem module
*/
@@ -59,6 +59,14 @@ bool openURL(const std::string &url);
void vibrate(double seconds);
/**
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua.
**/
bool showFilePicker();
/*
* Helper functions for the filesystem module
*/
@@ -0,0 +1,178 @@
/**
* Copyright (c) 2006-2023 LOVE Development Team
*
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any damages
* arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose,
* including commercial applications, and to alter it and redistribute it
* freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not
* claim that you wrote the original software. If you use this software
* in a product, an acknowledgment in the product documentation would be
* appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and must not be
* misrepresented as being the original software.
* 3. This notice may not be removed or altered from any source distribution.
**/
#ifndef LOVE_CONFIG_H
#define LOVE_CONFIG_H
// Platform stuff.
#if defined(WIN32) || defined(_WIN32)
# define LOVE_WINDOWS 1
// If _USING_V110_SDK71_ is defined it means we are using the xp toolset.
# if defined(_MSC_VER) && (_MSC_VER >= 1700) && !_USING_V110_SDK71_
# include <winapifamily.h>
# if WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_APP) && !WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_DESKTOP)
# define LOVE_WINDOWS_UWP 1
# define LOVE_NO_MODPLUG 1
# define LOVE_NOMPG123 1
# endif
# endif
#endif
#if defined(linux) || defined(__linux) || defined(__linux__)
# define LOVE_LINUX 1
#endif
#if defined(__ANDROID__)
# define LOVE_ANDROID 1
#endif
#if defined(__APPLE__)
# include <TargetConditionals.h>
# if TARGET_OS_IPHONE
# define LOVE_IOS 1
# elif TARGET_OS_MAC
# define LOVE_MACOSX 1
# endif
#endif
#if defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
// I know it's not linux, but it seems most "linux-only" code is bsd-compatible
# define LOVE_LINUX 1
#endif
// Endianness.
#if defined(__ppc__) || defined(__ppc) || defined(__powerpc__) || defined(__powerpc)
# define LOVE_BIG_ENDIAN 1
#else
# define LOVE_LITTLE_ENDIAN 1
#endif
// SSE instructions.
#if defined(__SSE__)
# define LOVE_SIMD_SSE
#elif defined(_MSC_VER)
# if defined(_M_AMD64) || defined(_M_X64)
# define LOVE_SIMD_SSE
# elif _M_IX86_FP
# define LOVE_SIMD_SSE
# endif
#endif
// NEON instructions.
#if defined(__ARM_NEON)
# define LOVE_SIMD_NEON
#endif
// Warnings.
#ifndef _CRT_SECURE_NO_WARNINGS
# define _CRT_SECURE_NO_WARNINGS
#endif
// Preferably, and ironically, this macro should go unused.
#ifndef LOVE_UNUSED
# define LOVE_UNUSED(x) (void)sizeof(x)
#endif
// Warn on unused return values
#ifdef __GNUC__
# define LOVE_WARN_UNUSED __attribute__((warn_unused_result))
#elif _MSC_VER
# define LOVE_WARN_UNUSED _Check_return_
#else
# define LOVE_WARN_UNUSED
#endif
#ifndef LOVE_BUILD
# define LOVE_BUILD
# define LOVE_BUILD_STANDALONE
# define LOVE_BUILD_EXE
//# define LOVE_BUILD_DLL
#endif
// DLL-stuff.
#if defined(_MSC_VER) || defined(__MINGW32__)
# define LOVE_EXPORT __declspec(dllexport)
#elif defined(__GNUC__) || defined(__clang__)
# define LOVE_EXPORT __attribute__((visibility("default")))
#else
# define LOVE_EXPORT
#endif
#if defined(LOVE_WINDOWS)
#ifndef LOVE_WINDOWS_UWP
# define LOVE_LEGENDARY_CONSOLE_IO_HACK
#endif // LOVE_WINDOWS_UWP
#ifndef __MINGW32__
# define NOMINMAX
#endif
#endif
#if defined(LOVE_MACOSX) || defined(LOVE_IOS)
# define LOVE_LEGENDARY_APP_ARGV_HACK
#endif
#if defined(LOVE_ANDROID) || defined(LOVE_IOS)
# define LOVE_LEGENDARY_ACCELEROMETER_AS_JOYSTICK_HACK
#endif
// Autotools config.h
#ifdef HAVE_CONFIG_H
# include <../config.h>
# undef VERSION
# ifdef WORDS_BIGENDIAN
# undef LOVE_LITTLE_ENDIAN
# define LOVE_BIG_ENDIAN 1
# else
# undef LOVE_BIG_ENDIAN
# define LOVE_LITTLE_ENDIAN 1
# endif
#else
# define LOVE_ENABLE_LOVE
# define LOVE_ENABLE_AUDIO
# define LOVE_ENABLE_DATA
# define LOVE_ENABLE_EVENT
# define LOVE_ENABLE_FILESYSTEM
# define LOVE_ENABLE_FONT
# define LOVE_ENABLE_GRAPHICS
# define LOVE_ENABLE_IMAGE
# define LOVE_ENABLE_JOYSTICK
# define LOVE_ENABLE_KEYBOARD
# define LOVE_ENABLE_MATH
# define LOVE_ENABLE_MOUSE
# define LOVE_ENABLE_PHYSICS
# define LOVE_ENABLE_SOUND
# define LOVE_ENABLE_SYSTEM
# define LOVE_ENABLE_THREAD
# define LOVE_ENABLE_TIMER
# define LOVE_ENABLE_TOUCH
# define LOVE_ENABLE_VIDEO
# define LOVE_ENABLE_WINDOW
# define LOVE_ENABLE_ENET
# define LOVE_ENABLE_LUASOCKET
# define LOVE_ENABLE_LUA53
#endif
// Check we have a sane configuration
#if !defined(LOVE_WINDOWS) && !defined(LOVE_LINUX) && !defined(LOVE_IOS) && !defined(LOVE_MACOSX) && !defined(LOVE_ANDROID)
# error Could not detect target platform
#endif
#if !defined(LOVE_LITTLE_ENDIAN) && !defined(LOVE_BIG_ENDIAN)
# error Could not detect endianness
#endif
#endif // LOVE_CONFIG_H
@@ -186,6 +186,18 @@ bool Filesystem::setIdentity(const char *ident, bool appendToPath)
save_path_full = storage_path + std::string("/save/") + save_identity;
// love::android::mkdir is a single mkdir(), not mkdir -p: on a genuinely
// first-ever launch (nothing has touched this app's external-files dir
// before) save_directory doesn't exist yet either, so creating
// save_path_full in one step fails with ENOENT and PHYSFS_mount below
// silently never mounts anything for the rest of this process -- not
// just the save dir, but everything routed through it (save.lua/
// options.lua, the ROM-derived asset cache, RomImporter's Android
// folder scan). Ensure each level exists in order instead.
if (!love::android::directoryExists(save_directory.c_str()) &&
!love::android::mkdir(save_directory.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_directory.c_str());
if (!love::android::directoryExists(save_path_full.c_str()) &&
!love::android::mkdir(save_path_full.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str());
@@ -338,6 +350,26 @@ bool Filesystem::setupWriteDirectory()
std::string temp_writedir = getDriveRoot(save_path_full);
std::string temp_createdir = skipDriveRoot(save_path_full);
#ifdef LOVE_ANDROID
// getUserDirectory() falls back to $HOME/getpwuid() (physfs_platform_posix.c),
// which is meaningless on Android and unrelated to save_path_full (an
// SDL_AndroidGet*StoragePath() subdirectory -- see setIdentity above), so
// the generic check below never matches and falls through to setting the
// write dir to the drive root ("/"), which no Android app can write to.
// Anchor to the real Android storage root instead.
std::string androidStorageRoot = isAndroidSaveExternal()
? SDL_AndroidGetExternalStoragePath() : SDL_AndroidGetInternalStoragePath();
if (save_path_full.find(androidStorageRoot) == 0)
{
temp_writedir = androidStorageRoot;
temp_createdir = save_path_full.substr(androidStorageRoot.length());
size_t startpos = temp_createdir.find_first_not_of('/');
if (startpos != std::string::npos)
temp_createdir = temp_createdir.substr(startpos);
}
else
#endif
// On some sandboxed platforms, physfs will break when its write directory
// is the root of the drive and it tries to create a folder (even if the
// folder's path is in a writable location.) If the user's home folder is
@@ -180,6 +180,15 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -106,6 +106,15 @@ public:
*/
virtual void vibrate(double seconds) const;
/**
* Shows the platform's native "pick a file" UI, if one is available.
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -95,6 +95,12 @@ int w_vibrate(lua_State *L)
return 0;
}
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -110,6 +116,7 @@ static const luaL_Reg functions[] =
{ "getPowerInfo", w_getPowerInfo },
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -61,6 +61,13 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
// is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity {
return openURL(url) == 0;
}
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
*/
@Keep
public static boolean showRomFilePicker() {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
return false;
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
}
}
/**
* Copies a given file from the assets folder to the destination.
*
+5
View File
@@ -62,10 +62,15 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
+29
View File
@@ -0,0 +1,29 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
--
-- Gallery entry #0, the legacy example. Its manifest stays api 1 with
-- category GAMEPLAY on purpose: it is the compatibility proof that a mod
-- written before manifest v2 keeps loading unchanged. The v2 examples
-- live in mods/examples/.
return {
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "beginner", "cosmetic", "data-only", "legacy" },
differences = {
changed = {
"the Oak's Lab starter gift becomes a level 20 Mew nicknamed HOGHEAD",
"MEW's front and back battle sprites point at inverted copies",
},
added = {},
known = {
"assets/ is empty until tools/generate_example_mod_sprite.py runs "
.. "against an imported cache",
"api 1: uses override where a v2 mod would use patch",
},
},
credits = {
{ who = "pret/pokered", for_ = "the Mew sprites the local generator inverts" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 1 },
}
+72
View File
@@ -0,0 +1,72 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
Copy the one closest to what you want to build.
| # | Mod | Persona | Category | What it does |
|---|---|---|---|---|
| 0 | [`../example_mew_starter`](../example_mew_starter) | (legacy) | `GAMEPLAY` | Oak's gift becomes a L20 Mew. The api-1 compatibility proof. |
| 1 | [`example_balance_tweaks`](example_balance_tweaks) | Tweaker | `BALANCE` | Faster starters, half-price TMs, a re-slotted Route 1 |
| 2 | [`example_shiny_palette`](example_shiny_palette) | Artist | `GRAPHICS` | A teal player recolor derived from your own cache |
| 3 | [`example_jukebox`](example_jukebox) | Musician | `AUDIO` | An authored chip song, a new cry, a jukebox screen |
| 4 | [`example_lost_parcel`](example_lost_parcel) | Quest author | `QUEST` | A two-town fetch quest over vanilla NPCs |
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
## None of these load by default
The engine discovers mods one level below `mods/`. The gallery lives one
level deeper, in `mods/examples/`, so a fresh install finds none of them
and the vanilla game is unchanged — the parity invariant holds by
construction.
To run one, copy it up a level:
```sh
cp -r mods/examples/example_balance_tweaks mods/
python3 tools/modkit.py validate mods/example_balance_tweaks --base imported
```
then enable it in `options.lua` (`mods = { example_balance_tweaks = true }`)
or toggle it in the F10 mod manager.
## Coverage
Between them the gallery writes into `pokemon`, `items`, `encounters`,
`maps`, `sprites`, `palettes`, `icons`, `music`, `cries`, `screens`,
`map_scripts`, `commands`, `tokens`, `statuses`, `rulesets`, `constants`
and `field`, and exercises:
- the write verbs `register`, `override` and `patch`, plus `get` and
`each` on the merged view
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
parallel scripts, `mod:` field routing, replaying an overridden base talk
handler through `MapScripts.baseTalk`, and the no-ROM-content posture
## What every entry has
```
mods/examples/<id>/
manifest.json api = 2, a category from the taxonomy, a semver engine range
main.lua the entry chunk
mod.card sharing metadata: summary, author, tags, differences, credits
README.md what it demonstrates, which persona, the commands to try it
CHANGELOG.md keep-a-changelog; headings match manifest.version
tests/ one runnable suite asserting the mod's stated effect
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- Base speed patches for VENUSAUR, CHARIZARD and BLASTOISE.
- TM price halving driven off `content.items:each()`.
- Route 1 grass re-slot.
@@ -0,0 +1,59 @@
# Balance Tweaks Example
Raises the final-stage Kanto starters to 100 base speed, halves every TM
price, and re-slots the Route 1 grass table — all without copying a single
record whole.
**Persona: the Tweaker.** A player who wants one number changed and copies
an example to get there. This is the shortest complete mod in the gallery
and the one to start from.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_balance_tweaks --base imported
luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
```
Then enable it: add `example_balance_tweaks = true` under `mods` in your
`options.lua`, or toggle it in the F10 mod manager.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.pokemon:patch` | `main.lua` — deep-merges one leaf, leaves the rest alone |
| `content.items:each` | `main.lua` — walks the merged view to find TMs instead of listing them |
| `content.encounters:patch` | `main.lua` — a list leaf replaces wholesale even inside a patch |
| `content.<r>:get` | `main.lua` — the guard that turns a missing id into a log line, not a crash |
`patch` is the point. The legacy `mods/example_mew_starter` has to copy
every field of Mew to change two sprite paths, because api 1 only had
`override`. With `patch` you name the leaf:
```lua
mod.content.pokemon:patch("VENUSAUR", { baseStats = { speed = 100 } })
```
Everything not named — `learnset`, `types`, `evolutions`, `spriteFront`
keeps its base value, and a second mod patching `baseStats.attack` on the
same species composes with this one instead of clobbering it.
## Exact changes
- `VENUSAUR` base speed 80 → 100
- `BLASTOISE` base speed 78 → 100
- `CHARIZARD` base speed 100 → 100 (already there; kept for symmetry)
- every item whose `machine.kind == "TM"` has its `price` halved
(`TM_TOXIC` 4000 → 2000, and 49 others; HMs carry `machine.kind == "HM"`
and are left alone)
- `ROUTE_1` grass `rate` 25 → 20, slot table re-weighted to include
`SPEAROW`
Field meanings are in the generated registry reference (`modkit docs`),
section `pokemon`, `items` and `encounters`.
## Credits
Base stat and mart price tables come from the player's own imported ROM;
this mod ships numbers, not data.
@@ -0,0 +1,44 @@
-- Gallery #1 (Tweaker): pure data, no engine seams. Everything here is
-- patch + each, so no record is ever copied whole and another mod editing
-- the same species keeps its own fields.
return function(mod)
-- patch deep-merges only the leaves it names: learnset, sprites, types
-- and evolutions all survive this speed change untouched
for _, id in ipairs({ "VENUSAUR", "CHARIZARD", "BLASTOISE" }) do
if mod.content.pokemon:get(id) then
mod.content.pokemon:patch(id, { baseStats = { speed = 100 } })
else
-- degrade instead of crashing: a species mod loaded ahead of this
-- one may have removed the vanilla starter line
mod.log:warn("%s missing from the merged view; speed patch skipped", id)
end
end
-- each() walks the merged view (engine records plus every mod ahead of
-- this one), so the TM list is discovered rather than hard-coded
local halved = 0
for id, item in mod.content.items:each() do
local machine = item.machine
if machine and machine.kind == "TM" and type(item.price) == "number"
and item.price > 0 then
mod.content.items:patch(id, { price = math.floor(item.price / 2) })
halved = halved + 1
end
end
mod.log:info("halved %d TM prices", halved)
-- lists replace wholesale even inside a patch, so a re-slotted encounter
-- table is written out in full while the rate rides along as a leaf
mod.content.encounters:patch("ROUTE_1", {
grass = {
rate = 20,
slots = {
{ level = 3, species = "PIDGEY" }, { level = 3, species = "RATTATA" },
{ level = 4, species = "SPEAROW" }, { level = 2, species = "RATTATA" },
{ level = 2, species = "PIDGEY" }, { level = 3, species = "SPEAROW" },
{ level = 3, species = "PIDGEY" }, { level = 4, species = "RATTATA" },
{ level = 4, species = "PIDGEY" }, { level = 5, species = "SPEAROW" },
},
},
})
end
@@ -0,0 +1,15 @@
{
"id": "example_balance_tweaks",
"name": "Balance Tweaks Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "BALANCE",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Tweaker gallery entry: patch and each over the merged view, with no whole-record copies."
}
@@ -0,0 +1,21 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "balance", "data-only", "beginner" },
differences = {
changed = {
"VENUSAUR and BLASTOISE base speed raised to 100 (CHARIZARD already was)",
"every TM item's price halved",
"Route 1 grass rate 25 -> 20 and SPEAROW added to the slot table",
},
added = {},
known = { "TM prices halve once per load, not once per install" },
},
credits = {
{ who = "pret/pokered", for_ = "the base stat and mart price tables this patches over" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,28 @@
-- Standalone: luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
-- Loads the mod through the real headless loader and asserts its stated
-- effect against the player's imported dataset.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_balance_tweaks", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.eq(run.mod and run.mod.state, "loaded", "reached the loaded state")
T.eq(Data.pokemon.VENUSAUR.baseStats.speed, 100, "VENUSAUR speed patched")
T.eq(Data.pokemon.BLASTOISE.baseStats.speed, 100, "BLASTOISE speed patched")
-- the patch named one leaf, so everything else survived
T.check(#Data.pokemon.VENUSAUR.learnset > 0, "VENUSAUR keeps its learnset")
T.eq(Data.pokemon.VENUSAUR.types[1], "GRASS", "VENUSAUR keeps its types")
T.eq(Data.items.TM_TOXIC.price, 2000, "TM price halved")
T.eq(Data.items.POTION.price, 300, "a non-TM item is untouched")
T.eq(Data.encounters.ROUTE_1.grass.rate, 20, "Route 1 grass rate patched")
T.eq(Data.encounters.ROUTE_1.grass.slots[3].species, "SPEAROW",
"Route 1 slot table re-slotted")
run.release()
T.finish("example_balance_tweaks")
+12
View File
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- The `ExampleDexNav` screen and its START-menu row.
- `SORT BY` and `SHOW UNSEEN` mod options.
- `countSeen`, `countOwned` and `species` exports.
+88
View File
@@ -0,0 +1,88 @@
# DexNav Example
A START-menu overlay listing every species in the merged dex with its
seen/owned state, sortable, and publishing a small API other mods can call.
**Persona: the Tool Builder.** Consume the merged view, never `require` a
private module, expose a stable inter-mod surface. This example is the
reference for all three.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_dexnav --base imported
luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
```
Enable it (`example_dexnav = true` under `mods` in `options.lua`, or the
F10 manager), then press START → **DEXNAV**. Its two options live in the
manager's per-mod options pane.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.screens:register` | `main.lua` — a factory the engine instantiates by id |
| `hooks:wrap("ui.start_menu.items")` | `main.lua` — decorate, do not replace |
| `mod.ui.insertBefore` | `main.lua` — anchor on a label, not a row index |
| `mod.options:define` / `:get` | `main.lua` — auto-rendered rows in the manager |
| `mod.exports` | `main.lua` — the inter-mod API |
| `content.pokemon:each` | `main.lua` — the whole world, engine records included |
## Reading, not reaching
Every fact this mod displays comes from two public sources:
- `mod.content.pokemon:each()` — the merged species view. A tool that
hard-codes 151 breaks the moment another mod registers a species; this
one just gets longer.
- `game.save.pokedex` — the seen/owned tables, handed in by the engine.
No `require("src.pokemon.…")`, no permission declared, nothing that a later
refactor of an engine module can break.
## Anchoring a menu row
```lua
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
local out = next(game, items)
if type(out) ~= "table" then return out end
return mod.ui.insertBefore(out, "SAVE", { label = "DEXNAV", onSelect = ... })
end)
```
Two rules, both load-bearing:
1. **Call `next` first, then decorate what comes back.** Build a fresh list
instead and every other mod's row disappears.
2. **Anchor on a stable label, not an index.** `insertBefore` appends when
the anchor is missing, so the row is always reachable even in a total
conversion that renamed `SAVE`.
## Exporting an API
```lua
mod.exports.countSeen = function(game) ... end
```
Another mod reads it as:
```lua
local nav = mod.find("example_dexnav")
if nav then print(nav.exports.countSeen(game)) end
```
`mod.find` returns nil when the other mod is absent, disabled, failed, or
has not run yet — so a dependent degrades instead of crashing. Declare it
in `optional_dependencies` if you can live without it and `dependencies`
if you cannot.
## Empty state
`SHOW UNSEEN` off on a fresh save means an empty list. `ListMenu` draws
`Nothing here.` and B still exits — never a blank frame with no way out.
The title still reports `DEXNAV 0/0`, so the screen explains itself.
## Credits
- pret/pokered — the START-menu layout the row is anchored into.
+98
View File
@@ -0,0 +1,98 @@
-- Gallery #6 (Tool builder): a read-mostly overlay. Everything it knows
-- comes from the merged view through the public mod API -- no private
-- require, no engine table reached behind the loader's back -- and what it
-- knows is published as a stable export other mods can call.
local SCREEN = "ExampleDexNav"
return function(mod)
mod.options:define({
{ key = "sort", label = "SORT BY", type = "choice", default = "dex",
choices = { { "DEX NO.", "dex" }, { "NAME", "name" } } },
{ key = "unseen", label = "SHOW UNSEEN", type = "toggle", default = true },
})
-- ------- the merged view, read once per open
-- content.pokemon:each() yields the engine's species AND every mod's,
-- which is the whole point: a tool that hard-codes 151 breaks the moment
-- someone adds a species.
local function species()
local rows = {}
for id, mon in mod.content.pokemon:each() do
rows[#rows + 1] = { id = id, name = mon.name or id, dex = mon.dex or 9999 }
end
return rows
end
local function dexOf(game)
return (game and game.save and game.save.pokedex) or { seen = {}, owned = {} }
end
local function counts(game)
local dex = dexOf(game)
local seen, owned = 0, 0
for _, row in ipairs(species()) do
if dex.seen[row.id] then seen = seen + 1 end
if dex.owned[row.id] then owned = owned + 1 end
end
return seen, owned
end
-- ------- the inter-mod API
-- Another mod reads this with mod.find("example_dexnav").exports; it is
-- the supported way to depend on this one, and the reason nothing here
-- reaches into a private module.
mod.exports.countSeen = function(game) return (counts(game)) end
mod.exports.countOwned = function(game) return select(2, counts(game)) end
mod.exports.species = species
-- ------- the screen
mod.content.screens:register(SCREEN, {
new = function(game)
local dex = dexOf(game)
local showUnseen = mod.options:get("unseen")
local rows = species()
if mod.options:get("sort") == "name" then
table.sort(rows, function(a, b) return a.name < b.name end)
else
table.sort(rows, function(a, b)
if a.dex ~= b.dex then return a.dex < b.dex end
return a.id < b.id
end)
end
local items = {}
for _, row in ipairs(rows) do
local state = dex.owned[row.id] and "OWN"
or (dex.seen[row.id] and "SEEN" or "----")
if showUnseen or state ~= "----" then
items[#items + 1] = { label = row.name, right = state, value = row.id }
end
end
local seen, owned = counts(game)
-- ListMenu draws "Nothing here." for an empty set, so a brand new
-- save with SHOW UNSEEN off reads as a sentence, not a blank frame
return mod.ui.ListMenu.new(game,
("DEXNAV %d/%d"):format(owned, seen), items, {
pageJump = true,
onChoose = function(_, menu) menu:close() end,
})
end,
})
-- ------- reaching it
-- Call next() first, then decorate the list it returns: another mod's
-- row survives, and the vanilla rows are never rebuilt by hand.
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
local out = next(game, items)
if type(out) ~= "table" then return out end
return mod.ui.insertBefore(out, "SAVE", {
label = "DEXNAV",
onSelect = function() mod.ui.push(game, SCREEN) end,
})
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_dexnav",
"name": "DexNav Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "TOOL",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Tool-builder gallery entry: a start-menu overlay over the merged dex, with mod options and a stable inter-mod export."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "ui", "tool", "quality-of-life" },
differences = {
changed = { "the START menu gains a DEXNAV row above SAVE" },
added = {
"ExampleDexNav screen",
"mod options: SORT BY and SHOW UNSEEN",
"exports countSeen / countOwned / species for other mods",
},
known = { "the list is built when the screen opens, so catching a mon mid-session needs a reopen" },
},
credits = {
{ who = "pret/pokered", for_ = "the start-menu layout the row is anchored into" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,54 @@
-- Standalone: luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
-- Exercises the export surface, the start-menu wrap and the screen factory.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local Font = require("src.render.Font")
Font.load(Data)
local run = T.sdk.loadMod("mods/examples/example_dexnav", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local exports = run.loader.exports.example_dexnav
T.check(type(exports.countSeen) == "function", "countSeen is exported")
T.check(type(exports.species) == "function", "species is exported")
T.eq(#exports.species(), 151, "the export reads the whole merged dex")
local game = {
data = Data,
save = { pokedex = { seen = { PIKACHU = true, MEW = true },
owned = { PIKACHU = true } } },
}
T.eq(exports.countSeen(game), 2, "countSeen counts the seen set")
T.eq(exports.countOwned(game), 1, "countOwned counts the owned set")
-- an empty dex is a legal state, not a crash
T.eq(exports.countSeen({ data = Data, save = {} }), 0, "a fresh save counts zero")
-- ------- the start-menu wrap decorates rather than replaces
local vanilla = { { label = "POKéDEX" }, { label = "SAVE" }, { label = "QUIT" } }
local hooked = Runtime.call("ui.start_menu.items",
function(_, items) return items end, game, vanilla)
T.eq(#hooked, 4, "the wrap added exactly one row")
T.eq(hooked[2].label, "DEXNAV", "the row is anchored before SAVE")
T.eq(hooked[3].label, "SAVE", "the vanilla rows are still in order")
-- ------- the screen factory builds a real state
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get(game, "ExampleDexNav")
T.check(factory and factory.new, "the screen resolves through the registry")
local screen = factory.new(game)
T.check(screen.items ~= nil and #screen.items == 151,
"the list shows every species with SHOW UNSEEN on")
T.eq(screen.title, "DEXNAV 1/2", "the title carries the owned/seen counts")
run.release()
Screens.invalidate()
T.finish("example_dexnav")
@@ -0,0 +1,13 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `Music_ExamplePalletRain`, a two-pulse loop authored in `song.lua`.
- A `music.select` wrap that swaps the Pallet Town map theme.
- An authored chip cry for MEW.
- The `ExampleJukebox` screen and its OPTIONS row.
+99
View File
@@ -0,0 +1,99 @@
# Jukebox Example
Adds one song authored note-by-note in Lua, swaps it in as the Pallet Town
theme, replaces Mew's cry, and ships a jukebox screen that lists every song
in the merged registry.
**Persona: the Musician.** Nothing here is a `.ogg`. The song is a
Game Boy channel program assembled at load time by `ChipAsm`.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_jukebox --base imported
luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
# render the song to a wav to hear it without launching the game
python3 tools/modkit.py bounce Music_ExamplePalletRain --seconds 20 --out bounce
```
Enable it (`example_jukebox = true` under `mods` in `options.lua`, or the
F10 manager), then open **OPTIONS → JUKEBOX**.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.music:register` (ChipAsm DSL) | `song.lua` + `main.lua` |
| `content.cries:override` (chip program) | `main.lua` |
| `hooks:wrap("music.select")` | `main.lua` — one choke point covers map, battle and jingle music |
| `content.screens:register` | `main.lua` — a screen factory the engine instantiates by id |
| `hooks:wrap("ui.options.rows")` | `main.lua` — how the player reaches the screen |
| `content.music:each` | `main.lua` — the jukebox list is the merged view, not a hard-coded array |
| `mod:read` | `main.lua` — loading a sibling file through the loader's filesystem |
## Authoring a song
`song.lua` returns what `ChipAsm.song{...}` builds: a self-contained
program blob plus its channel layout. Events are Lua tables, one per
command:
```lua
{ duty = 2 },
{ notetype = { speed = 12, volume = 11, fade = 2 } },
{ octave = 4 },
{ label = "lead" },
{ note = "E", len = 6 }, { note = "D", len = 2 },
{ loop = { count = 0, to = "lead" } },
```
The assembler is the validator. An unknown note name or an out-of-range
length raises *there*, naming the channel and event index, and `main.lua`
turns that into one mod-attributed load error. The music system never
latches on a bad program, because a bad program never reaches it.
Two shapes share the `music` registry and are dispatched per definition,
not by a global flag: `{ chip = ... }` for an authored program and
`{ file = "..." }` for an audio file. This example uses the first; a file
track is one line:
```lua
mod.content.music:register("Music_MyTheme", { file = mod.path .. "/theme.ogg" })
```
Note the cry uses `ChipAsm.sfx{...}.chip` — the assembler returns
`{ chip = program }`, and a cry record wants the program under its own
`chip` key.
## Deferring is the parity guarantee
```lua
mod.hooks:wrap("music.select", function(next, chosen, ctx)
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
return next(SONG_ID, ctx)
end
return next(chosen, ctx)
end)
```
Every path that is not Pallet Town calls `next(chosen, ctx)` with the
argument it was given, so playback everywhere else is exactly what it was
before the mod loaded.
## Empty state
The jukebox is a `ListMenu`, which draws `Nothing here.` when its item list
is empty rather than an empty frame. That cannot happen with the engine's
45 songs present, but it is the behavior a mod screen owes the player.
## Permissions
This mod declares `engine_internals`, because playing a song from a screen
currently needs `require("src.core.Music")` — the mod surface has no audio
playback facade yet. Declaring it is the honest path: `modkit validate`
accepts a declared require and flags an undeclared one.
## Credits
- Pallet Rain arrangement: this project.
- pret/pokered: the channel command set `ChipAsm` assembles to.
+96
View File
@@ -0,0 +1,96 @@
-- Gallery #3 (Musician): one authored chip song, one hook that swaps the
-- Pallet Town theme, one derived cry, and a jukebox screen that lists the
-- merged music registry.
local SONG_ID = "Music_ExamplePalletRain"
return function(mod)
-- the song lives in its own file; mod:read + load keeps it addressable
-- through the loader's filesystem instead of the host package.path, so
-- the mod works the same installed as it does in the repo
local source = mod:read("song.lua")
if not source then
mod.log:error("song.lua missing from %s -- reinstall the mod", mod.path)
return
end
local chunk, compileErr = load(source, "@" .. mod.path .. "/song.lua")
if not chunk then
mod.log:error("song.lua did not compile: %s", tostring(compileErr))
return
end
-- a malformed note table raises inside ChipAsm; catching it here turns
-- the whole mod into a mod-attributed load error instead of latching the
-- music system at playback time
local ok, song = pcall(chunk)
if not ok then
mod.log:error("song.lua failed to assemble: %s", tostring(song))
return
end
mod.content.music:register(SONG_ID, song)
-- a derived cry: the ChipAsm effect command set (channels 5-8), keyed by
-- species exactly like the vanilla cry table
mod.content.cries:override("MEW", {
-- .chip, not the whole return: ChipAsm hands back { chip = program }
-- and a cry record carries the program under its own chip key
chip = require("src.audio.ChipAsm").sfx{
channels = {
{ hw = 1, program = {
{ pitchSweep = { pace = 3, subtract = false, shift = 2 } },
{ squareNote = { len = 6, volume = 14, fade = 2, frequency = 0x5C0 } },
{ squareNote = { len = 8, volume = 12, fade = 3, frequency = 0x680 } },
} },
},
}.chip,
pitch = 128, length = 128,
})
-- music.select is the single choke point every song choice passes
-- through. Defer to next() for everything that is not the case this mod
-- cares about: with the mod installed but off the map, playback is
-- byte-for-byte what it was.
mod.hooks:wrap("music.select", function(next, chosen, ctx)
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
return next(SONG_ID, ctx)
end
return next(chosen, ctx)
end)
-- the jukebox itself: a screen factory in the screens registry
mod.content.screens:register("ExampleJukebox", {
new = function(game)
local ids = {}
for id in mod.content.music:each() do ids[#ids + 1] = id end
table.sort(ids)
local items = {}
for _, id in ipairs(ids) do
items[#items + 1] = { label = id:gsub("^Music_", ""), value = id }
end
-- ListMenu draws "Nothing here." on an empty set, so the empty state
-- is a sentence rather than a blank box
return mod.ui.ListMenu.new(game, "JUKEBOX", items, {
onChoose = function(item)
require("src.core.Music").play(game.data, item.value, true,
{ reason = "direct" })
end,
onCancel = function()
require("src.core.Music").stop()
end,
})
end,
})
-- reachable from OPTIONS; call next() first and decorate what comes back,
-- so every other mod's rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
local out = next(game, rows)
if type(out) ~= "table" then return out end
out[#out + 1] = {
id = "example_jukebox",
label = "JUKEBOX",
value = function() return "OPEN" end,
activate = function(g) mod.ui.push(g, "ExampleJukebox") end,
}
return out
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_jukebox",
"name": "Jukebox Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "AUDIO",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Musician gallery entry: an authored ChipAsm song, a music.select hook, a new cry and a jukebox screen."
}
+24
View File
@@ -0,0 +1,24 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "audio", "chiptune", "ui" },
differences = {
changed = {
"Pallet Town's map theme becomes Music_ExamplePalletRain",
"MEW's cry is replaced with an authored chip effect",
},
added = {
"Music_ExamplePalletRain song record",
"ExampleJukebox screen, reachable from the OPTIONS menu",
},
known = { "the jukebox plays looping songs only; jingles stop on their own" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the Pallet Rain arrangement" },
{ who = "pret/pokered", for_ = "the channel command set ChipAsm assembles to" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
+40
View File
@@ -0,0 +1,40 @@
-- "Pallet Rain": a two-pulse loop authored in the ChipAsm note-event DSL
-- (13-audio-modding.md). ChipAsm is on the loader's supported-require
-- list, so authoring a song needs no permissions.
--
-- The assembler is the validator: an out-of-range length or an unknown
-- note name raises here, at load, naming the channel and event index --
-- not silently at playback.
local ChipAsm = require("src.audio.ChipAsm")
return ChipAsm.song{
tempo = 0x120,
channels = {
-- lead: a four-bar descending figure that loops forever
{ hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 11, fade = 2 } },
{ octave = 4 },
{ label = "lead" },
{ note = "E", len = 6 }, { note = "D", len = 2 },
{ note = "C", len = 6 }, { rest = 2 },
{ note = "A", len = 4 }, { note = "G", len = 4 },
{ note = "C", len = 8 },
{ note = "E", len = 6 }, { note = "G", len = 2 },
{ note = "A", len = 8 },
{ rest = 8 },
{ loop = { count = 0, to = "lead" } },
} },
-- counter-line: same length, one octave down, softer
{ hw = 2, program = {
{ duty = 1 },
{ notetype = { speed = 12, volume = 7, fade = 1 } },
{ octave = 3 },
{ label = "bass" },
{ note = "C", len = 8 }, { note = "G", len = 8 },
{ note = "A", len = 8 }, { note = "F", len = 8 },
{ note = "C", len = 8 }, { note = "G", len = 8 },
{ loop = { count = 0, to = "bass" } },
} },
},
}
@@ -0,0 +1,70 @@
-- Standalone: luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
-- Asserts the song assembles, the cry merges, and music.select swaps only
-- the map this mod claims.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_jukebox", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
-- ------- the authored song
local song = Data.audio.songs.Music_ExamplePalletRain
T.check(type(song) == "table", "the song registered")
T.check(type(song.chip) == "table" and #song.chip.blob > 0,
"it assembled to a non-empty program blob")
T.eq(#song.chip.channels, 2, "both channels are laid out")
T.eq(song.chip.channels[1].address, 0x4000,
"the first channel is based at the 0x4000 window")
-- ------- the cry
local cry = Data.audio.cries.MEW
T.check(type(cry) == "table" and type(cry.chip) == "table",
"the MEW cry is an authored chip program")
T.check(#cry.chip.blob > 0, "the cry program is non-empty")
T.eq(cry.chip.channels[1].number, 5,
"an sfx program lives on the effect channels (5-8)")
-- ------- the hook swaps exactly one map
local function select(song_, ctx)
return Runtime.call("music.select", function(chosen) return chosen end, song_, ctx)
end
T.eq(select("Music_PalletTown", { reason = "map", mapId = "PALLET_TOWN" }),
"Music_ExamplePalletRain", "Pallet Town gets the new theme")
T.eq(select("Music_Routes1", { reason = "map", mapId = "ROUTE_1" }),
"Music_Routes1", "every other map defers to the vanilla choice")
T.eq(select("Music_Battle", { reason = "battle", kind = "wild" }),
"Music_Battle", "battle music defers too")
T.eq(select("Music_PalletTown", nil), "Music_PalletTown",
"a direct play with no context defers")
-- ------- the screen and its options row
local Font = require("src.render.Font")
Font.load(Data)
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get({ data = Data }, "ExampleJukebox")
T.check(factory and factory.new, "the jukebox resolves through the screens registry")
local screen = factory.new({ data = Data })
T.check(#screen.items > 0, "the jukebox lists the merged music registry")
local listed = false
for _, item in ipairs(screen.items) do
if item.value == "Music_ExamplePalletRain" then listed = true end
end
T.check(listed, "the mod's own song is in the list")
local rows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
T.eq(#rows, 2, "the options hook added exactly one row")
T.eq(rows[2].id, "example_jukebox", "the row is the jukebox entry")
run.release()
Screens.invalidate()
T.finish("example_jukebox")
@@ -0,0 +1,15 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- The two-town fetch quest over `VIRIDIAN_CITY` and `PEWTER_CITY`.
- `EXAMPLE_LOST_PARCEL_PARCEL` key item and the `{EXAMPLE_PARCEL_REWARD}` token.
- The `example_lost_parcel:count_ask` verb and the ambient parallel script.
- `example_lost_parcel:base_nerd_chat`, which replays the Pewter super
nerd's base handler through `MapScripts.baseTalk` so the branches the
quest does not own keep the vanilla YES/NO conversation intact.
+131
View File
@@ -0,0 +1,131 @@
# The Lost Parcel
A courier in Viridian City dropped a parcel somewhere in Pewter City.
Fetch it and he pays you a NUGGET.
**Persona: the Quest Author.** Two towns, two vanilla NPCs, a branching
conversation, a key item, a reward and some ambience — and not one line of
map data or engine source changed.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_lost_parcel --base imported
luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
```
Enable it (`example_lost_parcel = true` under `mods` in `options.lua`, or
the F10 manager), then:
```
VIRIDIAN CITY PEWTER CITY
┌───────────────────┐ ┌───────────────────┐
│ GAMBLER ◀──────┼── accept ───┼──▶ SUPER NERD │
│ (quest giver) │ │ (has the │
│ ▲ │ │ parcel) │
└───────┼───────────┘ └─────────┬─────────┘
└──────────── return ───────────────┘
```
Talk to the gambler in Viridian (the one south of the Poké Mart), say
`SURE`, walk to Pewter, talk to the super nerd by the museum, walk back.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.map_scripts:register` (compose) | `main.lua` — two maps, no map edits |
| talk override on a real `TEXT_` constant | `main.lua``TEXT_VIRIDIANCITY_GAMBLER1`, `TEXT_PEWTERCITY_SUPER_NERD1` |
| `choice` + `label` + `jump_if_true/false` | `main.lua` — a five-branch conversation |
| `MOD_` flag convention | `main.lua``MOD_EXAMPLE_LOST_PARCEL_*` |
| `set_field "mod:key"` | `main.lua` — quest scratch state in `save.modData[mod.id]` |
| `mod.save:get/set` | `main.lua` — the same value through the loader-side namespace |
| `content.commands:register` (table form) | `main.lua` — a `foreground` verb of the mod's own |
| `MapScripts.baseTalk` (replay the overridden handler) | `main.lua``example_lost_parcel:base_nerd_chat` |
| `content.tokens:register` | `main.lua``{EXAMPLE_PARCEL_REWARD}` |
| `content.items:register` | `main.lua` — the parcel key item |
| a parallel ambient script | `main.lua``scripts.example_nerd_pace` + `onEnter` |
| `events:on` / `events:emit` | `main.lua` — announcing completion under `mod.<id>.*` |
## How the compose merge works
`map_scripts` is a **compose** registry, not a record registry. Registering
does not replace the engine's contribution for a map; it prepends to an
ordered chain, and each key composes by its own rule
(`09-scripting-and-quests.md` §4.4):
| key | rule |
|---|---|
| `talk`, `scripts` | single winner per name; `false` suppresses and falls through |
| `onEnter`, `onVictory`, `onBoulderMoved` | all contributions run, each `pcall`-guarded |
| `onStep`, `onInteract` | first truthy return consumes the step |
So this mod's `onEnter` for Pewter City runs *alongside* the engine's, not
instead of it. Its `talk` entry for `TEXT_PEWTERCITY_SUPER_NERD1` does win
outright — the engine's handler for that constant stops being dispatched
the moment this mod loads. Giving it back is the last branch's whole job:
```lua
{ "label", "vanilla" },
{ "example_lost_parcel:base_nerd_chat" },
```
```lua
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
base(ctx.game, ctx.overworld, ctx.npc, function() ctx.runner:resume() end)
ctx.runner:yield()
```
`MapScripts.baseTalk` reaches the engine handler still sitting behind the
override (`09-scripting-and-quests.md` §6) — the supported replacement for
re-wrapping it. A `{ "show_text", "TEXT_PEWTERCITY_SUPER_NERD1" }` row
*looks* like it does the same thing and does not: this NPC's base handler
is a Lua function that asks YES/NO and answers with one of two follow-ups,
while `show_text` resolves the constant to its opening line and stops. So
before the quest starts and after the parcel is taken, the player gets the
whole conversation they always got, choice included. The test drives both
answers, in both states, and asserts every line.
This is also why the manifest declares `engine_internals`: replaying a base
handler means requiring `src.script.MapScripts`.
## Flags, fields and mod state
Three storage routes, three jobs:
- **`MOD_`-prefixed flags** — the quest's public state machine. In the
normal flag namespace so `check_flag` works, prefixed so it can never
collide with a pokered event constant.
- **`set_field "mod:asked_count"`** — script-visible scratch state, routed
into `save.modData[mod.id]` by the owning contribution's attribution.
Two copies of a quest cannot collide on one key.
- **`mod.save:get/set`** — the same namespace from Lua, for code that is
not a script row.
## Verb metadata
The custom verb is registered in the table form:
```lua
mod.content.commands:register("example_lost_parcel:count_ask", {
foreground = true,
fn = function(ctx) ... end,
})
```
`foreground = true` marks it illegal inside a parallel script, so the
ambient runner can never touch quest state. Namespacing the verb with the
mod id keeps it from colliding with another mod's — `register` on a name
the engine already owns is an error, and replacing one requires `override`.
## Validation
Every row is checked against the merged command set once all entry chunks
have run. Typo a verb or jump to a label that does not exist and this mod
fails at *load* with the row number, is rolled back whole, and says so in
the manager — it never half-loads into a broken conversation.
## Credits
- pret/pokered — the `TEXT_` constants and base conversations this
composes with.
+172
View File
@@ -0,0 +1,172 @@
-- Gallery #4 (Quest author): the worked multi-map fetch quest from
-- 09-scripting-and-quests.md 6. A courier in Viridian City lost a parcel
-- in Pewter City; the player retrieves it for a NUGGET.
--
-- No map is edited and no engine file is touched. Both NPCs are vanilla
-- objects addressed by their real TEXT_ constants, and the base
-- conversation is still reachable on every branch the quest does not own.
local MapScripts = require("src.script.MapScripts")
local PARCEL = "EXAMPLE_LOST_PARCEL_PARCEL"
local REWARD = "NUGGET"
-- flags a mod writes are MOD_-prefixed by convention, so a save never
-- confuses them with the pokered event namespace
local STARTED = "MOD_EXAMPLE_LOST_PARCEL_STARTED"
local TAKEN = "MOD_EXAMPLE_LOST_PARCEL_TAKEN"
local DONE = "MOD_EXAMPLE_LOST_PARCEL_DONE"
-- the Pewter super nerd's object index on PEWTER_CITY; the ambient script
-- makes this one fidget while the parcel is still lying around
local NERD_INDEX = 3
return function(mod)
-- ------- the reward item
mod.content.items:register(PARCEL, {
id = PARCEL,
name = "PARCEL?",
price = 0,
keyItem = true,
tossable = false,
})
-- ------- a text token, so the reward name is written once
mod.content.tokens:register("EXAMPLE_PARCEL_REWARD", function(game)
local item = game and game.data and game.data.items[REWARD]
return item and item.name or REWARD
end)
-- ------- a script verb of this mod's own
-- The table form carries dispatch metadata: foreground marks it illegal
-- inside a parallel script, which is what keeps the ambient runner from
-- ever touching quest state.
mod.content.commands:register("example_lost_parcel:count_ask", {
foreground = true,
fn = function(ctx)
-- mod: fields route into save.modData[owner], so quest scratch state
-- is attributable and two quests never collide on one key
local base = ctx.save.modData and ctx.save.modData[mod.id]
local asked = (base and base.asked_count or 0) + 1
ctx.save.modData = ctx.save.modData or {}
ctx.save.modData[mod.id] = ctx.save.modData[mod.id] or {}
ctx.save.modData[mod.id].asked_count = asked
-- the same number through the loader-side namespace, which is what a
-- screen or another mod would read
mod.save:set("asked_count", asked)
end,
})
-- ------- handing a branch back to the base conversation
-- talk dispatch is single-winner, so the Pewter rows below replace the
-- engine's handler outright. Re-running the TEXT_ constant with
-- show_text would only replay its opening line: the base handler is a
-- Lua function that asks YES/NO and answers with one of two follow-ups,
-- and none of that survives a text lookup. baseTalk reaches the handler
-- still sitting behind the override (09 6), so the branches the quest
-- does not own play the whole vanilla conversation.
mod.content.commands:register("example_lost_parcel:base_nerd_chat", {
foreground = true,
fn = function(ctx)
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
if not base then return end
local runner = ctx.runner
base(ctx.game, ctx.overworld, ctx.npc, function() runner:resume() end)
runner:yield()
end,
})
-- ------- Viridian City: the quest giver
mod.content.map_scripts:register("VIRIDIAN_CITY", {
talk = {
TEXT_VIRIDIANCITY_GAMBLER1 = {
{ "check_flag", DONE },
{ "jump_if_true", "after" },
{ "check_flag", STARTED },
{ "jump_if_true", "pending" },
{ "show_text", "I dropped a parcel\nsomewhere in\nPEWTER CITY..." },
{ "choice", { "SURE", "NO WAY" } },
{ "jump_if_false", "refused" },
{ "set_flag", STARTED },
{ "set_field", "mod:asked_count", 0 },
{ "show_text", "Thanks! A {EXAMPLE_PARCEL_REWARD}\nawaits you!" },
{ "jump", "end" },
{ "label", "pending" },
{ "example_lost_parcel:count_ask" },
{ "check_item", PARCEL },
{ "jump_if_false", "remind" },
{ "take_item", PARCEL },
{ "give_item", REWARD },
{ "set_flag", DONE },
{ "emote", "player", "happy", 45 },
{ "show_text", "You found it!\nHere, as promised!" },
{ "jump", "end" },
{ "label", "remind" },
{ "show_text", "It's a small brown\nparcel. PEWTER CITY!" },
{ "jump", "end" },
{ "label", "refused" },
{ "show_text", "Aww. GYMs are\nclosed anyway..." },
{ "jump", "end" },
{ "label", "after" },
{ "show_text", "Thanks again,\n{PLAYER}!" },
},
},
})
-- ------- Pewter City: the parcel, and some ambience while it is lost
mod.content.map_scripts:register("PEWTER_CITY", {
talk = {
TEXT_PEWTERCITY_SUPER_NERD1 = {
{ "check_flag", STARTED },
{ "jump_if_false", "vanilla" },
{ "check_flag", TAKEN },
{ "jump_if_true", "vanilla" },
{ "show_text", "Someone dropped\nthis parcel by the\nMUSEUM." },
{ "give_item", PARCEL, 1, false },
{ "set_flag", TAKEN },
{ "show_text", "{PLAYER} got the\nparcel back!" },
{ "jump", "end" },
-- every branch the quest does not own replays the base handler, so
-- the vanilla conversation is never lost to the override
{ "label", "vanilla" },
{ "example_lost_parcel:base_nerd_chat" },
},
},
-- all-run: this composes with the engine's own onEnter for the map
-- instead of replacing it
onEnter = function(game, ow)
local flags = game.save and game.save.flags or {}
if flags[STARTED] and not flags[TAKEN] then
ow:queueScript({ { "run_parallel", "PEWTER_CITY/example_nerd_pace" } })
end
end,
scripts = {
-- background-legal verbs only; the runner rejects foreground rows in
-- a parallel slot, and the script dies on map exit
example_nerd_pace = {
{ "label", "top" },
{ "march_in_place", NERD_INDEX, true }, { "wait", 90 },
{ "march_in_place", NERD_INDEX, false }, { "wait", 150 },
{ "jump", "top" },
},
},
})
-- quest completion is worth announcing to other mods; a mod may only
-- broadcast under its own prefix
mod.events:on("flag.changed", function(ev)
if ev.name == DONE and ev.value then
mod.events:emit("mod.example_lost_parcel.completed", { reward = REWARD })
end
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_lost_parcel",
"name": "The Lost Parcel",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "QUEST",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Quest-author gallery entry: a two-town fetch quest over real TEXT constants, with choices, labels, MOD_ flags and a parallel ambient script."
}
@@ -0,0 +1,27 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "quest", "story", "scripting" },
differences = {
changed = {
"the Viridian gambler and the Pewter super nerd gain quest branches; "
.. "their vanilla lines still play on every other branch",
},
added = {
"EXAMPLE_LOST_PARCEL_PARCEL key item",
"{EXAMPLE_PARCEL_REWARD} text token",
"example_lost_parcel:count_ask script verb",
"example_lost_parcel:base_nerd_chat script verb, which replays the "
.. "super nerd's base conversation the quest branches around",
"an ambient parallel script on PEWTER_CITY while the parcel is lost",
},
known = { "the parcel can be tossed from the bag; the quest then stalls at the reminder line" },
},
credits = {
{ who = "pret/pokered", for_ = "the TEXT_ constants and base conversations this composes with" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,163 @@
-- Standalone: luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
-- Plays the quest end to end headlessly: accept, fetch, hand over.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local Font = require("src.render.Font")
local ScriptRunner = require("src.script.ScriptRunner")
local MapScripts = require("src.script.MapScripts")
-- the engine attaches its hand-ported scripts as the base contribution at
-- boot; the quest composes on top of them, so the harness needs them too
require("data.scripts.init")
Font.load(Data)
local run = T.sdk.loadMod("mods/examples/example_lost_parcel", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.check(Data.items.EXAMPLE_LOST_PARCEL_PARCEL ~= nil, "the parcel item merged")
T.check(Data.tokens.EXAMPLE_PARCEL_REWARD ~= nil, "the reward token merged")
T.check(Data.commands["example_lost_parcel:count_ask"] ~= nil,
"the mod's script verb merged")
-- a stack whose boxes are answered from OUTSIDE the coroutine: a text box
-- pushed by show_text resolves on the next drive tick, never re-entrantly
local choice = 1
local function newGame()
local game = { data = Data, save = {
flags = {}, inventory = {}, modData = {},
player = { name = "RED", rival = "BLUE" },
} }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
game.stack = stack
game.shown = {} -- first line of every text box, in order
game.asked = 0 -- YES/NO boxes the conversation put up
return game
end
-- advance one pending box or emote hold; choice menus pick `choice`
local function settle(game, ow)
if ow.emote then
local held = ow.emote
ow.emote = nil
held.onDone()
return true
end
local top = table.remove(game.stack.states)
if not top then return false end
if top.pages then
game.lastText = top.pages[1] and top.pages[1][1]
game.shown[#game.shown + 1] = game.lastText
end
if top.items then
local item = top.items[choice]
if item and item.onSelect then item.onSelect() end
elseif top.onChoose then
-- a bare ChoiceBox: what the engine's own Lua talk handlers ask with
game.asked = game.asked + 1
top.onChoose(choice == 1)
elseif top.onDone then
top.onDone()
end
return true
end
-- the first line of a generated text constant, which is what a TextBox
-- paginates onto its first row
local function firstLine(s) return (tostring(s):match("^[^\n\f\v]*")) end
-- the overworld the talk dispatch would supply; its map label is what
-- show_text resolves a bare TEXT_ constant through
local function overworldFor(mapId)
return { map = { id = mapId, def = { label = Data.maps[mapId].label } } }
end
local function talk(game, mapId, textConst)
local rows = MapScripts.talkScript(mapId, textConst)
T.check(rows ~= nil, mapId .. "." .. textConst .. " has a talk script")
local ow = overworldFor(mapId)
local runner = ScriptRunner.new(game, ow)
runner:run(rows, { source = MapScripts.talkSource(mapId, textConst) })
for _ = 1, 400 do
if not runner:isRunning() then break end
if not settle(game, ow) then runner:update() end
end
T.check(not runner:isRunning(), "the " .. textConst .. " script completed")
end
-- ------- branch 1: refuse the quest
do
choice = 2
local game = newGame()
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(not game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED,
"refusing the choice leaves the quest unstarted")
end
-- ------- branch 2: accept, fetch, deliver
local game = newGame()
choice = 1
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED, "accepting sets the started flag")
T.eq(game.save.modData.example_lost_parcel.asked_count, 0,
"set_field mod: wrote into the mod's own save namespace")
-- The override wins talk dispatch outright, so the branches the quest does
-- not own owe the player the whole base conversation -- which for this NPC
-- is an opening line, a YES/NO prompt and one of two follow-ups, not a
-- single line. Both answers are driven, before the quest starts and again
-- once the parcel is gone.
local NERD_INTRO = Data.text._PewterCitySuperNerd1DidYouCheckOutMuseumText
local NERD_YES = Data.text._PewterCitySuperNerd1WerentThoseFossilsAmazingText
local NERD_NO = Data.text._PewterCitySuperNerd1YouHaveToGoText
T.check(NERD_INTRO and NERD_YES and NERD_NO,
"the base super nerd conversation is in the generated text")
local function readsVanillaNerd(when, flags)
for _, answer in ipairs({ 1, 2 }) do
local plain = newGame()
for name, value in pairs(flags or {}) do plain.save.flags[name] = value end
choice = answer
talk(plain, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
T.check((plain.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
when .. ": the base branch never hands out the parcel")
T.eq(plain.asked, 1, when .. ": the vanilla YES/NO prompt still comes up")
T.eq(#plain.shown, 2, when .. ": the opening line and a follow-up both play")
T.eq(plain.shown[1], firstLine(NERD_INTRO), when .. ": the vanilla opening line")
T.eq(plain.shown[2], firstLine(answer == 1 and NERD_YES or NERD_NO),
when .. ": the follow-up answers the choice the player made")
end
end
readsVanillaNerd("before the quest")
readsVanillaNerd("after the parcel is taken", {
MOD_EXAMPLE_LOST_PARCEL_STARTED = true,
MOD_EXAMPLE_LOST_PARCEL_TAKEN = true,
})
choice = 1
talk(game, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_TAKEN, "the parcel is taken")
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) > 0,
"the parcel is in the bag")
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_DONE, "the quest completes")
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
"the parcel is consumed")
T.check((game.save.inventory.NUGGET or 0) > 0, "the NUGGET reward is paid")
T.eq(game.save.modData.example_lost_parcel.asked_count, 1,
"the mod's own verb counted the one pending visit")
-- the ambient script is background-legal: no foreground verb in it
local rows = MapScripts.namedScript("PEWTER_CITY", "example_nerd_pace")
T.check(rows ~= nil, "the parallel ambient script is registered")
run.release()
T.finish("example_lost_parcel")
@@ -0,0 +1,13 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- Three original species with cries and icons, and their art generator.
- The `SABLE_COVE` map and encounter table.
- `SABLE_TIDE_BADGE` and the one-badge constants override.
- The `SableTitle` screen and the `field.boot` override that reaches it.
@@ -0,0 +1,103 @@
# Sable Cove (Mini Conversion)
The smallest thing that is recognizably a *different game*: its own title
screen, its own starting town, its own three-species dex, its own single
badge — running on the same engine, with the same import.
**Persona: the Total-Conversion Team.** This is the capstone skeleton. It
is deliberately incomplete as a game and deliberately complete as a
demonstration of which seams a conversion owns.
## Legal callout (read this first)
A total conversion on this engine is a **recipe, not a redistribution**.
- The Red import still runs. It supplies the fallback infrastructure this
conversion sits on: the `OVERWORLD` tileset, the font, the move table,
the type chart. That data lives on the player's machine, decoded from
the player's own ROM.
- The conversion overrides on top. Everything under `assets/` here is
original work, plotted pixel by pixel by
`tools/make_assets.py` — run it yourself and diff the output.
- It never ships extracted content, and it never launders extracted
content into "new" species by transforming Red sprites. If your
conversion wants to *derive* art from the player's cache, that is what
`assets_transforms` is for — see
`mods/examples/example_shiny_palette/` for the worked pattern.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_mini_conversion --base imported
python3 tools/modkit.py lint mods/examples/example_mini_conversion
luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
# regenerate the original art from its shape tables
python3 mods/examples/example_mini_conversion/tools/make_assets.py
```
Enable it (`example_mini_conversion = true` under `mods` in `options.lua`,
or the F10 manager) and start the game. You land on the SABLE COVE title
screen; NEW GAME spawns you in Sable Cove with 1500 money as SABLE.
## What it demonstrates
| Seam | Where |
|---|---|
| `profile = "total_conversion"` | `manifest.json` — implies `affects_link` |
| `content.field:patch("boot", …)` | `main.lua` — spawn, names, money, boot screens |
| `content.constants:patch` / `:override` | `main.lua` — dex size, level cap, badge list |
| `content.pokemon:register` | `main.lua` — three species with full records |
| `content.cries:register` (ChipAsm) | `main.lua` — one authored effect per species |
| `content.icons:register` | `main.lua` — party icons keyed by species id |
| `content.maps:register` | `main.lua` — one map on the imported tileset |
| `content.encounters:register` | `main.lua` — its wild table |
| `content.items:register` | `main.lua` — the badge, which is an item |
| `content.screens:register` | `main.lua` — the title screen the boot config names |
| `events:on("game.ready")` | `main.lua` — checking the boot merge actually took |
## patch vs override on a deep registry
`constants` and `field` are **deep** registries. Two rules differ from the
record registries, and both bite a conversion:
1. `register` and `patch` are the same verb. A partial payload is the
normal case; only the keys you name move.
2. **Lists append.** That is deliberate — two mods each adding a badge both
land. But a conversion wants to *replace* the badge list, and appending
would leave Kanto's eight in front of its one:
```lua
mod.content.constants:patch("badges", { … }) -- 8 + 1 = 9 badges
mod.content.constants:override("badges", { … }) -- 1 badge
```
`override` is the verb that drops a list. The test asserts both behaviors.
`field.boot` is the opposite case: `patch` is right there, because the keys
this conversion does not name (`startFacing`, the `splash` and `newGame`
screen ids) should keep the engine's values.
## Priority
`"priority": 900`. A conversion wants to merge *after* content mods so its
`field.boot` and `constants` win. If another mod still beats it, the
`game.ready` listener says so by name instead of leaving the player on a
map they did not expect.
## What a real conversion adds next
This skeleton stops at the boundary of the mechanism demonstration. A
shipping conversion continues with:
- `map_scripts` for the story (see `mods/examples/example_lost_parcel/`)
- `maps:remove` / `pokemon:remove` tombstones to hide Kanto content
- `content.text` and `text_pointers` for its own dialogue
- `content.music` for its soundtrack (see `mods/examples/example_jukebox/`)
- `link_fields` and an honest `affects_link` so its players do not corrupt
each other's saves in a trade
## Credits
- All original sprite art: this project (`tools/make_assets.py`).
- pret/pokered: the `OVERWORLD` tileset, font and move table this builds on.
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

@@ -0,0 +1,180 @@
-- Gallery #7 (Total-conversion team): the smallest thing that is
-- recognizably a different game. Its own boot config, its own title
-- screen, its own three-species dex, its own single badge, and one map.
--
-- LEGAL POSTURE (constraint 1): the Red import still runs and supplies the
-- fallback infrastructure this conversion sits on -- the OVERWORLD tileset,
-- the font, the move table. The conversion overrides on top. Every pixel
-- under assets/ is original work generated by tools/make_assets.py; nothing
-- here transforms extracted Red art into "new" content.
local MAP = "SABLE_COVE"
local BADGE = "SABLE_TIDE_BADGE"
-- three original species; ids are namespaced so a mixed load cannot
-- collide with Red's
local DEX = {
{ id = "SABLE_EMBERKIT", name = "EMBERKIT", dex = 1, art = "emberkit",
types = { "FIRE" }, stats = { hp = 45, attack = 60, defense = 40,
speed = 65, special = 50 } },
{ id = "SABLE_TIDEPUP", name = "TIDEPUP", dex = 2, art = "tidepup",
types = { "WATER" }, stats = { hp = 50, attack = 48, defense = 60,
speed = 45, special = 55 } },
{ id = "SABLE_MOSSLING", name = "MOSSLING", dex = 3, art = "mossling",
types = { "GRASS", "POISON" }, stats = { hp = 55, attack = 50,
defense = 55, speed = 40,
special = 60 } },
}
return function(mod)
local ChipAsm = require("src.audio.ChipAsm")
-- ------- species, cries and icons
for _, entry in ipairs(DEX) do
mod.content.pokemon:register(entry.id, {
id = entry.id,
name = entry.name,
dex = entry.dex,
types = entry.types,
baseStats = entry.stats,
catchRate = 190,
baseExp = 64,
growthRate = "MEDIUM_FAST",
level1Moves = { "TACKLE" },
learnset = {
{ level = 7, move = "GROWL" },
{ level = 13, move = "QUICK_ATTACK" },
},
evolutions = {},
spriteFront = mod.path .. "/assets/" .. entry.art .. "_front.png",
spriteBack = mod.path .. "/assets/" .. entry.art .. "_back.png",
frontSize = 5,
cry = entry.id,
icon = { image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
frames = 2 },
})
-- one authored chip effect per species, keyed by species id exactly
-- like the vanilla cry table
mod.content.cries:register(entry.id, {
chip = ChipAsm.sfx{
channels = { { hw = 1, program = {
{ pitchSweep = { pace = 2 + entry.dex, subtract = entry.dex == 2,
shift = 3 } },
{ squareNote = { len = 5, volume = 13, fade = 2,
frequency = 0x480 + entry.dex * 0x60 } },
} } },
}.chip,
pitch = 128, length = 128,
})
mod.content.icons:register(entry.id, {
image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
frames = 2,
})
end
-- ------- the badge, which is an item like every vanilla badge
mod.content.items:register(BADGE, {
id = BADGE, name = "TIDEBADGE", price = 0, keyItem = true, tossable = false,
})
-- ------- the rules the engine used to hard-code
-- deep registry: register and patch are the same verb, and only the keys
-- named here move. Everything else keeps its imported value.
mod.content.constants:patch("dexSize", #DEX)
mod.content.constants:patch("dexDigits", 1)
mod.content.constants:patch("levelCap", 50)
-- override, not patch: under deep semantics a list APPENDS, so patching
-- here would leave Kanto's eight badges in front of this one. override
-- is the verb that drops a list, which is exactly what a conversion wants
mod.content.constants:override("badges", { { id = BADGE, name = "TIDE" } })
mod.content.constants:override("hmMoves", { "CUT", "SURF" })
-- ------- the one map
local blocks = {}
for i = 1, 10 * 9 do blocks[i] = 1 end
mod.content.maps:register(MAP, {
id = MAP,
label = "SableCove",
index = 1000,
tileset = "OVERWORLD",
width = 10, height = 9,
blocks = blocks,
borderBlock = 11,
warps = {}, objects = {}, signs = {},
})
mod.content.encounters:register(MAP, {
grass = { rate = 25, slots = {
{ level = 3, species = "SABLE_EMBERKIT" },
{ level = 3, species = "SABLE_TIDEPUP" },
{ level = 3, species = "SABLE_MOSSLING" },
{ level = 4, species = "SABLE_TIDEPUP" },
{ level = 4, species = "SABLE_MOSSLING" },
{ level = 5, species = "SABLE_EMBERKIT" },
{ level = 5, species = "SABLE_TIDEPUP" },
{ level = 5, species = "SABLE_MOSSLING" },
{ level = 6, species = "SABLE_EMBERKIT" },
{ level = 6, species = "SABLE_MOSSLING" },
} },
})
-- ------- the new game itself
mod.content.field:patch("boot", {
startMap = MAP, startX = 5, startY = 4, startFacing = "down",
playerName = "SABLE", rivalName = "CORAL",
startMoney = 1500,
lastHeal = { map = MAP, x = 5, y = 4 },
namePresets = { player = { "SABLE", "WREN", "PIKE" },
rival = { "CORAL", "REEF", "SHOAL" } },
-- the conversion owns the boot flow; splash and newGame keep the
-- engine screens, which is the point of naming them individually
screens = { title = "SableTitle" },
})
-- ------- the title screen
mod.content.screens:register("SableTitle", {
new = function(game, opts)
opts = opts or {}
local Font = mod.ui.Font
local state = { game = game, isOpaque = true, blink = 0 }
function state:update()
self.blink = (self.blink + 1) % 60
local input = game.input
if not input then return end
if input:wasPressed("a") or input:wasPressed("start") then
if opts.onNewGame then opts.onNewGame() end
end
end
function state:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("SABLE COVE", 40, 40)
Font.draw("A MINI CONVERSION", 12, 56)
if self.blink < 40 then Font.draw("PRESS A", 52, 104) end
end
return state
end,
})
mod.events:on("game.ready", function(ev)
-- the payload carries the live Game; a conversion uses it to check
-- that its own boot config actually took
local boot = ev.game and ev.game.data and ev.game.data.field
and ev.game.data.field.boot
if not (boot and boot.startMap == MAP) then
mod.log:warn("another mod owns field.boot; raise this mod's priority "
.. "above %s to win the merge", tostring(boot and boot.startMap))
end
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_mini_conversion",
"name": "Sable Cove (Mini Conversion)",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "total_conversion",
"category": "TOTAL_CONVERSION",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 900,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Total-conversion gallery entry: a one-town game with its own boot, title, three-species dex and one badge."
}

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