Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aad86fb351 | |||
| 2c9970a643 | |||
| 352a9a0eb8 | |||
| 5533785364 | |||
| 11f4f5570e | |||
| 6239387643 | |||
| 4a50cb0728 | |||
| 78aa1f3c0a | |||
| 5128b840b1 | |||
| da14b7ee3f | |||
| 2dbe21babf | |||
| 7a85862602 | |||
| 09b12a7cf0 | |||
| e2f2b432ff | |||
| 8c274d969e | |||
| 4c7633b56e | |||
| 943ae23e73 | |||
| 839cf19088 | |||
| 5019ba2caf | |||
| 0b267fc536 | |||
| 290bde1c39 | |||
| 7e92e54669 | |||
| b89b895962 | |||
| 4311955c43 | |||
| 747d9acad1 | |||
| 5041125751 | |||
| 15029d811f | |||
| 2c2a5a4220 | |||
| f64666c6ce | |||
| 3f4aaccbf5 | |||
| 20ad4e6c41 | |||
| 60b3cebdce | |||
| 9468ffccdf | |||
| 948ae8abbd | |||
| cb22684c2a | |||
| 94be169e35 | |||
| 36188ef18a | |||
| 7a1b4e5c45 | |||
| eba6e8d752 | |||
| ec5a72b365 | |||
| 9325d56046 | |||
| d650e605b0 | |||
| 47923d95b3 |
@@ -0,0 +1,101 @@
|
||||
name: Bug report
|
||||
description: Something in the game is broken, wrong, or not behaving like the original.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
A screenshot is worth more than any description. If you can grab one, grab one.
|
||||
If you genuinely can't, that's fine, but then the details below need to be thorough
|
||||
enough that someone can find the bug without ever seeing your screen.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What's broken, in a sentence.
|
||||
placeholder: Warp at the south end of the Route 5-8 underground path drops you in the wrong spot
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Which build are you running
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Both
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The release you downloaded. It's in the release title, like 0.1.4.
|
||||
placeholder: 0.1.4
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Where in the game
|
||||
description: >
|
||||
Be specific. Route, town, building, floor, menu, battle. If it's a map
|
||||
transition, say which side you came in from.
|
||||
placeholder: Underground path between Route 5 and Route 8, south entrance
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: Screenshot, or a detailed description if you couldn't take one
|
||||
description: >
|
||||
Drag the image straight into this box. If you couldn't get a screenshot,
|
||||
write "no screenshot" and then describe exactly what was on screen: where
|
||||
the player sprite was, what the tiles around it looked like, any text on
|
||||
screen, whether anything was frozen or flickering. The more concrete, the better.
|
||||
placeholder: |
|
||||
Drop the image here.
|
||||
|
||||
Or, with no screenshot:
|
||||
Screen faded out like a normal warp, then faded back in with the player
|
||||
standing on the grass tile north of the Route 6 gate house instead of
|
||||
inside the tunnel. Player was facing down. No text box. Music kept playing
|
||||
the underground path track instead of switching to the Route 6 track.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to make it happen
|
||||
description: Numbered steps, starting from a point someone else can get to.
|
||||
placeholder: |
|
||||
1. Start a new game, get to Cerulean
|
||||
2. Go south to Route 5, enter the underground path building
|
||||
3. Walk to the south end of the tunnel
|
||||
4. Step onto the stairs
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What should have happened
|
||||
placeholder: Should have come out inside the Route 8 side entrance building, facing up.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Does it happen every time or only sometimes. Did it start after a specific
|
||||
release. Anything you were doing right before it. Leave blank if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Feature request
|
||||
description: Ask for something new, or a change to how something already works.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
and how you picture it working. A request with real detail is one someone can
|
||||
actually build without guessing at what you meant.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live in the game, what does the
|
||||
player see or do. If it changes something that already exists, say what it does today
|
||||
and what it should do instead.
|
||||
placeholder: |
|
||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||
foot and the player moves at bike speed. Release and you go back to walking.
|
||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing
|
||||
description: >
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||
without making the game easier.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
||||
know, say what you'd expect as a player and leave the rest open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld to run
|
||||
- Same speed as the bike
|
||||
- Disabled anywhere the bike is disabled
|
||||
- Sprite should probably need a running animation, not sure what that takes
|
||||
- No effect on encounter rates
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Does this change how the original game plays
|
||||
description: >
|
||||
Some requests are quality of life, some change the actual game. Both are fine,
|
||||
it just helps to know which one you're asking for.
|
||||
options:
|
||||
- Quality of life, original game is untouched
|
||||
- Changes how the game plays
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, how another game does it, related issues. Leave blank
|
||||
if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -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
|
||||
@@ -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,8 +208,12 @@ 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"
|
||||
ls -lh "$outdir"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
@@ -206,14 +224,49 @@ jobs:
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Issues this release closes, per GitHub's own "closing issues" links
|
||||
# (works for squash, rebase, and merge commits alike). Scans every
|
||||
# commit since the previous tag so a skipped release run doesn't drop
|
||||
# issues on the floor.
|
||||
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
|
||||
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
|
||||
|
||||
closed=""
|
||||
for pr in $(git log --pretty=%H "$range" \
|
||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||
--jq '.[].number' 2>/dev/null \
|
||||
| sort -un || true); do
|
||||
closed+="$(gh api graphql \
|
||||
-f owner="${GITHUB_REPOSITORY%/*}" \
|
||||
-f name="${GITHUB_REPOSITORY#*/}" \
|
||||
-F pr="$pr" \
|
||||
-f query='
|
||||
query($owner:String!, $name:String!, $pr:Int!) {
|
||||
repository(owner:$owner, name:$name) {
|
||||
pullRequest(number:$pr) {
|
||||
closingIssuesReferences(first:50) { nodes { number title } }
|
||||
}
|
||||
}
|
||||
}' \
|
||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
|
||||
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
|
||||
done
|
||||
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
||||
|
||||
notes="Download the correct version for your computer below."
|
||||
if [ -n "$closed" ]; then
|
||||
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
gh release create "$tag" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$v" \
|
||||
--notes "$notes" \
|
||||
"dist/release/PokemonRed-${v}-macos.zip" \
|
||||
"dist/release/PokemonRed-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -10,19 +10,11 @@ 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
@@ -30,114 +22,92 @@ 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Controls
|
||||
|
||||
## Source Checkout
|
||||
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.
|
||||
|
||||
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:
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place the 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/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.
|
||||
|
||||
## Wanna Support My Work?
|
||||
[](https://buymeacoffee.com/bryanthaboi)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 370 KiB |
@@ -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,29 @@ 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 touch input is gesture-based,
|
||||
-- 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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Celadon Chief House (scripts/CeladonChiefHouse.asm). The CHIEF is a
|
||||
-- talk-only NPC in the original ROM, and ChiefData (data/trainers/
|
||||
-- parties.asm) is an empty, unreferenced trainer entry. After the HALL
|
||||
-- OF FAME (EVENT_BEAT_CHAMPION_RIVAL) the CHIEF puts up a fight, using a
|
||||
-- reconstructed party for the otherwise-unused OPP_CHIEF class.
|
||||
|
||||
return {
|
||||
talk = {
|
||||
TEXT_CELADONCHIEFHOUSE_CHIEF = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
|
||||
{ "jump_if_false", 12 }, -- 3
|
||||
{ "check_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 4
|
||||
{ "jump_if_true", 12 }, -- 5
|
||||
{ "show_text", "So you've come to\nshut down my\noperation?\f"
|
||||
.. "TEAM ROCKET's\nCHIEF won't go\ndown so easy!" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_CHIEF", 1 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 8
|
||||
{ "set_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 9
|
||||
{ "show_text", "Gah! Even the\nCHIEF is no match\nfor you!\f"
|
||||
.. "TEAM ROCKET is\nfinished for\ngood!" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
{ "show_text", "_CeladonChiefHouseChiefText" }, -- 12
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -5,10 +5,11 @@
|
||||
-- The escort choreography (SUPER_NERD1 walking the player to the
|
||||
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
|
||||
-- movement + a wPewterCityCurScript state machine that steers the
|
||||
-- player off-map; that part is already covered on this map by
|
||||
-- story5.lua's onStep gate (walks the player back a step at the
|
||||
-- east exit before EVENT_BEAT_BROCK). Here we only port the real
|
||||
-- YES/NO-branched flavor text these NPCs speak when talked to.
|
||||
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
|
||||
-- map by story5.lua's onStep gate at the east exit (before
|
||||
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
|
||||
-- Here we only port the real YES/NO-branched flavor text these NPCs
|
||||
-- speak when talked to.
|
||||
|
||||
local M = {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
||||
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
||||
-- afterwards he gloats or sulks and marches out of the lab
|
||||
-- (OaksLabRivalBattleEndScript).
|
||||
-- afterwards HealParty + flag always, then he gloats or sulks and
|
||||
-- marches out (OaksLabRivalEndBattleScript). A loss does not black out.
|
||||
|
||||
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
|
||||
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
|
||||
@@ -19,32 +19,35 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||
{ "jump_if_true", 19 }, -- 2
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 19 }, -- 4
|
||||
{ "ask", askText }, -- 5
|
||||
{ "jump_if_false", 20 }, -- 6
|
||||
{ "give_pokemon", species, 5 }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8
|
||||
{ "set_flag", choseFlag }, -- 9
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text)
|
||||
{ "push_screen", "DexEntryMenu", species }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
{ "give_pokemon", species, 5 }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
|
||||
{ "set_flag", choseFlag }, -- 10
|
||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 11
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12
|
||||
{ "face_object", 1, "up" }, -- 13
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
{ "face_object", 1, "up" }, -- 14
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 16
|
||||
{ "jump", 20 }, -- 17
|
||||
{ "jump", 20 }, -- 18 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", 21 }, -- 18
|
||||
{ "jump", 21 }, -- 19 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||
}
|
||||
end
|
||||
|
||||
@@ -58,33 +61,40 @@ return {
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
||||
{ "jump_if_false", 12 }, -- 3
|
||||
{ "jump_if_false", 14 }, -- 3
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
||||
{ "jump_if_true", 12 }, -- 5
|
||||
{ "jump_if_true", 14 }, -- 5
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
||||
{ "jump", 30 }, -- 11
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 12
|
||||
{ "jump_if_false", 27 }, -- 13
|
||||
{ "check_item", "POKE_BALL" }, -- 14
|
||||
{ "jump_if_true", 25 }, -- 15
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16
|
||||
{ "jump_if_false", 29 }, -- 17
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18
|
||||
{ "jump_if_true", 25 }, -- 19
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 21
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23
|
||||
{ "jump", 30 }, -- 24
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25
|
||||
{ "jump", 30 }, -- 26
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27
|
||||
{ "jump", 30 }, -- 28
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end)
|
||||
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
|
||||
{ "jump", 32 }, -- 13
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
|
||||
{ "jump_if_false", 29 }, -- 15
|
||||
{ "check_item", "POKE_BALL" }, -- 16
|
||||
{ "jump_if_true", 27 }, -- 17
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
|
||||
{ "jump_if_false", 31 }, -- 19
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
||||
{ "jump_if_true", 27 }, -- 21
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 23
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
|
||||
{ "jump", 32 }, -- 26
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
|
||||
{ "jump", 32 }, -- 28
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
|
||||
{ "jump", 32 }, -- 30
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
@@ -100,9 +110,9 @@ return {
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||
{ "jump_if_false", 20 }, -- 3
|
||||
{ "jump_if_false", 21 }, -- 3
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
||||
{ "jump_if_true", 18 }, -- 5
|
||||
{ "jump_if_true", 19 }, -- 5
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||
{ "jump_if_false", 11 }, -- 8
|
||||
@@ -113,18 +123,20 @@ return {
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
||||
{ "jump", 16 }, -- 14
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16
|
||||
{ "jump", 21 }, -- 17
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18
|
||||
{ "jump", 25 }, -- 19
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20
|
||||
-- battle aftermath: on a win the rival sulks and marches out
|
||||
-- (OaksLabRivalBattleEnd); on a loss the blackout already warped
|
||||
-- us away, so the script just ends
|
||||
{ "jump_if_false", 25 }, -- 21
|
||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22
|
||||
{ "move_npc_to", 1, 4, 11 }, -- 23
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end)
|
||||
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
|
||||
{ "heal_party" }, -- 16
|
||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
|
||||
{ "jump", 23 }, -- 18
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
|
||||
{ "jump", "end" }, -- 22
|
||||
-- win: sulk text then exit; loss: Rival1WinText already played in
|
||||
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
|
||||
{ "jump_if_false", 25 }, -- 23
|
||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
|
||||
{ "move_npc_to", 1, 4, 11 }, -- 25
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
|
||||
},
|
||||
},
|
||||
|
||||
@@ -171,9 +183,12 @@ return {
|
||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- a loss blacks out to another map: end the script there
|
||||
table.insert(rows, { "jump_if_false", base + 7 })
|
||||
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
|
||||
-- already shown in-battle via Rival1WinText)
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,31 @@ local M = {}
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.VIRIDIAN_MART = {
|
||||
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
|
||||
-- script, not a talk. Entering with a starter and no parcel runs
|
||||
-- ViridianMartDefaultScript -- the clerk calls out, then
|
||||
-- StartSimulatingJoypadStates walks the player to the counter
|
||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||
-- never presses A. This matters beyond convenience: the parcel gates
|
||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||
-- it on entry rather than letting you walk out without it.
|
||||
--
|
||||
-- The talk branch below is kept as the fallback for a save that reaches
|
||||
-- the counter without this having fired.
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
|
||||
if not f.EVENT_GOT_STARTER then return end
|
||||
ow:queueScript({
|
||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 2 },
|
||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||
})
|
||||
end,
|
||||
talk = {
|
||||
TEXT_VIRIDIANMART_CLERK = {
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
||||
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
-- the old man napping on the north path (scripts/ViridianCity.asm)
|
||||
-- after his coffee, the old man offers the catch tutorial: not in a
|
||||
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
|
||||
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
|
||||
-- only ever grumbles and shoves you back down -- he never wakes,
|
||||
-- moves or hides. The coffee ask and the catch tutorial belong to
|
||||
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
|
||||
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
|
||||
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
|
||||
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
||||
{ "jump_if_true", 5 }, -- 2
|
||||
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
|
||||
{ "jump", 14 }, -- 4
|
||||
{ "face_player" }, -- 5
|
||||
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
|
||||
{ "jump_if_true", 12 }, -- 7 (in a hurry)
|
||||
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
|
||||
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
|
||||
{ "old_man_demo" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
||||
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
|
||||
{ "move_player", "down", 1 }, -- 2
|
||||
},
|
||||
|
||||
-- The walking old man at (17,5), shown once the Pokédex swaps him in
|
||||
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
|
||||
-- off, NO leads into the catch tutorial: he explains, demos a catch
|
||||
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
|
||||
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
|
||||
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
|
||||
TEXT_VIRIDIANCITY_OLD_MAN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
|
||||
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
|
||||
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
|
||||
{ "old_man_demo" }, -- 5
|
||||
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
|
||||
{ "jump", 9 }, -- 7
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||
},
|
||||
},
|
||||
-- the sleeping old man lies across the Route 22 path (18,9): until
|
||||
-- he moves you can't slip past on either side (scripts/ViridianCity
|
||||
-- blocks the whole corridor, not just his tile)
|
||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||
-- and the girl (17,9). With the Pokédex the check returns immediately
|
||||
-- and you simply walk past at x=19.
|
||||
onStep = function(game, ow, x, y)
|
||||
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
|
||||
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
|
||||
if gone then return false end
|
||||
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
|
||||
if y <= 8 and x >= 17 and x <= 19 then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
|
||||
if x ~= 19 or y ~= 9 then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -102,26 +133,107 @@ M.BLUES_HOUSE = {
|
||||
|
||||
M.BILLS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BILLSHOUSE_BILL_POKEMON = {
|
||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
|
||||
{ "jump_if_true", 11 }, -- 2
|
||||
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
|
||||
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
|
||||
-- the cell-separator PC throws its switch
|
||||
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH)
|
||||
{ "play_sound", "Switch" }, -- 5
|
||||
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6
|
||||
-- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
|
||||
-- (NO only adds "No, you gotta help!" before rejoining the YES
|
||||
-- path), then the "get in the TELEPORTER" line and the monster
|
||||
-- walking into the cell-separator machine
|
||||
-- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
|
||||
-- when they stand in the way facing down), where it is hidden and
|
||||
-- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
|
||||
-- OverworldState.billsHousePC for the separator itself.
|
||||
TEXT_BILLSHOUSE_BILL_POKEMON = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
local function toMachine()
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._BillsHouseBillUseSeparationSystemText
|
||||
or "When I'm in the\nTELEPORTER, run\nthe Cell\nSeparation System!", function()
|
||||
local function entered()
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save,
|
||||
overworld = ow },
|
||||
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
done()
|
||||
end
|
||||
if ow.player.facing == "down" then
|
||||
-- the player is standing on his straight path: walk around
|
||||
-- (.PokemonWalkAroundPlayerMovement)
|
||||
ow:scriptMove(npc, "right", 1, function()
|
||||
ow:scriptMove(npc, "up", 2, function()
|
||||
ow:scriptMove(npc, "left", 1, function()
|
||||
ow:scriptMove(npc, "up", 1, entered)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
else
|
||||
ow:scriptMove(npc, "up", 3, entered)
|
||||
end
|
||||
end))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._BillsHouseBillImNotAPokemonText or "Hey! I'm not a\nPOKéMON!",
|
||||
nil, { choice = function(yes)
|
||||
if yes then
|
||||
toMachine()
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._BillsHouseBillNoYouGottaHelpText
|
||||
or "No! You gotta\nhelp me!", toMachine))
|
||||
end
|
||||
end }))
|
||||
end,
|
||||
|
||||
-- BillsHouseBillSSTicketText (human Bill after the separation)
|
||||
TEXT_BILLSHOUSE_BILL_SS_TICKET = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 2
|
||||
{ "jump_if_true", 12 }, -- 3
|
||||
{ "show_text", "_BillsHouseBillThankYouText" }, -- 4
|
||||
-- pokered gives first (GiveItem fills wStringBuffer), then prints
|
||||
-- the received text that reads it (scripts/BillsHouse.asm; the
|
||||
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the
|
||||
-- sound_get_key_item jingle plays like BillsHouse.asm:196)
|
||||
{ "give_item", "S_S_TICKET", 1, false }, -- 7
|
||||
{ "show_text", "_SSTicketReceivedText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
|
||||
{ "give_item", "S_S_TICKET", 1, false }, -- 5
|
||||
{ "show_text", "_SSTicketReceivedText" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
|
||||
-- The two Cerulean guards are a SWAP PAIR, not scenery
|
||||
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
|
||||
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
|
||||
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
|
||||
-- house's south door at (27,11), and that house is one of the two
|
||||
-- ways through the fence that splits Cerulean in half (the badge
|
||||
-- house is the other). Leaving GUARD2 up forever severs the city --
|
||||
-- the gym/mart half can never reach the Route 5 exit.
|
||||
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
|
||||
-- CeruleanHideRocket), so either route opens the path.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
|
||||
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 12
|
||||
},
|
||||
|
||||
TEXT_BILLSHOUSE_BILL_CHECK_OUT_MY_RARE_POKEMON = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 2
|
||||
},
|
||||
},
|
||||
-- repair saves that already got the ticket under the old collapsed
|
||||
-- script (the monster never hidden, human Bill never shown)
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_GOT_SS_TICKET
|
||||
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||
game.save.flags.EVENT_MET_BILL = true
|
||||
game.save.flags.EVENT_MET_BILL_2 = true
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -142,17 +254,48 @@ M.VERMILION_CITY = {
|
||||
game.save.trashPuzzle = puz
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
end,
|
||||
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
||||
-- the unguarded cell (18,30) just west of the sailor leads straight
|
||||
-- onto the dock warp, so stepping onto it heading for the dock gets
|
||||
-- ticket-checked (and turned back once the ship has sailed) without
|
||||
-- the player ever pressing A. The sailor himself never disappears.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x ~= 18 or y ~= 30 then return false end
|
||||
if ow.player.facing ~= "down" then return false end
|
||||
local f = game.save.flags
|
||||
local t = game.data.text
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if f.EVENT_SS_ANNE_LEFT then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
return true
|
||||
end
|
||||
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
|
||||
.. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard."),
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
-- the sailor guarding the dock gangway
|
||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
||||
-- flashing the ticket just lets you through -- he never hides, and
|
||||
-- once the ship has sailed he only reports it gone
|
||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2
|
||||
{ "check_item", "S_S_TICKET" }, -- 3
|
||||
{ "jump_if_false", 8 }, -- 4
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5
|
||||
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6
|
||||
{ "jump", 9 }, -- 7
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
{ "jump", 12 }, -- 8
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -208,7 +351,17 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5
|
||||
{ "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6
|
||||
{ "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7
|
||||
{ "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8
|
||||
-- pokered warps to wDestinationWarpID $1 (0-based) -- the house's
|
||||
-- SECOND warp, the door mat at (3,7) -- facing UP
|
||||
-- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP +
|
||||
-- hWarpDestinationMap MR_FUJIS_HOUSE). Landing (3,3) instead put
|
||||
-- the player at the Pokédex table, and the route's first waypoint
|
||||
-- (3,7) then stepped onto a LIVE door mat and exited the house
|
||||
-- before ever talking to Fuji -- so the POKE_FLUTE was never
|
||||
-- collected and the Route 16 SNORLAX sealed the map. The arrival
|
||||
-- mat itself is inert until stepped off (warpEntryCell), which is
|
||||
-- what makes the vanilla coordinates safe.
|
||||
{ "warp", "MR_FUJIS_HOUSE", 3, 7, "up" }, -- 8
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -343,6 +496,50 @@ M.WARDENS_HOUSE = {
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
-- .PlayerCoordsArray -- (6,13) and (7,12) -- every frame while
|
||||
-- EVENT_BEAT_SILPH_CO_GIOVANNI is unset: standing there shows his text,
|
||||
-- walks him three tiles down (.GiovanniMovement), and starts the fight.
|
||||
-- He also has no trainer-header entry, so sight engagement never fires
|
||||
-- either. Without this hook he was a talk-only statue four tiles away
|
||||
-- from anything the route (or a vanilla-faithful player walking the same
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
local gio
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||
{ "face_player" }, -- 1
|
||||
@@ -515,6 +712,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 +748,17 @@ 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.
|
||||
TEXT_HALLOFFAME_PC = {
|
||||
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
|
||||
{ "jump_if_false", "end" }, -- 2 NO: back away
|
||||
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -567,6 +798,21 @@ M.POKEMON_TOWER_2F = {
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
|
||||
},
|
||||
},
|
||||
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
||||
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
|
||||
-- waits to be talked to
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return false end
|
||||
if not ((x == 15 and y == 5) or (x == 14 and y == 6)) then return false end
|
||||
if ow.runner:isRunning() then return false end
|
||||
local rival = ow:npcByIndex(1)
|
||||
if not rival then return false end
|
||||
ow.player.facing = (x == 15) and "left" or "up"
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
|
||||
{ npc = rival })
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
|
||||
|
||||
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
|
||||
|
||||
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
|
||||
-- (engine/items/inventory.asm), which walks the same three item ids and
|
||||
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
|
||||
local function takeGuardDrink(game)
|
||||
for _, drink in ipairs(DRINKS) do
|
||||
if (game.save.inventory[drink] or 0) > 0 then
|
||||
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
||||
if game.save.inventory[drink] == 0 then
|
||||
game.save.inventory[drink] = nil
|
||||
end
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function saffronGate(guardText, triggers, horizontal)
|
||||
return {
|
||||
talk = {
|
||||
@@ -289,18 +306,15 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
|
||||
return
|
||||
end
|
||||
for _, drink in ipairs(DRINKS) do
|
||||
if (game.save.inventory[drink] or 0) > 0 then
|
||||
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
||||
if game.save.inventory[drink] == 0 then
|
||||
game.save.inventory[drink] = nil
|
||||
end
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!"), done))
|
||||
return
|
||||
end
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
|
||||
function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"You can go on\nthrough!"), done))
|
||||
end))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
|
||||
@@ -317,6 +331,31 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- Stepping on the trigger WITH a drink hands it over right here.
|
||||
--
|
||||
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
|
||||
-- `farcall RemoveGuardDrink` before it decides anything: the coord
|
||||
-- trigger itself takes the drink and sets
|
||||
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
|
||||
-- gets the thirsty line and the walk-back. We had the removal on the
|
||||
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
|
||||
-- bag was turned away and the four gates stayed shut unless you
|
||||
-- happened to talk to him -- which vanilla never requires.
|
||||
--
|
||||
-- Saffron is the middle of the map, so this sealed it: every route
|
||||
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
|
||||
-- short way) was unreachable, and the bot could not get to Lavender
|
||||
-- for the POKE_FLUTE at all.
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
|
||||
function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"You can go on\nthrough!")))
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local back
|
||||
if horizontal then
|
||||
back = ow.player.facing == "left" and "right" or "left"
|
||||
@@ -324,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
back = ow.player.facing == "up" and "down" or "up"
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!",
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||
function()
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
end))
|
||||
@@ -386,6 +425,26 @@ M.BIKE_SHOP = {
|
||||
-- the Cinnabar lab (the wait is skipped).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- The Super Nerd (object index 1) claims both fossils and blocks the way
|
||||
-- to them. In scripts/MtMoonB2F.asm he isn't a sight-line trainer: his
|
||||
-- header carries no range, so MtMoonB2FDefaultScript force-triggers the
|
||||
-- battle the instant the player steps onto (13,8) -- the chokepoint tile
|
||||
-- to his right -- and reaching for a fossil intercepts you too.
|
||||
local function superNerdBeaten(ow)
|
||||
local nerd = ow:npcByIndex(1)
|
||||
return not nerd or ow:trainerDefeated(nerd)
|
||||
end
|
||||
|
||||
local function engageSuperNerd(game, ow, onDone)
|
||||
local nerd = ow:npcByIndex(1)
|
||||
if not nerd or ow:trainerDefeated(nerd) then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
nerd:facePlayer(ow.player)
|
||||
ow:engageTrainer(nerd, onDone)
|
||||
end
|
||||
|
||||
local function mtMoonFossil(itemId, otherName)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -394,7 +453,13 @@ local function mtMoonFossil(itemId, otherName)
|
||||
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function()
|
||||
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
|
||||
if not superNerdBeaten(ow) then
|
||||
engageSuperNerd(game, ow, done)
|
||||
return
|
||||
end
|
||||
local name = game.data.items[itemId].name
|
||||
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then done() return end
|
||||
game.save.inventory[itemId] = 1
|
||||
@@ -403,7 +468,6 @@ local function mtMoonFossil(itemId, otherName)
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
|
||||
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
|
||||
local name = game.data.items[itemId].name
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s got the\n%s!"):format(game.save.player.name, name), done))
|
||||
end))
|
||||
@@ -412,6 +476,15 @@ local function mtMoonFossil(itemId, otherName)
|
||||
end
|
||||
|
||||
M.MT_MOON_B2F = {
|
||||
-- MtMoonB2FDefaultScript forces the Super Nerd battle when the player
|
||||
-- steps onto (13,8), the tile beside him guarding the fossils.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 13 and y == 8 and not superNerdBeaten(ow) then
|
||||
engageSuperNerd(game, ow, nil)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
|
||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
|
||||
|
||||
@@ -63,7 +63,15 @@ M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||
-- (10,16) until identified with the Silph Scope and defeated.
|
||||
-- (10,16) until defeated.
|
||||
--
|
||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||
-- balls dodged). An earlier version of this port turned the player back
|
||||
-- without the scope and never opened the battle, which made 6F
|
||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_TOWER_6F = {
|
||||
@@ -71,26 +79,32 @@ M.POKEMON_TOWER_6F = {
|
||||
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
|
||||
if x ~= 10 or y ~= 16 then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!",
|
||||
function()
|
||||
local back = ow.player.facing == "up" and "down" or "up"
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
"The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function()
|
||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeGhost()
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
if result == "win" then
|
||||
-- wBattleResult parity (PokemonTower6FMarowakBattleScript's
|
||||
-- "and a / jr nz"): losing writes $1 and running writes $2, but
|
||||
-- ItemUsePokeDoll ends the battle WITHOUT touching it, so the
|
||||
-- script reads 0 -- defeated. That is the famous Poke Doll
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
"The restless soul\ncalmed down and\ndeparted!"))
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
end
|
||||
ow:afterBattle(result)
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
game.stack:push(battle)
|
||||
end))
|
||||
@@ -303,6 +317,33 @@ M.GAME_CORNER = {
|
||||
done()
|
||||
end))
|
||||
end,
|
||||
-- the grunt guarding the poster (GameCornerRocketText /
|
||||
-- GameCornerRocketBattleScript / GameCornerRocketExitScript): after
|
||||
-- losing he warns the BOSS and leaves the floor for good, freeing
|
||||
-- the tile in front of the hideout switch
|
||||
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
|
||||
if ow:trainerDefeated(npc) then
|
||||
done()
|
||||
return
|
||||
end
|
||||
ow:engageTrainer(npc, function()
|
||||
if not ow:trainerDefeated(npc) then
|
||||
done()
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save,
|
||||
overworld = ow },
|
||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||
done()
|
||||
end))
|
||||
end)
|
||||
end,
|
||||
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
@@ -349,24 +390,42 @@ 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
|
||||
|
||||
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
|
||||
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)
|
||||
@@ -411,18 +470,56 @@ M.GAME_CORNER_PRIZE_ROOM = {
|
||||
-- and the player steps off the dock, the ship sets sail.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- the ship's hull/deck blocks (block cols 5-8, rows 1-2) and the water
|
||||
-- that replaces them once she sails (the surrounding blocks of each row)
|
||||
local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 5, by = 1, water = 1 }, { bx = 6, by = 1, water = 1 },
|
||||
{ bx = 7, by = 1, water = 1 }, { bx = 8, by = 1, water = 1 },
|
||||
{ bx = 5, by = 2, water = 13 }, { bx = 6, by = 2, water = 13 },
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_SS_ANNE_LEFT then
|
||||
local f = game.save.flags
|
||||
if f.EVENT_SS_ANNE_LEFT then
|
||||
-- the ship is long gone: erase her right away, and anyone who
|
||||
-- still lands here is sent back out past the guard
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
ow.map:setBlock(b.bx, b.by, b.water)
|
||||
end
|
||||
ow.map.renderer:rebuild()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||
or "The ship set sail.", function()
|
||||
ow:startWarpTo("VERMILION_CITY", 19, 30, "up")
|
||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||
end))
|
||||
elseif game.save.flags.EVENT_GOT_HM01 then
|
||||
game.save.flags.EVENT_SS_ANNE_LEFT = true
|
||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||
-- the horn blows, smoke puffs drift off the funnel, the ship is
|
||||
-- erased to open water, and the player is walked off the dock
|
||||
-- into the city past the guard (VermilionCity's
|
||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||
f.EVENT_SS_ANNE_LEFT = true
|
||||
require("src.core.Music").stop()
|
||||
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
|
||||
local function puff(n, cx)
|
||||
if n <= 0 then return end
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { "wait", 100 }
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 30 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -457,6 +457,25 @@ M.ROUTE_24 = {
|
||||
battleOrDone()
|
||||
end,
|
||||
},
|
||||
-- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the
|
||||
-- player stands on (10,15) in front of the recruiter while
|
||||
-- EVENT_GOT_NUGGET is unset (dbmapcoord 10,15), independent of facing
|
||||
-- or input -- he has no trainer header, so sight never engages him.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_GOT_NUGGET then return false end
|
||||
if not (x == 10 and y == 15) then return false end
|
||||
if ow.runner:isRunning() or ow.engaging then return false end
|
||||
local recruiter
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "ROUTE24_COOLTRAINER_M1" then
|
||||
recruiter = npc
|
||||
break
|
||||
end
|
||||
end
|
||||
if not recruiter then return false end
|
||||
ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -633,4 +652,96 @@ M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
|
||||
-- rooms this one gates its ENTRANCE, not its exit: the .blk ships with
|
||||
-- the arena doorway CLOSED (blocks $72/$73 at block (2,6)/(3,6), cells
|
||||
-- (4-7,12-13)), and LanceShowOrHideEntranceBlocks OPENS it ($31/$32)
|
||||
-- on every map load while EVENT_LANCES_ROOM_LOCK_DOOR is unset.
|
||||
-- Without this script the doorway never opened, so the whole arena --
|
||||
-- Lance AND both CHAMPIONS_ROOM warps at (5,0)/(6,0) -- was sealed off
|
||||
-- from the entrance hall and the league dead-ended here ("goto (6,11)
|
||||
-- unreachable on LANCES_ROOM").
|
||||
--
|
||||
-- LancesRoomDefaultScript's coordinate triggers, all inert once
|
||||
-- EVENT_BEAT_LANCE is set:
|
||||
-- (5,1)/(6,2) beside Lance -> his battle starts (a coordinate
|
||||
-- trigger, not a talk; victories.lua OPP_LANCE#1 sets
|
||||
-- EVENT_BEAT_LANCE on the win, so a loss re-arms)
|
||||
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
|
||||
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
|
||||
-- the door behind the player with SFX_GO_INSIDE
|
||||
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
|
||||
-- (up 12, left 12, down 7, left 6) landing on (6,11).
|
||||
-- It marches straight across the room's water decor:
|
||||
-- pokered's CollisionCheckOnLand skips collision
|
||||
-- entirely while simulated joypad states run, and our
|
||||
-- scriptMove is collision-free the same way.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function lanceEntranceBlocks(game, ow)
|
||||
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
|
||||
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
|
||||
ow:replaceBlock(3, 6, locked and 0x73 or 0x32)
|
||||
end
|
||||
|
||||
local function lanceLockDoor(game, ow)
|
||||
if game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR then return end
|
||||
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
|
||||
require("src.core.Sound").play(game.data, "Go_Inside")
|
||||
lanceEntranceBlocks(game, ow)
|
||||
end
|
||||
|
||||
local function lanceWalkIn(game, ow)
|
||||
ow:scriptMove(ow.player, "up", 12, function()
|
||||
ow:scriptMove(ow.player, "left", 12, function()
|
||||
ow:scriptMove(ow.player, "down", 7, function()
|
||||
ow:scriptMove(ow.player, "left", 6, function()
|
||||
-- the walk lands on (6,11); vanilla's per-frame coord poll
|
||||
-- then locks the door at once. scriptMove landings do not
|
||||
-- fire onStep, so lock here.
|
||||
lanceLockDoor(game, ow)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
M.LANCES_ROOM = {
|
||||
onEnter = function(game, ow)
|
||||
lanceEntranceBlocks(game, ow)
|
||||
-- the warp arrival lands ON the staircase trigger, and onStep only
|
||||
-- fires for completed steps -- start the walk-in here, the same
|
||||
-- way Lorelei's auto walk-in runs from its onEnter
|
||||
if not game.save.flags.EVENT_BEAT_LANCE
|
||||
and ow.player.cellX == 24 and ow.player.cellY == 16 then
|
||||
lanceWalkIn(game, ow)
|
||||
end
|
||||
end,
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_LANCE then return false end
|
||||
if (x == 5 and y == 1) or (x == 6 and y == 2) then
|
||||
local lance
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
|
||||
end
|
||||
if not lance or ow:trainerDefeated(lance) then return false end
|
||||
lance:facePlayer(ow.player)
|
||||
ow:engageTrainer(lance, function() end)
|
||||
return true
|
||||
end
|
||||
if (x == 5 or x == 6) and y == 11 then
|
||||
lanceLockDoor(game, ow)
|
||||
return false
|
||||
end
|
||||
if x == 24 and y == 16 then
|
||||
lanceWalkIn(game, ow)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
|
||||
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
|
||||
|
||||
return M
|
||||
|
||||
@@ -259,21 +259,39 @@ M.CINNABAR_ISLAND = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- Pewter's youngster stops you leaving east before Brock is beaten
|
||||
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the
|
||||
-- original escorts you to the gym, we walk you back a step)
|
||||
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
||||
-- escorts you to the gym (scripts/PewterCity.asm
|
||||
-- PewterCityCheckPlayerLeavingEastScript /
|
||||
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
|
||||
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
|
||||
-- west along the road to the front of the PEWTER_GYM door at (16,17).
|
||||
M.PEWTER_CITY = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
||||
return false
|
||||
end
|
||||
local t = text(game)
|
||||
-- walk up onto the road row (y=17), west to one tile east of the gym
|
||||
-- door, then drop below the door and turn to face it
|
||||
local steps = {}
|
||||
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
|
||||
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
|
||||
steps[#steps + 1] = "down"
|
||||
steps[#steps + 1] = "left"
|
||||
local function walk(i)
|
||||
if not steps[i] then
|
||||
ow.player.facing = "up"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
|
||||
end
|
||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
||||
or "Hey! You're a\ntrainer, right?", function()
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", function()
|
||||
ow:scriptMove(ow.player, "left", 1)
|
||||
walk(1)
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
@@ -360,7 +378,7 @@ local rocketRows = {
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", 16 }, -- 8
|
||||
{ "jump_if_false", 18 }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
@@ -368,6 +386,15 @@ local rocketRows = {
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
|
||||
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
|
||||
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
|
||||
-- disappears. GUARD2 stands on the only walkable neighbour of the
|
||||
-- trashed house's south door, which is one of the two ways through the
|
||||
-- fence dividing Cerulean, so this swap is what reconnects the city.
|
||||
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
|
||||
-- both are idempotent, so whichever the player reaches first opens it.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
@@ -444,10 +471,22 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
return function(game, ow, x, y)
|
||||
if game.save.inventory.BICYCLE then return false end
|
||||
if not inCoords(coords, x, y) then return false end
|
||||
-- walk the player up to the tile beside the counter, no further:
|
||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||
-- (Route16Gate1FDefaultScript's wCoordIndex-1). Forcing a fixed one
|
||||
-- tile up from the counter-adjacent row shoved the player onto the
|
||||
-- impassable desk and boxed them in.
|
||||
local closestY = coords[1][2]
|
||||
for _, c in ipairs(coords) do
|
||||
if c[2] < closestY then closestY = c[2] end
|
||||
end
|
||||
local dist = y - closestY
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||
ow:scriptMove(ow.player, "up", 1)
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
|
||||
@@ -5,24 +5,46 @@
|
||||
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
|
||||
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
|
||||
-- The TM each gym leader hands out afterwards is also ported.
|
||||
--
|
||||
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
||||
-- sets to retire unfought non-leader trainers (PewterGym.asm
|
||||
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
|
||||
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
|
||||
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
for i = first, last do
|
||||
t[#t + 1] = prefix .. i
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
return {
|
||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||
item = "TM_BIDE" },
|
||||
item = "TM_BIDE",
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM" },
|
||||
item = "TM_BUBBLEBEAM",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||
item = "TM_THUNDERBOLT" },
|
||||
item = "TM_THUNDERBOLT",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
|
||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||
item = "TM_MEGA_DRAIN" },
|
||||
item = "TM_MEGA_DRAIN",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
|
||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||
item = "TM_TOXIC" },
|
||||
item = "TM_TOXIC",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
|
||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||
item = "TM_PSYWAVE" },
|
||||
item = "TM_PSYWAVE",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
|
||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||
item = "TM_FIRE_BLAST" },
|
||||
item = "TM_FIRE_BLAST",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
|
||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||
item = "TM_FISSURE" },
|
||||
item = "TM_FISSURE",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
|
||||
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
@@ -31,7 +53,8 @@ return {
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
||||
-- only him (data/maps/objects/FightingDojo.asm).
|
||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" },
|
||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
|
||||
|
||||
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
||||
-- the flags make the Hall of Fame checkable)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,58 +1,49 @@
|
||||
# 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`:
|
||||
## Developer console
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "example_mod",
|
||||
"name": "Example Mod",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"priority": 0,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": []
|
||||
}
|
||||
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
|
||||
```
|
||||
|
||||
`main.lua`:
|
||||
While developer mode is active:
|
||||
|
||||
```lua
|
||||
return function(mod)
|
||||
mod.log:info("hello from a native mod")
|
||||
- `` ` `` (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.
|
||||
|
||||
mod.content.pokemon:override("PIKACHU", {
|
||||
name = "PIKACHU",
|
||||
types = { "ELECTRIC" },
|
||||
base_stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 },
|
||||
})
|
||||
The console understands these verbs (anything else is evaluated as Lua):
|
||||
|
||||
mod.events:on("battle.start", function(context)
|
||||
context.mod_message = "A native mod changed this battle."
|
||||
end)
|
||||
end
|
||||
```
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
@@ -42,8 +42,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 +70,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
|
||||
|
||||
@@ -132,6 +146,10 @@ 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`
|
||||
- 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`).
|
||||
@@ -13,7 +13,32 @@ 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"))
|
||||
|
||||
-- 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,6 +49,10 @@ local function bootGame()
|
||||
local fn = assert(loadfile(driverPath))()
|
||||
driverCo = coroutine.create(fn)
|
||||
end
|
||||
-- After the two above are known: a scripted run drives the multiplier
|
||||
-- from love.update's loop, so the in-engine one must stay at 1 or the
|
||||
-- two would compound (10x10 = 100 steps per observation).
|
||||
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
|
||||
end
|
||||
|
||||
function love.load(args)
|
||||
@@ -31,8 +60,12 @@ function love.load(args)
|
||||
for i, a in ipairs(args or {}) do
|
||||
if a == "--editor" then
|
||||
editorMode = true
|
||||
elseif a == "--developer" then
|
||||
_G.POKEPORT_DEV_MODE = true
|
||||
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
||||
savePath = args[i + 1]
|
||||
elseif a == "--speed" and tonumber(args[i + 1]) then
|
||||
speedOverride = tonumber(args[i + 1])
|
||||
end
|
||||
end
|
||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||
@@ -47,43 +80,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 +211,31 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
Game:joystickremoved(joystick)
|
||||
end
|
||||
|
||||
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
|
||||
-- direction's key-up can be delivered to the OS instead of the game while
|
||||
-- unfocused, so reset input on either transition rather than trust it.
|
||||
function love.focus(f)
|
||||
if editorMode then return end
|
||||
if Importer then
|
||||
if Importer.focus then Importer:focus(f) end
|
||||
return
|
||||
end
|
||||
Game:focus(f)
|
||||
end
|
||||
|
||||
-- v is true when the window becomes visible again, false on minimize.
|
||||
function love.visible(v)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
Game:visible(v)
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:mousepressed(x, y, 1) end
|
||||
@@ -191,6 +288,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 +299,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
|
||||
|
||||
@@ -27,17 +27,17 @@ refresh is safe, just rebuild.
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Debug APK (default Android debug keystore)
|
||||
# Build the APK
|
||||
scripts/build_android.sh
|
||||
|
||||
# Release APK (signing is manual, see below)
|
||||
scripts/build_android.sh --release
|
||||
# Build the APK, setting app.version_name/app.version_code to match a release
|
||||
scripts/build_android.sh --version 0.2.5
|
||||
|
||||
# Zip game.love + branding only (no Android SDK required)
|
||||
scripts/build_android.sh --package-only
|
||||
```
|
||||
|
||||
Or via `scripts/build.sh android`.
|
||||
Or via `scripts/build.sh android [--version X.Y.Z]`.
|
||||
|
||||
The embedded `game.love` deliberately excludes `data/generated/`,
|
||||
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
||||
@@ -59,12 +59,10 @@ Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||
|
||||
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
||||
Build task: `assembleEmbedNoRecordDebug`.
|
||||
|
||||
- Debug task: `assembleEmbedNoRecordDebug`
|
||||
- Release task: `assembleEmbedNoRecordRelease`
|
||||
|
||||
APKs land under `app/build/outputs/apk/embedNoRecord/{debug,release}/`.
|
||||
`scripts/build_android.sh` also copies the built APK(s) to `dist/android/{debug,release}/`.
|
||||
The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||
`scripts/build_android.sh` also copies it to `dist/android/debug/`.
|
||||
|
||||
### Payload path
|
||||
|
||||
@@ -79,17 +77,15 @@ scripts, tests, and mobile build sources are excluded.
|
||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||
| `app.name` | Pokemon Red |
|
||||
| `app.orientation` | `portrait` |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
||||
|
||||
## Releases
|
||||
|
||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||
`PokemonRed-<version>-android.apk`.
|
||||
|
||||
## Signing
|
||||
|
||||
- **Debug**: default Android debug keystore (no setup).
|
||||
- **Release**: out-of-band. Create a keystore yourself and wire it into
|
||||
`app/build.gradle`, **do not commit keystores or passwords**.
|
||||
|
||||
Example (placeholder only):
|
||||
|
||||
```bash
|
||||
keytool -genkey -v -keystore /path/to/pokemonred-release.jks \
|
||||
-alias pokemonred -keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
Signed with the default Android keystore (no setup required).
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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": ">=1.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")
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": ">=1.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."
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": ">=1.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."
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": ">=1.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.
|
||||
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 262 B |
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 326 B |
|
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": ">=1.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."
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 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 = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "total-conversion", "capstone" },
|
||||
differences = {
|
||||
changed = {
|
||||
"field.boot spawns on SABLE_COVE as SABLE, rival CORAL, 1500 money",
|
||||
"constants.dexSize 151 -> 3, dexDigits 3 -> 1, levelCap 100 -> 50",
|
||||
"constants.badges replaced with one TIDEBADGE",
|
||||
"constants.hmMoves replaced with CUT and SURF",
|
||||
"the title screen is SableTitle",
|
||||
},
|
||||
added = {
|
||||
"SABLE_EMBERKIT, SABLE_TIDEPUP and SABLE_MOSSLING with cries and icons",
|
||||
"SABLE_TIDE_BADGE key item",
|
||||
"the SABLE_COVE map and its encounter table",
|
||||
},
|
||||
known = {
|
||||
"no gym, no story and no warps yet: this is the skeleton the TC guide elaborates",
|
||||
"Red's maps and species stay merged and reachable; a real conversion removes them",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "all original sprite art under assets/" },
|
||||
{ who = "pret/pokered", for_ = "the OVERWORLD tileset, font and move table this builds on" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Standalone: luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
|
||||
-- Asserts the conversion owns the boot flow, the dex and the badge list.
|
||||
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_mini_conversion", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
T.eq(run.mod and run.mod.manifest.profile, "total_conversion",
|
||||
"the manifest declares the total_conversion profile")
|
||||
|
||||
-- ------- boot: the new game the conversion starts
|
||||
|
||||
local boot = Data.field.boot
|
||||
T.eq(boot.startMap, "SABLE_COVE", "boot spawns on the conversion's own map")
|
||||
T.eq(boot.playerName, "SABLE", "boot renames the player")
|
||||
T.eq(boot.screens.title, "SableTitle", "the conversion owns the title screen")
|
||||
-- patch is a merge: the keys the conversion did not name keep their values
|
||||
T.eq(boot.startFacing, "down", "an unnamed boot key survives the patch")
|
||||
|
||||
-- ------- the dex
|
||||
|
||||
T.eq(Data.constants.dexSize, 3, "the dex shrinks to three species")
|
||||
T.eq(#Data.constants.badges, 1, "one badge replaces the eight")
|
||||
T.eq(Data.constants.badges[1].id, "SABLE_TIDE_BADGE", "the badge is the mod's item")
|
||||
T.check(Data.items.SABLE_TIDE_BADGE ~= nil, "the badge item merged")
|
||||
-- constants is a deep registry, so untouched keys keep their imported value
|
||||
T.eq(Data.constants.partyMax, 6, "an unpatched constant is unchanged")
|
||||
-- and a deep list REPLACES only because the mod said override
|
||||
T.eq(#Data.constants.hmMoves, 2, "override drops a deep list instead of appending")
|
||||
|
||||
for _, id in ipairs({ "SABLE_EMBERKIT", "SABLE_TIDEPUP", "SABLE_MOSSLING" }) do
|
||||
local mon = Data.pokemon[id]
|
||||
T.check(mon ~= nil, id .. " merged into the species table")
|
||||
T.check(Data.audio.cries[id] ~= nil, id .. " has a cry")
|
||||
T.check(Data.icons.bySpecies[id] ~= nil, id .. " has an icon")
|
||||
-- the art it points at really exists, not a path into the void. Read it
|
||||
-- back through the loader's own filesystem: mod.path is whatever the
|
||||
-- loader mounted the mod at, which is not the repo-relative directory
|
||||
for _, path in ipairs({ mon.spriteFront, mon.spriteBack }) do
|
||||
T.check(run.loader.fs.getInfo(path) ~= nil,
|
||||
"sprite exists: " .. tostring(path))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the map and its encounters
|
||||
|
||||
local map = Data.maps.SABLE_COVE
|
||||
T.check(map ~= nil, "the conversion's map merged")
|
||||
T.eq(#map.blocks, map.width * map.height, "the block array matches the map size")
|
||||
T.eq(#Data.encounters.SABLE_COVE.grass.slots, 10, "the map has a full slot table")
|
||||
|
||||
-- ------- the title screen resolves through the registry
|
||||
|
||||
local Screens = require("src.ui.Screens")
|
||||
Screens.invalidate()
|
||||
local factory = Screens.get({ data = Data }, "SableTitle")
|
||||
T.check(factory and factory.new, "SableTitle resolves through the screens registry")
|
||||
local reached = false
|
||||
local state = factory.new({ data = Data, input = {
|
||||
wasPressed = function(_, key) return key == "a" end } },
|
||||
{ onNewGame = function() reached = true end })
|
||||
state:update()
|
||||
T.check(reached, "pressing A on the title starts a new game")
|
||||
|
||||
run.release()
|
||||
Screens.invalidate()
|
||||
T.finish("example_mini_conversion")
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate this mod's original sprite art.
|
||||
|
||||
python3 mods/examples/example_mini_conversion/tools/make_assets.py
|
||||
|
||||
Every pixel below is plotted from the shape tables in this file, so the
|
||||
output is original work and nothing is read from the player's imported
|
||||
cache. The four colors are the Game Boy shade ramp; the renderer
|
||||
re-shades them into the active palette, so no trueColor opt-out is needed.
|
||||
|
||||
Front sheets are 40x40 (frontSize 5), backs 32x32 and icons 16x32
|
||||
(two 16x16 frames), matching what the battle and party screens expect.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")
|
||||
|
||||
# lightest first; index 0 is the transparent-white background
|
||||
SHADES = [(248, 248, 248), (168, 168, 168), (88, 88, 88), (8, 8, 8)]
|
||||
|
||||
# Each species is a coarse silhouette painted from primitives: the point is
|
||||
# that these are geometric marks, not creature art traced from anything.
|
||||
SPECIES = {
|
||||
"emberkit": {"body": "triangle", "accent": 1},
|
||||
"tidepup": {"body": "diamond", "accent": 1},
|
||||
"mossling": {"body": "hex", "accent": 2},
|
||||
}
|
||||
|
||||
|
||||
def blank(w, h):
|
||||
return [[0] * w for _ in range(h)]
|
||||
|
||||
|
||||
def stroke(grid, x, y, shade):
|
||||
if 0 <= y < len(grid) and 0 <= x < len(grid[0]):
|
||||
grid[y][x] = shade
|
||||
|
||||
|
||||
def triangle(grid, cx, cy, r, shade):
|
||||
for row in range(r * 2):
|
||||
half = row // 2
|
||||
for x in range(cx - half, cx + half + 1):
|
||||
stroke(grid, x, cy - r + row, shade)
|
||||
|
||||
|
||||
def diamond(grid, cx, cy, r, shade):
|
||||
for dy in range(-r, r + 1):
|
||||
span = r - abs(dy)
|
||||
for dx in range(-span, span + 1):
|
||||
stroke(grid, cx + dx, cy + dy, shade)
|
||||
|
||||
|
||||
def hexagon(grid, cx, cy, r, shade):
|
||||
for dy in range(-r, r + 1):
|
||||
span = r if abs(dy) <= r // 2 else r - (abs(dy) - r // 2)
|
||||
for dx in range(-span, span + 1):
|
||||
stroke(grid, cx + dx, cy + dy, shade)
|
||||
|
||||
|
||||
SHAPES = {"triangle": triangle, "diamond": diamond, "hex": hexagon}
|
||||
|
||||
|
||||
def outline(grid, shade):
|
||||
"""Darken every lit pixel that touches an unlit one."""
|
||||
h, w = len(grid), len(grid[0])
|
||||
edges = []
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if not grid[y][x]:
|
||||
continue
|
||||
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
nx, ny = x + dx, y + dy
|
||||
if not (0 <= nx < w and 0 <= ny < h) or not grid[ny][nx]:
|
||||
edges.append((x, y))
|
||||
break
|
||||
for x, y in edges:
|
||||
grid[y][x] = shade
|
||||
|
||||
|
||||
def save(grid, path):
|
||||
h, w = len(grid), len(grid[0])
|
||||
img = Image.new("RGBA", (w, h))
|
||||
img.putdata([SHADES[grid[y][x]] + (255,)
|
||||
for y in range(h) for x in range(w)])
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path)
|
||||
print("wrote", os.path.relpath(path))
|
||||
|
||||
|
||||
def build(name, spec):
|
||||
shape = SHAPES[spec["body"]]
|
||||
accent = spec["accent"]
|
||||
|
||||
front = blank(40, 40)
|
||||
shape(front, 20, 22, 13, accent)
|
||||
shape(front, 20, 12, 5, 2)
|
||||
for x in (16, 24):
|
||||
stroke(front, x, 11, 3)
|
||||
stroke(front, x, 12, 3)
|
||||
outline(front, 3)
|
||||
save(front, os.path.join(ROOT, name + "_front.png"))
|
||||
|
||||
back = blank(32, 32)
|
||||
shape(back, 16, 20, 11, accent)
|
||||
outline(back, 3)
|
||||
save(back, os.path.join(ROOT, name + "_back.png"))
|
||||
|
||||
# two 16x16 frames stacked: the party-menu bob
|
||||
icon = blank(16, 32)
|
||||
for frame, lift in enumerate((0, 1)):
|
||||
shape(icon, 8, 9 + frame * 16 - lift, 5, accent)
|
||||
outline(icon, 3)
|
||||
save(icon, os.path.join(ROOT, name + "_icon.png"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name, spec in sorted(SPECIES.items()):
|
||||
build(name, spec)
|
||||
@@ -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
|
||||
|
||||
- `transforms.lua` recolor of the player overworld sheets.
|
||||
- `EXAMPLE_SHINY` palette record and a `PALLET` town palette override.
|
||||
- `trueColor` opt-out patches for `SPRITE_RED` and `SPRITE_RED_BIKE`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Shiny Palette Example
|
||||
|
||||
Recolors the player's overworld sheets to teal and repaints Pallet Town —
|
||||
and ships no ROM-derived pixels to do it.
|
||||
|
||||
**Persona: the Artist.** This is the canonical answer to "how do I ship a
|
||||
recolor legally": you ship the *transform*, not the image.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_shiny_palette --base imported
|
||||
python3 tools/modkit.py lint mods/examples/example_shiny_palette
|
||||
luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
|
||||
```
|
||||
|
||||
Enable it (`example_shiny_palette = true` under `mods` in `options.lua`, or
|
||||
the F10 manager) and start the game. On first load the transform runs once
|
||||
and writes `save/mod-derived/example_shiny_palette/sprites/*.png`. Delete
|
||||
that directory to force a re-run.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `assets_transforms` | `manifest.json` + `transforms.lua` — the recipe that derives art |
|
||||
| `content.palettes:register` | `main.lua` — the v2 named-record palette shape |
|
||||
| `content.palettes:override` | `main.lua` — the vanilla four-triple shape |
|
||||
| `content.sprites:patch` | `main.lua` — `trueColor` opt-out, nothing else touched |
|
||||
| `events:on("assets.transformed")` | `main.lua` — the empty-state warning |
|
||||
|
||||
## The legal pattern
|
||||
|
||||
`transforms.lua` runs inside a restricted context with exactly two
|
||||
filesystem roots: read `assets/generated/**` (the player's own imported
|
||||
cache) and write `save/mod-derived/example_shiny_palette/**`. There is no
|
||||
`require`, no `love`, no `io`, no `os`. The only way data leaves the
|
||||
sandbox is the `ctx` table.
|
||||
|
||||
Because the derived file keeps the *same relative name* as the cache file
|
||||
it came from, the asset resolver finds it automatically:
|
||||
|
||||
```
|
||||
assets/generated/sprites/red.png <- the player's import
|
||||
save/mod-derived/example_shiny_palette/sprites/red.png <- this mod's recolor
|
||||
```
|
||||
|
||||
Every consumer of the first path transparently gets the second. No
|
||||
`sprites:override`, no path string in `main.lua` — and `modkit lint` can
|
||||
prove the repo carries no cache-derived bytes, because it carries no bytes
|
||||
at all.
|
||||
|
||||
The one thing that *does* need a registry entry is the 4-shade contract.
|
||||
The renderer normally re-shades an overworld sheet into the current
|
||||
palette's four grays, which would throw the teal away. `trueColor = true`
|
||||
opts out:
|
||||
|
||||
```lua
|
||||
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
|
||||
```
|
||||
|
||||
`patch`, not `override`: `image`, `frames` and `walker` stay whatever the
|
||||
merged view already holds, so the derived sheet keeps supplying the pixels.
|
||||
|
||||
## Empty state
|
||||
|
||||
No ROM imported yet? `ctx.exists(rel)` is false, the transform writes
|
||||
nothing, the mod still loads, and `main.lua` logs a remediation line naming
|
||||
the directory to delete once you have imported. It never errors.
|
||||
|
||||
## Original assets
|
||||
|
||||
`assets/accent_sparkle.png` is a 16x16 four-shade sparkle drawn for this
|
||||
example. It is the only image in the directory and it is original work.
|
||||
|
||||
## Credits
|
||||
|
||||
- pret/pokered — the overworld sheet layout the transform recolors.
|
||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1,37 @@
|
||||
-- Gallery #2 (Artist): a recolor that ships no pixels. transforms.lua
|
||||
-- derives the sheets from the player's own cache; this file only declares
|
||||
-- the palette records and the one flag the recolor needs.
|
||||
return function(mod)
|
||||
-- v2 record shape: a named table of four colors, lightest first
|
||||
mod.content.palettes:register("EXAMPLE_SHINY", {
|
||||
colors = {
|
||||
{ r = 248, g = 248, b = 248 },
|
||||
{ r = 120, g = 224, b = 216 },
|
||||
{ r = 32, g = 128, b = 152 },
|
||||
{ r = 8, g = 32, b = 64 },
|
||||
},
|
||||
})
|
||||
|
||||
-- vanilla raw shape: four {r,g,b} triples. Overriding a town palette is
|
||||
-- the smallest visible artist change there is -- no assets involved.
|
||||
mod.content.palettes:override("PALLET", {
|
||||
{ 248, 248, 248 }, { 152, 232, 224 }, { 64, 152, 168 }, { 8, 32, 64 },
|
||||
})
|
||||
|
||||
-- trueColor opts SPRITE_RED out of the 4-shade re-shade so the teal the
|
||||
-- transform baked in survives to the screen. patch, not override: the
|
||||
-- image path and frame count stay whatever the merged view already has,
|
||||
-- which is how the derived sheet keeps supplying the pixels.
|
||||
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
|
||||
mod.content.sprites:patch("SPRITE_RED_BIKE", { trueColor = true })
|
||||
|
||||
mod.events:on("assets.transformed", function(ev)
|
||||
if ev.modId ~= mod.id then return end
|
||||
if ev.count == 0 then
|
||||
mod.log:warn("no sheets derived -- import your ROM first, then "
|
||||
.. "delete save/mod-derived/%s to re-run the transform", mod.id)
|
||||
else
|
||||
mod.log:info("derived %d recolored sheets", ev.count)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "example_shiny_palette",
|
||||
"name": "Shiny Palette Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GRAPHICS",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"assets_transforms": "transforms.lua",
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Artist gallery entry: a recolored player sheet derived from the player's own cache, plus two palette records."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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 teal player recolor derived from your own cache, plus two palette records.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "cosmetic", "graphics", "beginner" },
|
||||
screenshots = {
|
||||
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
||||
},
|
||||
differences = {
|
||||
changed = {
|
||||
"SPRITE_RED and SPRITE_RED_BIKE opt into trueColor",
|
||||
"the PALLET town palette is recolored",
|
||||
},
|
||||
added = { "EXAMPLE_SHINY palette record" },
|
||||
known = {
|
||||
"the derived sheets only appear after a ROM import; without a cache "
|
||||
.. "the transform writes nothing and the vanilla sheets keep rendering",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the overworld sheet layout the transform recolors" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
-- Standalone: luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
|
||||
-- Asserts the palette records merge and the transform degrades cleanly
|
||||
-- when there is no imported cache to read.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
-- The transform reads assets/generated/** and writes save/mod-derived/**.
|
||||
-- Hiding the cache is what puts this run on the no-cache path, which is
|
||||
-- the branch a mod owes the player: write nothing, load anyway. The pixel
|
||||
-- path needs a real LOVE run (love.image is not in the headless stub).
|
||||
local MOD = "mods/examples/example_shiny_palette"
|
||||
|
||||
local function noCacheFs()
|
||||
local inner = T.fs.new(".")
|
||||
local overlay = {}
|
||||
local hidden = "assets/generated/"
|
||||
local mount = "mods/example_shiny_palette"
|
||||
|
||||
local function map(path)
|
||||
if path == mount then return MOD end
|
||||
if path and path:sub(1, #mount + 1) == mount .. "/" then
|
||||
return MOD .. path:sub(#mount + 1)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
local fs = { root = inner.root }
|
||||
function fs.read(path)
|
||||
if path:sub(1, #hidden) == hidden then return nil end
|
||||
return overlay[path] or inner.read(map(path))
|
||||
end
|
||||
function fs.write(path, body) overlay[path] = body return true end
|
||||
function fs.createDirectory() return true end
|
||||
function fs.load(path) return inner.load(map(path)) end
|
||||
function fs.getInfo(path)
|
||||
if path == "mods" then return { type = "directory" } end
|
||||
if path:sub(1, #hidden) == hidden then return nil end
|
||||
if overlay[path] then return { type = "file" } end
|
||||
return inner.getInfo(map(path))
|
||||
end
|
||||
function fs.getDirectoryItems(path)
|
||||
if path == "mods" then return { "example_shiny_palette" } end
|
||||
return inner.getDirectoryItems(map(path))
|
||||
end
|
||||
return fs
|
||||
end
|
||||
|
||||
local run = T.sdk.loadMod(MOD, { data = Data, fs = noCacheFs() })
|
||||
T.eq(#run.errors, 0,
|
||||
"loads clean with no cache to transform (" .. tostring(run.errors[1]) .. ")")
|
||||
T.eq(run.mod and run.mod.manifest.assets_transforms, "transforms.lua",
|
||||
"the manifest declares its transform")
|
||||
|
||||
-- ------- palettes
|
||||
|
||||
local shiny = Data.palettes.palettes.EXAMPLE_SHINY
|
||||
T.check(shiny ~= nil, "the v2 named palette record merged")
|
||||
T.eq(#shiny.colors, 4, "it carries exactly four colors")
|
||||
T.eq(shiny.colors[1].r, 248, "the lightest shade is first")
|
||||
|
||||
local pallet = Data.palettes.palettes.PALLET
|
||||
T.eq(#pallet, 4, "the town palette override kept the raw four-triple shape")
|
||||
T.eq(pallet[2][2], 232, "the override took")
|
||||
|
||||
-- ------- the trueColor opt-out, applied by patch
|
||||
|
||||
for _, id in ipairs({ "SPRITE_RED", "SPRITE_RED_BIKE" }) do
|
||||
local sprite = Data.sprites[id]
|
||||
T.eq(sprite.trueColor, true, id .. " opted into trueColor")
|
||||
T.check(sprite.image ~= nil and sprite.image ~= "",
|
||||
id .. " kept its sheet path (patch named only the flag)")
|
||||
T.check(sprite.frames ~= nil, id .. " kept its frame count")
|
||||
end
|
||||
|
||||
-- ------- the recipe itself compiles and is a function(ctx)
|
||||
|
||||
local chunk = assert(loadfile(MOD .. "/transforms.lua"))
|
||||
local transform = chunk()
|
||||
T.check(type(transform) == "function", "transforms.lua returns a function(ctx)")
|
||||
|
||||
-- driven with an empty cache it must write nothing and not raise
|
||||
local wrote = 0
|
||||
local ok, err = pcall(transform, {
|
||||
exists = function() return false end,
|
||||
readImage = function() error("must not read without exists()", 0) end,
|
||||
writeImage = function() wrote = wrote + 1 end,
|
||||
recolor = function(img) return img end,
|
||||
})
|
||||
T.check(ok, "the recipe survives an empty cache (" .. tostring(err) .. ")")
|
||||
T.eq(wrote, 0, "and writes nothing rather than failing the mod")
|
||||
|
||||
-- with a cache present it derives one file per declared sheet
|
||||
wrote = 0
|
||||
local read = {}
|
||||
T.check(pcall(transform, {
|
||||
exists = function() return true end,
|
||||
readImage = function(rel) read[#read + 1] = rel return { rel } end,
|
||||
writeImage = function() wrote = wrote + 1 end,
|
||||
recolor = function(img) return img end,
|
||||
}), "the recipe runs over a populated cache")
|
||||
T.eq(wrote, #read, "every sheet it read, it wrote back")
|
||||
T.check(wrote >= 1, "at least one sheet is derived")
|
||||
|
||||
run.release()
|
||||
T.finish("example_shiny_palette")
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Asset transform: the whole point of this example. It runs once at
|
||||
-- install inside the restricted context -- read the player's own imported
|
||||
-- cache, write under save/mod-derived/<id>/ -- so the repo ships the
|
||||
-- recipe and never a ROM-derived pixel.
|
||||
--
|
||||
-- The derived path mirrors the cache path, so Assets.resolve picks it up
|
||||
-- for every consumer of assets/generated/sprites/red.png with no registry
|
||||
-- entry at all. A recolor is exactly this: read, recolor, write back
|
||||
-- under the same relative name.
|
||||
local SHEETS = {
|
||||
"sprites/red.png",
|
||||
"sprites/red_bike.png",
|
||||
}
|
||||
|
||||
-- lightest shade first; the recolor buckets every ink pixel into one of
|
||||
-- these four by luminance, matching the importer's own 4-gray split
|
||||
local TEAL = {
|
||||
{ 248, 248, 248 },
|
||||
{ 120, 224, 216 },
|
||||
{ 32, 128, 152 },
|
||||
{ 8, 32, 64 },
|
||||
}
|
||||
|
||||
return function(ctx)
|
||||
for _, rel in ipairs(SHEETS) do
|
||||
-- a player who has not imported yet simply gets no derived art; the
|
||||
-- vanilla sheet keeps rendering and the mod stays loaded
|
||||
if ctx.exists(rel) then
|
||||
ctx.writeImage(ctx.recolor(ctx.readImage(rel), TEAL), rel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
- `EXAMPLE_RAIN` status record and the `example_weather_battles` ruleset.
|
||||
- A `battle.damage` wrap that scales WATER and FIRE while it rains.
|
||||
- Rain lifecycle driven by `battle.started` / `battle.turn_started` / `battle.ended`.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Weather Battles Example
|
||||
|
||||
Adds rain: for the first five turns of every battle, WATER moves deal 1.5x
|
||||
damage and FIRE moves deal 0.5x. Only under the `WEATHER` ruleset — pick
|
||||
`gen1_faithful` and the mod is installed and inert.
|
||||
|
||||
**Persona: the Mechanic Designer.** A new battle mechanic, no engine fork.
|
||||
The status and ruleset registries plus one hook carry the whole thing.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_weather --base imported
|
||||
luajit mods/examples/example_weather/tests/example_weather_test.lua
|
||||
```
|
||||
|
||||
Enable it (`example_weather = true` under `mods` in `options.lua`, or the
|
||||
F10 manager), then **OPTIONS → RULESET → WEATHER**.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `content.statuses:register` | `main.lua` — declaring a field effect |
|
||||
| `content.rulesets:register` | `main.lua` — a ruleset the OPTIONS menu lists automatically |
|
||||
| `content.rulesets:get` | `main.lua` — deriving from vanilla without requiring a private module |
|
||||
| `hooks:wrap("battle.damage")` | `main.lua` — the one behavior change |
|
||||
| `events:on("battle.started" / "battle.turn_started" / "battle.ended")` | `main.lua` — the rain counter |
|
||||
| `mod.save:get/set` | `main.lua` — per-mod state, not a global |
|
||||
|
||||
## Parity, at the mod level
|
||||
|
||||
The engine's promise is that a mod-free game is unchanged. This example
|
||||
makes the same promise one level up: a *player* who installs it but does
|
||||
not select the ruleset gets vanilla battles.
|
||||
|
||||
```lua
|
||||
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
|
||||
return next(ctx)
|
||||
end
|
||||
```
|
||||
|
||||
`next(ctx)` with the arguments it was handed *is* the vanilla call. No
|
||||
allocation, no rounding, no reordering — the same number the engine would
|
||||
have produced. Everything above that line is a gate, and every gate that
|
||||
fails defers.
|
||||
|
||||
## Preserving multiple returns
|
||||
|
||||
`Damage.compute` returns two values: the damage number and an info table
|
||||
carrying the crit flag and the type multiplier. A wrapper that returns only
|
||||
the first silently throws the second away, and the battle log stops saying
|
||||
"A critical hit!".
|
||||
|
||||
```lua
|
||||
local damage, info = next(ctx)
|
||||
if type(damage) ~= "number" then return damage, info end
|
||||
return math.max(1, math.floor(damage * scale)), info
|
||||
```
|
||||
|
||||
Hook chains preserve every return value, so passing `info` back through is
|
||||
all it takes.
|
||||
|
||||
## Deriving a ruleset from vanilla
|
||||
|
||||
A ruleset record is the *whole* rule table — `oneIn256Miss`,
|
||||
`critUsesBaseSpeed`, `randMin`, `randMax` and the rest. Registering one
|
||||
that only sets `name` would silently drop every Gen 1 quirk. So this mod
|
||||
reads the vanilla record out of the merged registry and copies it:
|
||||
|
||||
```lua
|
||||
local base = mod.content.rulesets:get("gen1_faithful")
|
||||
local weather = {}
|
||||
for key, value in pairs(base) do weather[key] = value end
|
||||
weather.name = "WEATHER"
|
||||
weather.exampleWeather = true
|
||||
```
|
||||
|
||||
`:get` on a registry is the public path to engine content. It needs no
|
||||
permission, and it composes: if another mod patched `gen1_faithful` first,
|
||||
this ruleset inherits that patch too.
|
||||
|
||||
`exampleWeather` is not in the ruleset schema. Unknown fields on a record
|
||||
registry are preserved rather than rejected — that is what makes rulesets
|
||||
extensible, and it is how the damage hook recognizes its own ruleset
|
||||
without a second lookup.
|
||||
|
||||
## Missing dependency, handled
|
||||
|
||||
If another mod removed `gen1_faithful`, `:get` returns nil. This example
|
||||
logs a remediation line and returns — the rest of the game keeps working
|
||||
and the manager shows one attributed message. No `assert`, no crash.
|
||||
|
||||
## Credits
|
||||
|
||||
- pret/pokered — the damage formula the hook scales.
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Gallery #5 (Mechanic designer): a new battle mechanic with no engine
|
||||
-- fork. A rain field effect scales WATER and FIRE damage, lives behind an
|
||||
-- opt-in ruleset, and keeps its own counter in mod.save.
|
||||
--
|
||||
-- The parity lesson is the ruleset gate: install this mod, leave the
|
||||
-- ruleset on gen1_faithful, and every battle is byte-for-byte vanilla
|
||||
-- because the hook returns next(...) untouched.
|
||||
local RULESET = "example_weather_battles"
|
||||
local RAIN_TURNS = 5
|
||||
|
||||
local BOOST = { WATER = 1.5 }
|
||||
local DAMPEN = { FIRE = 0.5 }
|
||||
|
||||
return function(mod)
|
||||
-- ------- the field effect, declared as a status record
|
||||
|
||||
mod.content.statuses:register("EXAMPLE_RAIN", {
|
||||
id = "EXAMPLE_RAIN",
|
||||
label = "RAIN",
|
||||
hudLabel = "RAIN",
|
||||
-- a field effect is never inflicted on a battler; the record is the
|
||||
-- declaration the HUD and other mods read, the hook is the behavior
|
||||
canInflict = function() return false end,
|
||||
})
|
||||
|
||||
-- ------- the ruleset that turns it on
|
||||
-- Read the vanilla record out of the merged registry rather than
|
||||
-- requiring the module: same table, no engine_internals permission.
|
||||
|
||||
local base = mod.content.rulesets:get("gen1_faithful")
|
||||
if not base then
|
||||
mod.log:error("gen1_faithful missing from the rulesets registry; "
|
||||
.. "another mod removed it, so %s cannot be derived", RULESET)
|
||||
return
|
||||
end
|
||||
local weather = {}
|
||||
for key, value in pairs(base) do weather[key] = value end
|
||||
weather.name = "WEATHER"
|
||||
-- the marker the damage hook gates on; unknown fields ride through the
|
||||
-- schema untouched, which is what makes rulesets extensible
|
||||
weather.exampleWeather = true
|
||||
mod.content.rulesets:register(RULESET, weather)
|
||||
|
||||
-- ------- rain lifecycle, in this mod's own save namespace
|
||||
|
||||
local function raining()
|
||||
return (mod.save:get("turnsLeft", 0)) > 0
|
||||
end
|
||||
|
||||
mod.events:on("battle.started", function(ev)
|
||||
local ruleset = ev.battle and ev.battle.ruleset
|
||||
if ruleset and ruleset.exampleWeather then
|
||||
mod.save:set("turnsLeft", RAIN_TURNS)
|
||||
else
|
||||
mod.save:set("turnsLeft", 0)
|
||||
end
|
||||
end)
|
||||
|
||||
mod.events:on("battle.turn_started", function()
|
||||
local left = mod.save:get("turnsLeft", 0)
|
||||
if left > 0 then mod.save:set("turnsLeft", left - 1) end
|
||||
end)
|
||||
|
||||
mod.events:on("battle.ended", function()
|
||||
mod.save:set("turnsLeft", 0)
|
||||
end)
|
||||
|
||||
-- ------- the one behavior change
|
||||
|
||||
mod.hooks:wrap("battle.damage", function(next, ctx)
|
||||
-- the two gates, cheapest first: the player has to have picked the
|
||||
-- ruleset, and it has to still be raining
|
||||
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
|
||||
return next(ctx)
|
||||
end
|
||||
local moveType = ctx.move and ctx.move.type
|
||||
local scale = BOOST[moveType] or DAMPEN[moveType]
|
||||
if not scale then return next(ctx) end
|
||||
|
||||
-- Damage.compute returns (damage, info); pass the second value through
|
||||
-- untouched or the crit and type-effectiveness flags vanish
|
||||
local damage, info = next(ctx)
|
||||
if type(damage) ~= "number" then return damage, info end
|
||||
return math.max(1, math.floor(damage * scale)), info
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "example_weather",
|
||||
"name": "Weather Battles Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "overhaul",
|
||||
"category": "MECHANIC",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Mechanic-designer gallery entry: a rain field effect behind an opt-in ruleset, driven by the battle.damage hook."
|
||||
}
|
||||
@@ -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 = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
||||
differences = {
|
||||
changed = {
|
||||
"under the WEATHER ruleset only: WATER move damage x1.5 and FIRE x0.5 while it rains",
|
||||
},
|
||||
added = {
|
||||
"example_weather_battles ruleset, selectable in OPTIONS",
|
||||
"EXAMPLE_RAIN field-effect status record",
|
||||
},
|
||||
known = {
|
||||
"rain is unconditional at battle start and has no on-screen indicator yet",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the damage formula the hook scales" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Standalone: luajit mods/examples/example_weather/tests/example_weather_test.lua
|
||||
-- Drives the battle.damage hook through the runtime bus, both with the
|
||||
-- ruleset on and with it off.
|
||||
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_weather", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local weather = Data.rulesets.example_weather_battles
|
||||
T.check(weather ~= nil, "the ruleset merged and is selectable")
|
||||
T.eq(weather.name, "WEATHER", "the ruleset carries a display name")
|
||||
T.eq(weather.oneIn256Miss, Data.rulesets.gen1_faithful.oneIn256Miss,
|
||||
"the derived ruleset keeps every gen1_faithful rule")
|
||||
T.check(Data.statuses.EXAMPLE_RAIN ~= nil, "the rain status record merged")
|
||||
|
||||
-- the vanilla stand-in the hook wraps; 100 damage and an info table
|
||||
local function vanilla() return 100, { crit = false, typeMult = 10 } end
|
||||
|
||||
local function hit(ruleset, moveType)
|
||||
return Runtime.call("battle.damage", vanilla,
|
||||
{ ruleset = ruleset, move = { type = moveType } })
|
||||
end
|
||||
|
||||
-- ------- ruleset off: the mod is installed and changes nothing
|
||||
|
||||
Runtime.emit("battle.started", { battle = { ruleset = Data.rulesets.gen1_faithful } })
|
||||
T.eq(hit(Data.rulesets.gen1_faithful, "WATER"), 100,
|
||||
"gen1_faithful is untouched with the mod installed")
|
||||
T.eq(hit(Data.rulesets.gen1_faithful, "FIRE"), 100,
|
||||
"FIRE is untouched under gen1_faithful too")
|
||||
|
||||
-- ------- ruleset on: rain scales WATER up and FIRE down
|
||||
|
||||
Runtime.emit("battle.started", { battle = { ruleset = weather } })
|
||||
T.eq(hit(weather, "WATER"), 150, "rain boosts WATER damage")
|
||||
T.eq(hit(weather, "FIRE"), 50, "rain dampens FIRE damage")
|
||||
T.eq(hit(weather, "NORMAL"), 100, "every other type is untouched")
|
||||
|
||||
local damage, info = hit(weather, "WATER")
|
||||
T.eq(damage, 150, "the scaled damage is the first return")
|
||||
T.check(info ~= nil and info.typeMult == 10,
|
||||
"the info table survives the wrap")
|
||||
|
||||
-- ------- the counter runs out
|
||||
|
||||
for _ = 1, 5 do Runtime.emit("battle.turn_started", {}) end
|
||||
T.eq(hit(weather, "WATER"), 100, "rain stops after its turn count")
|
||||
|
||||
Runtime.emit("battle.started", { battle = { ruleset = weather } })
|
||||
T.eq(hit(weather, "WATER"), 150, "a new battle starts the rain again")
|
||||
Runtime.emit("battle.ended", {})
|
||||
T.eq(hit(weather, "WATER"), 100, "battle.ended clears the rain")
|
||||
|
||||
run.release()
|
||||
T.finish("example_weather")
|
||||