Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1950348d4 | |||
| 5603477787 | |||
| 0ca1defb66 | |||
| 370ddea0b8 | |||
| 6a9e77cde0 | |||
| a03f69926e | |||
| 04ece522d7 | |||
| 4943d3e5f4 | |||
| 5a48a61f2e | |||
| fd66a04686 | |||
| b69f8c27c9 | |||
| 66f0326492 | |||
| dbab4a2116 | |||
| 3e8da9b232 | |||
| 223202f230 | |||
| 3078c20e1d | |||
| 617a87a47b | |||
| 8539a6b268 | |||
| f9f38d161f | |||
| 8b85cda84c | |||
| 1deba07106 | |||
| c24c7cbcb7 | |||
| c2334f2f91 | |||
| bfac5fe327 | |||
| 3b1032cc63 | |||
| 72f83760b7 | |||
| 63a179b77a | |||
| 37ba5c3879 | |||
| 00f86ca192 | |||
| b0868e7571 | |||
| 9ec3ff26d8 | |||
| 726e1d4ec5 | |||
| 5b3c5f5da8 | |||
| c45f6be8f2 | |||
| 3b2e489325 | |||
| 10acfe0710 | |||
| 5113cfd951 | |||
| 43158d724b | |||
| f0a88ea473 | |||
| 31365d8dfd | |||
| 531a719794 | |||
| ff90062741 | |||
| 803f86d5b3 | |||
| 3448fae3f5 | |||
| 25df838b45 | |||
| be90e5b42c | |||
| 08e8dfc416 | |||
| 6a27be54de | |||
| d4055209e6 | |||
| 043f6b2426 | |||
| f7695308b7 | |||
| a8936e79d6 | |||
| f6b29e6caa | |||
| 3069b2e2a9 | |||
| 6625391f76 | |||
| c40dcd6159 | |||
| 35f5c9513c | |||
| 5a0f9ab06d | |||
| 13b58dc51e | |||
| d872010209 | |||
| 6306975352 | |||
| 819150f50f | |||
| bfba1f7bb7 | |||
| 8278b209b4 | |||
| 1c4515eaaa | |||
| c04b93ce1c | |||
| 2c9970a643 |
@@ -25,7 +25,9 @@ body:
|
|||||||
options:
|
options:
|
||||||
- macOS
|
- macOS
|
||||||
- Windows
|
- Windows
|
||||||
- Both
|
- Linux
|
||||||
|
- Android
|
||||||
|
- Multiple platforms
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -38,6 +40,17 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: discord
|
||||||
|
attributes:
|
||||||
|
label: Discord username (optional)
|
||||||
|
description: >
|
||||||
|
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||||
|
Leave blank if you'd rather keep everything on GitHub.
|
||||||
|
placeholder: yourname
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: location
|
id: location
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,35 +1,53 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
description: Ask for something new, or a change to how something already works.
|
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
|
Use this for **engine / launcher / platform** work (ports, video options, save
|
||||||
|
tooling, networking, mod API seams, docs).
|
||||||
|
|
||||||
|
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||||
|
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||||
|
Gen 2-like battle toggles, soundtrack packs — open a
|
||||||
|
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
|
||||||
|
instead.
|
||||||
|
|
||||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
"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
|
and how you picture it working.
|
||||||
actually build without guessing at what you meant.
|
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
label: One line summary
|
label: One line summary
|
||||||
description: What you want, in a sentence.
|
description: What you want, in a sentence.
|
||||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: discord
|
||||||
|
attributes:
|
||||||
|
label: Discord username (optional)
|
||||||
|
description: >
|
||||||
|
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||||
|
Leave blank if you'd rather keep everything on GitHub.
|
||||||
|
placeholder: yourname
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what
|
id: what
|
||||||
attributes:
|
attributes:
|
||||||
label: What do you want
|
label: What do you want
|
||||||
description: >
|
description: >
|
||||||
Describe it properly. What is it, where does it live in the game, what does the
|
Describe it properly. What is it, where does it live (launcher, options,
|
||||||
player see or do. If it changes something that already exists, say what it does today
|
engine), what does the player see or do. If it changes something that already
|
||||||
and what it should do instead.
|
exists, say what it does today and what it should do instead.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
|
||||||
foot and the player moves at bike speed. Release and you go back to walking.
|
with the same save folder layout and mod discovery path.
|
||||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -41,9 +59,8 @@ body:
|
|||||||
What's annoying or missing right now. What does this fix. If it's just because you
|
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.
|
think it would be fun, say that, it's a real answer.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
LÖVE already runs on Linux; without a packaged build, players have to assemble
|
||||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
it themselves and miss release notes / update checks.
|
||||||
without making the game easier.
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -52,14 +69,12 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: How should it work
|
label: How should it work
|
||||||
description: >
|
description: >
|
||||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
The specifics. Which menu, what happens in the edge cases. If you don't
|
||||||
know, say what you'd expect as a player and leave the rest open.
|
know, say what you'd expect as a player and leave the rest open.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
- Hold B on the overworld to run
|
- GitHub Releases asset next to the .dmg / .exe
|
||||||
- Same speed as the bike
|
- Same options.lua / mods/ layout as desktop
|
||||||
- Disabled anywhere the bike is disabled
|
- Documented in the README install section
|
||||||
- Sprite should probably need a running animation, not sure what that takes
|
|
||||||
- No effect on encounter rates
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
name: Mod request
|
||||||
|
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
|
||||||
|
labels: ["mod request"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
This tracker is for ideas that should ship as **mods**, not as core engine
|
||||||
|
features — alternate sprites, running shoes, day/night, shiny indicators,
|
||||||
|
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||||
|
|
||||||
|
The engine already exposes a lot of this through registries and hooks
|
||||||
|
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
|
||||||
|
Use a **Feature request** instead for launcher / ports / video options /
|
||||||
|
networking / save tooling / new API seams.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: One line summary
|
||||||
|
description: What the mod should do, in a sentence.
|
||||||
|
placeholder: Hold B to run at bike speed on the overworld
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: discord
|
||||||
|
attributes:
|
||||||
|
label: Discord username (optional)
|
||||||
|
description: >
|
||||||
|
So maintainers or mod authors can ping you on Discord if they pick this up.
|
||||||
|
Leave blank if you'd rather keep everything on GitHub.
|
||||||
|
placeholder: yourname
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What should the mod do
|
||||||
|
description: >
|
||||||
|
Describe the player-facing behavior. What changes, where, what does the
|
||||||
|
player see or press. If it toggles from Options or a START-menu entry, say so.
|
||||||
|
placeholder: |
|
||||||
|
Hold B while walking outdoors to move at bike speed. Release to walk again.
|
||||||
|
Same places the bike is allowed; no effect in battles or menus.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: why
|
||||||
|
attributes:
|
||||||
|
label: Why is this worth doing as a mod
|
||||||
|
description: >
|
||||||
|
Why optional/modded rather than a core option. Who wants it on, who wants
|
||||||
|
vanilla left alone.
|
||||||
|
placeholder: |
|
||||||
|
Great for replaying and backtracking, but some people want a strict Gen 1
|
||||||
|
pace. A mod (or an opt-in mod option) keeps both camps happy.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: how
|
||||||
|
attributes:
|
||||||
|
label: How should it work
|
||||||
|
description: >
|
||||||
|
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
|
||||||
|
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
|
||||||
|
mention it — otherwise leave it open.
|
||||||
|
placeholder: |
|
||||||
|
- Hold B on the overworld
|
||||||
|
- Same step timing as the bike
|
||||||
|
- Disabled where the bike is disabled
|
||||||
|
- Prefer hooks:wrap("movement.speed") if that still fits
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: vanilla
|
||||||
|
attributes:
|
||||||
|
label: With the mod off, is vanilla unchanged
|
||||||
|
options:
|
||||||
|
- Yes — parity when disabled
|
||||||
|
- No — it would replace something always-on
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Best-fit mod category
|
||||||
|
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
|
||||||
|
options:
|
||||||
|
- GAMEPLAY / QoL
|
||||||
|
- GRAPHICS
|
||||||
|
- AUDIO
|
||||||
|
- BALANCE / ruleset
|
||||||
|
- CONTENT (maps, encounters, trainers)
|
||||||
|
- UI / TOOL
|
||||||
|
- TOTAL_CONVERSION-ish
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Anything else
|
||||||
|
description: >
|
||||||
|
Reference screenshots, other games/hacks that do it, related issues, or
|
||||||
|
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -14,7 +14,9 @@ name: ci
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
# Integration branch + release branch. PRs already run via pull_request
|
||||||
|
# (any base); this list is only for post-merge push runs.
|
||||||
|
branches: [dev, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||||
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
|
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||||
|
# Mac runner, and publishes them as a GitHub Release.
|
||||||
#
|
#
|
||||||
# Versioning:
|
# Versioning:
|
||||||
# - First ever release is 0.1.0.
|
# - First ever release is 0.1.0.
|
||||||
@@ -10,6 +11,9 @@ name: Release
|
|||||||
# - To force a specific version, either:
|
# - To force a specific version, either:
|
||||||
# * run this workflow manually (Actions tab) and type it into "version", or
|
# * run this workflow manually (Actions tab) and type it into "version", or
|
||||||
# * put "[release X.Y.Z]" anywhere in the commit message.
|
# * put "[release X.Y.Z]" anywhere in the commit message.
|
||||||
|
#
|
||||||
|
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
|
||||||
|
# so promoting `dev` -> `main` is the ship gate that cuts a build.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -166,6 +170,13 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Build Anbernic RG34XXSP port
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||||
|
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||||
|
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
- name: Notarize & staple macOS app
|
- name: Notarize & staple macOS app
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -214,8 +225,27 @@ jobs:
|
|||||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
|
|
||||||
|
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||||
|
# future RG35XX/other-CFW pack can ship alongside it).
|
||||||
|
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||||
|
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||||
|
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||||
|
|
||||||
|
# Platform-independent update payload, built alongside the desktop
|
||||||
|
# apps above (same game.love that gets fused into each of them).
|
||||||
|
love_file=".bazinga/work/game.love"
|
||||||
|
[ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; }
|
||||||
|
cp "$love_file" "$outdir/gen1recomp-${v}.love"
|
||||||
|
|
||||||
ls -lh "$outdir"
|
ls -lh "$outdir"
|
||||||
|
|
||||||
|
# Checksums for every staged release asset (sums file itself is
|
||||||
|
# written after this and named outside the gen1recomp-* glob, so it
|
||||||
|
# never lists itself).
|
||||||
|
(cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt)
|
||||||
|
cat "$outdir/sha256sums.txt"
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -266,7 +296,10 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||||
"dist/release/gen1recomp-${v}-android.apk"
|
"dist/release/gen1recomp-${v}-android.apk" \
|
||||||
|
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||||
|
"dist/release/gen1recomp-${v}.love" \
|
||||||
|
"dist/release/sha256sums.txt"
|
||||||
|
|
||||||
echo "Published release $tag"
|
echo "Published release $tag"
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
# Pokemon Gen 1 Recompilation Project
|
# Gen1Recomp
|
||||||
|
|
||||||
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
|
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
||||||
hand-written Lua; game data and graphics are decoded from a ROM supplied by
|
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||||
the player.
|
supplied by the player.
|
||||||
|
|
||||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
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
|
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||||
content input.
|
game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
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
|
not copied into the cache. Later launches load the private generated cache and
|
||||||
do not ask for the ROM again.
|
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||||
|
side by side.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -20,30 +21,94 @@ 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
|
or drop it onto the window. Import takes a few seconds and the game starts
|
||||||
automatically.
|
automatically.
|
||||||
|
|
||||||
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
|
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
|
verifies SHA-1 before creating any game data:
|
||||||
|
|
||||||
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
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
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
audio channel programs copied out of the verified ROM.
|
audio channel programs copied out of the verified ROM.
|
||||||
|
|
||||||
## Controls
|
## 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.
|
| Action | Keyboard | Controller |
|
||||||
|
| ------ | ----------------- | ------------------ |
|
||||||
|
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||||
|
| A | Z / Enter / Space | A |
|
||||||
|
| B | X / Backspace | B |
|
||||||
|
| Start | Escape | Start |
|
||||||
|
| Select | Tab / Shift | Back / Select |
|
||||||
|
|
||||||
|
|
||||||
|
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||||
|
supported out of the box.
|
||||||
|
|
||||||
|
### Hotkeys
|
||||||
|
|
||||||
|
|
||||||
|
| Key | What it does |
|
||||||
|
| --------- | ---------------------------------------------------- |
|
||||||
|
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||||
|
| `2` | Cycle COLORS |
|
||||||
|
| `3` | Cycle TILT (free-roam overworld) |
|
||||||
|
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||||
|
| `5` | Cycle GBC FX |
|
||||||
|
| `F1` | Save |
|
||||||
|
| `F2` | Load |
|
||||||
|
| `F10` | Open / close the mod manager |
|
||||||
|
|
||||||
|
|
||||||
|
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||||
|
and persist in `options.lua`.
|
||||||
|
|
||||||
|
### Rulesets
|
||||||
|
|
||||||
|
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||||
|
Both rulesets share the same damage formulas; they differ only in whether
|
||||||
|
the original's quirks are kept. The setting persists in `options.lua`, and
|
||||||
|
mods can register their own.
|
||||||
|
|
||||||
|
`gen1_faithful` is the default and reproduces the original cartridge,
|
||||||
|
famous bugs included:
|
||||||
|
|
||||||
|
| Rule | Behavior |
|
||||||
|
| --------------------------- | ----------------------------------------------------- |
|
||||||
|
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
|
||||||
|
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
|
||||||
|
| `critIgnoresStages` | Crit rate ignores stat stages |
|
||||||
|
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
|
||||||
|
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
|
||||||
|
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
|
||||||
|
| `randMin` / `randMax` | Damage random factor 217-255 |
|
||||||
|
|
||||||
|
`modern_clean` keeps the formulas but removes the notorious quirks:
|
||||||
|
|
||||||
|
| Rule | Behavior |
|
||||||
|
| --------------------------- | ----------------------------------------------------- |
|
||||||
|
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
|
||||||
|
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
|
||||||
|
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
|
||||||
|
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
|
||||||
|
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
|
||||||
|
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
|
||||||
|
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
|
||||||
|
|
||||||
## Running From Source
|
## Running From Source
|
||||||
|
|
||||||
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
|
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||||
`Play-Mac.command` or `Play-Windows.bat`, or run:
|
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
|
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||||
scripts/run.sh
|
scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
then `love .` for later launches. Windows PowerShell scripts, the optional
|
then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||||
developer data build, test suites, and cache management are covered in
|
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).
|
[Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
|
||||||
|
|
||||||
## Portable Mode
|
## Portable Mode
|
||||||
|
|
||||||
@@ -51,7 +116,7 @@ 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
|
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
|
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
|
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
|
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||||
running from source), then launch the game. Portable mode is desktop-only
|
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
|
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||||
runs from a read-only package.
|
runs from a read-only package.
|
||||||
@@ -59,14 +124,21 @@ runs from a read-only package.
|
|||||||
With `portable.txt` present:
|
With `portable.txt` present:
|
||||||
|
|
||||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||||
that same folder instead of the OS save directory.
|
that same folder instead of the OS save directory.
|
||||||
- A ROM import writes the generated `data/generated` and `assets/generated`
|
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||||
cache straight into that folder too (nothing is left in the OS save
|
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
|
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.
|
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
|
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||||
already written to either location is touched automatically, so copy files
|
already written to either location is touched automatically, so copy files
|
||||||
over yourself if you want to carry existing progress across the switch.
|
over yourself if you want to carry existing progress across the switch.
|
||||||
|
|
||||||
|
## Handhelds
|
||||||
|
|
||||||
|
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||||
|
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||||
|
Install steps, controls, and troubleshooting live in
|
||||||
|
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||||
|
|
||||||
## Modding
|
## Modding
|
||||||
|
|
||||||
@@ -74,40 +146,38 @@ 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 —
|
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
|
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
|
||||||
reference — lives on the
|
reference — lives on the
|
||||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||||
|
|
||||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
|
||||||
|
|
||||||
## Bugs and Ideas
|
## Bugs and Ideas
|
||||||
|
|
||||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
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
|
that looks wrong, text in the wrong box, anything that does not match the
|
||||||
original game.
|
original game.
|
||||||
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
[Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
|
||||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
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.
|
can't get one, the form asks you to describe what you saw instead.
|
||||||
|
|
||||||
Thought of a feature that could be good, or a way to improve one that already
|
Thought of a feature that could be good, or a way to improve one that already
|
||||||
exists?
|
exists?
|
||||||
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
|
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
|
||||||
Say what you want, why it is worth doing, and how you picture it working. A
|
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.
|
request with real detail is one that can actually get built.
|
||||||
|
|
||||||
## More
|
## More
|
||||||
|
|
||||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
|
||||||
— START > LINK connects two copies directly over UDP.
|
— START > LINK connects two copies directly over UDP.
|
||||||
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
|
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||||
- `docs/architecture.md` — runtime details;
|
- `docs/architecture.md` — runtime details;
|
||||||
`docs/behavior-porting-notes.md` — formula provenance.
|
`docs/behavior-porting-notes.md` — formula provenance.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Special Thanks
|
## Special Thanks
|
||||||
|
|
||||||
This project would not be possible without [pret](https://github.com/pret) >
|
This project would not be possible without [pret](https://github.com/pret) >
|
||||||
the pret band of decompiling maniacs > and their
|
the pret band of decompiling maniacs > and their
|
||||||
[pokered](https://github.com/pret/pokered) disassembly.
|
[pokered](https://github.com/pret/pokered) disassembly.
|
||||||
|
|
||||||
## Wanna Support My Work?
|
|
||||||
[](https://buymeacoffee.com/bryanthaboi)
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 753 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
# On-screen touch control art
|
||||||
|
|
||||||
|
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
|
||||||
|
Those Awesome Guys), public domain under CC0:
|
||||||
|
https://thoseawesomeguys.com/prompts/
|
||||||
|
|
||||||
|
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
|
||||||
|
d-pad (neutral + per-direction highlight), A, B, plus (START) and
|
||||||
|
minus (SELECT).
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
|
||||||
|
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
|
||||||
|
# + PortMaster).
|
||||||
|
#
|
||||||
|
# The stock Anbernic firmware resolves ports relative to the launch script
|
||||||
|
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
|
||||||
|
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
|
||||||
|
# device does not need a separate love_11.5 runtime download on first launch.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-rg34xxsp.sh [--version X.Y.Z]
|
||||||
|
#
|
||||||
|
# Output:
|
||||||
|
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||||
|
#
|
||||||
|
# Install on device:
|
||||||
|
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
|
||||||
|
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
|
||||||
|
# roms/PORTS/Gen1recomp.sh
|
||||||
|
# roms/PORTS/gen1recomp/...
|
||||||
|
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
|
||||||
|
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
|
||||||
|
# folder when zenity is missing).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
HERE="$ROOT/.bazinga"
|
||||||
|
CACHE="$HERE/cache/rg34xxsp"
|
||||||
|
WORK="$HERE/work/rg34xxsp"
|
||||||
|
DIST="$ROOT/dist/rg34xxsp"
|
||||||
|
|
||||||
|
APP_NAME="gen1recomp"
|
||||||
|
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
|
||||||
|
# 64-bit MOD for RG34XXSP). Release uploads stage it as
|
||||||
|
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
|
||||||
|
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
|
||||||
|
PORT_DIR_NAME="gen1recomp"
|
||||||
|
LAUNCHER_NAME="Gen1recomp.sh"
|
||||||
|
LOVE_VERSION="11.5"
|
||||||
|
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||||
|
|
||||||
|
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||||
|
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||||
|
|
||||||
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
|
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version) VERSION="$2"; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,24p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) fail "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v curl >/dev/null || fail "curl is required"
|
||||||
|
command -v zip >/dev/null || fail "zip is required"
|
||||||
|
command -v unzip >/dev/null || fail "unzip is required"
|
||||||
|
|
||||||
|
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local url="$1" dest="$2"
|
||||||
|
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
say "downloading $(basename "$dest")"
|
||||||
|
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||||
|
|| fail "download failed: $url"
|
||||||
|
mv "$dest.tmp" "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- game tree
|
||||||
|
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
|
||||||
|
# main.lua and RomImporter can scan it without zenity/kdialog.
|
||||||
|
say "staging lovegame/"
|
||||||
|
GAME_SRC="$WORK/lovegame"
|
||||||
|
rm -rf "$GAME_SRC"
|
||||||
|
mkdir -p "$GAME_SRC"
|
||||||
|
|
||||||
|
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||||
|
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||||
|
# save row opens it in-process (main.lua).
|
||||||
|
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||||
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
|
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||||
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
|
fail "payload unexpectedly contains generated ROM data"
|
||||||
|
fi
|
||||||
|
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||||
|
rm -f "$WORK/game-payload.zip"
|
||||||
|
|
||||||
|
# Stamp release version into the staged tree only (never the working tree).
|
||||||
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
say "stamping engine version $VERSION"
|
||||||
|
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||||
|
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||||
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
|
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|
"$GAME_SRC/src/core/Version.lua" \
|
||||||
|
|| fail "version stamp failed"
|
||||||
|
else
|
||||||
|
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||||
|
: > "$GAME_SRC/portable.txt"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- love runtime
|
||||||
|
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||||
|
LOVE_BIN="$CACHE/love.aarch64"
|
||||||
|
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||||
|
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||||
|
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||||
|
OGG_LIB="$CACHE/libogg.so.0"
|
||||||
|
|
||||||
|
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||||
|
|
||||||
|
# Sanity: love stub must be an aarch64 ELF.
|
||||||
|
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||||
|
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- port tree
|
||||||
|
say "assembling port package"
|
||||||
|
PORT_ROOT="$WORK/port"
|
||||||
|
rm -rf "$PORT_ROOT"
|
||||||
|
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||||
|
|
||||||
|
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||||
|
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||||
|
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||||
|
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||||
|
|
||||||
|
# Drop a short license pointer for the bundled LÖVE bits.
|
||||||
|
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||||
|
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||||
|
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||||
|
see https://love2d.org/ for full terms.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- launcher
|
||||||
|
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
|
||||||
|
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
|
||||||
|
# casing and mount points (mmc / sdcard / TF1 / TF2).
|
||||||
|
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
|
||||||
|
# Uses SHDIR-relative paths so stock firmware finds the game folder.
|
||||||
|
|
||||||
|
export HOME="${HOME:-/root}"
|
||||||
|
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||||
|
|
||||||
|
if [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||||
|
controlfolder="/opt/system/Tools/PortMaster"
|
||||||
|
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||||
|
controlfolder="/opt/tools/PortMaster"
|
||||||
|
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||||
|
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||||
|
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
|
||||||
|
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
|
||||||
|
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
|
||||||
|
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
|
||||||
|
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||||
|
controlfolder="/roms/ports/PortMaster"
|
||||||
|
else
|
||||||
|
controlfolder="/roms/PORTS/PortMaster"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$controlfolder/control.txt"
|
||||||
|
get_controls
|
||||||
|
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||||
|
|
||||||
|
GAMEDIR="$SHDIR/gen1recomp"
|
||||||
|
CONFDIR="$GAMEDIR/conf"
|
||||||
|
mkdir -p "$CONFDIR"
|
||||||
|
|
||||||
|
cd "$GAMEDIR" || exit 1
|
||||||
|
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||||
|
|
||||||
|
export XDG_DATA_HOME="$CONFDIR"
|
||||||
|
export XDG_CONFIG_HOME="$CONFDIR"
|
||||||
|
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||||
|
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||||
|
# Mali / H700: prefer GLES where available
|
||||||
|
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||||
|
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
||||||
|
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
||||||
|
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
||||||
|
# persisted in options.lua.
|
||||||
|
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
||||||
|
|
||||||
|
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||||
|
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ -n "${GPTOKEYB:-}" ]; then
|
||||||
|
$GPTOKEYB "love.aarch64" &
|
||||||
|
fi
|
||||||
|
if type pm_platform_helper >/dev/null 2>&1; then
|
||||||
|
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||||
|
|
||||||
|
if type pm_finish >/dev/null 2>&1; then
|
||||||
|
pm_finish
|
||||||
|
else
|
||||||
|
if [ -n "${ESUDO:-}" ]; then
|
||||||
|
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||||
|
else
|
||||||
|
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- metadata
|
||||||
|
cat > "$PORT_ROOT/port.json" <<EOF
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"name": "gen1recomp.zip",
|
||||||
|
"items": [
|
||||||
|
"$LAUNCHER_NAME",
|
||||||
|
"$PORT_DIR_NAME"
|
||||||
|
],
|
||||||
|
"items_opt": null,
|
||||||
|
"attr": {
|
||||||
|
"title": "gen1recomp",
|
||||||
|
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||||
|
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
|
||||||
|
"genres": ["adventure", "rpg"],
|
||||||
|
"porter": ["gen1recomp"],
|
||||||
|
"image": {},
|
||||||
|
"rtr": true,
|
||||||
|
"runtime": null,
|
||||||
|
"reqs": [],
|
||||||
|
"arch": ["aarch64"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<gameList>
|
||||||
|
<game>
|
||||||
|
<path>./$LAUNCHER_NAME</path>
|
||||||
|
<name>gen1recomp</name>
|
||||||
|
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||||
|
<releasedate>20250101T000000</releasedate>
|
||||||
|
<developer>the bois club</developer>
|
||||||
|
<publisher>the bois club</publisher>
|
||||||
|
<genre>RPG</genre>
|
||||||
|
</game>
|
||||||
|
</gameList>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||||
|
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
|
||||||
|
|
||||||
|
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||||
|
**Stock OS 64-bit MOD** (H700, PortMaster).
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
|
||||||
|
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
|
||||||
|
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
|
||||||
|
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
|
||||||
|
|
||||||
|
### Controls (launcher)
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|--|--|
|
||||||
|
| D-pad / left stick | Move cursor |
|
||||||
|
| A | Click |
|
||||||
|
| L1 / R1 | Switch tabs |
|
||||||
|
| Right stick | Scroll lists |
|
||||||
|
| Start / Select | Play or Choose ROM |
|
||||||
|
|
||||||
|
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||||
|
|
||||||
|
### Display options
|
||||||
|
|
||||||
|
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
||||||
|
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
||||||
|
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
||||||
|
`POKEPORT_GBCFX=1`.
|
||||||
|
|
||||||
|
### First run
|
||||||
|
|
||||||
|
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||||
|
**Choose ROM** — the game scans that folder. After import, the ROM-derived
|
||||||
|
cache and saves stay beside the game (`portable.txt`).
|
||||||
|
|
||||||
|
Canonical SHA-1 (1 MiB US carts only):
|
||||||
|
|
||||||
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
|
||||||
|
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- zip
|
||||||
|
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||||
|
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
|
||||||
|
say "packing $ZIP_OUT"
|
||||||
|
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||||
|
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||||
|
|
||||||
|
say "done."
|
||||||
|
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||||
|
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
|
||||||
@@ -13,7 +13,12 @@ function love.conf(t)
|
|||||||
_G.POKEPORT_DEV_MODE = developer
|
_G.POKEPORT_DEV_MODE = developer
|
||||||
|
|
||||||
if editor then
|
if editor then
|
||||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
-- Same identity as the game, deliberately: the editor edits the game's
|
||||||
|
-- saves and reads the game's ROM cache, both of which live under this
|
||||||
|
-- folder. A private editor identity would point love.filesystem at an
|
||||||
|
-- empty directory in a packaged build, so `--editor` could not find
|
||||||
|
-- data/generated at all (SaveIO.defaultPath already assumed this name).
|
||||||
|
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
|
||||||
t.window.title = "Pokemon Save Editor"
|
t.window.title = "Pokemon Save Editor"
|
||||||
t.window.width = 1280
|
t.window.width = 1280
|
||||||
t.window.height = 800
|
t.window.height = 800
|
||||||
@@ -47,8 +52,9 @@ function love.conf(t)
|
|||||||
-- freely to portrait or landscape), otherwise locked to the window's w/h
|
-- freely to portrait or landscape), otherwise locked to the window's w/h
|
||||||
-- aspect. So a non-resizable tall window forced portrait; resizable lets
|
-- aspect. So a non-resizable tall window forced portrait; resizable lets
|
||||||
-- the game follow the device. The renderer letterboxes the 160x144
|
-- the game follow the device. The renderer letterboxes the 160x144
|
||||||
-- viewport into whatever size results, and touch input is gesture-based,
|
-- viewport into whatever size results, and the on-screen touch controls
|
||||||
-- so both orientations just work. iOS follows the Info.plist orientations
|
-- re-lay themselves out from the new window size, so both orientations
|
||||||
|
-- just work. iOS follows the Info.plist orientations
|
||||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||||
t.window.resizable = true
|
t.window.resizable = true
|
||||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
|
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
|
||||||
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
|
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
|
||||||
--
|
--
|
||||||
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
|
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
|
||||||
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
|
-- data/scripts/story4.lua.
|
||||||
-- omitted here.
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
COPYCATS_HOUSE_2F = {
|
COPYCATS_HOUSE_2F = {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
|
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
|
||||||
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
|
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
|
||||||
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
|
-- guide and SUPER_NERD2 garden nerd.
|
||||||
--
|
--
|
||||||
-- The escort choreography (SUPER_NERD1 walking the player to the
|
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||||
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
|
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||||
-- movement + a wPewterCityCurScript state machine that steers the
|
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||||
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
|
-- YES/NO-branched flavor text is here.
|
||||||
-- 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 = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -61,15 +57,6 @@ M.PEWTER_CITY = {
|
|||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
|
|
||||||
-- me" line the youngster says when the player is stopped from
|
|
||||||
-- leaving Pewter east before beating Brock; the actual gate/step
|
|
||||||
-- block is handled by story5.lua's onStep for this map.
|
|
||||||
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
|
||||||
local t = text(game)
|
|
||||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
|
||||||
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
|
|
||||||
end,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
-- pokered/scripts/PewterNidoranHouse.asm
|
-- pokered/scripts/PewterNidoranHouse.asm
|
||||||
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
||||||
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
||||||
-- No sound-effect command exists in Commands.lua, so we port the dialogue line
|
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
|
||||||
-- only; the cry SFX is cosmetic and has no gameplay effect.
|
-- has to sit immediately before that row (src/script/Commands.lua); the cry
|
||||||
|
-- then sounds once the box has typed out, matching the asm's print-then-cry
|
||||||
|
-- order. Its waitForButton form keeps DisplayTextID's trailing
|
||||||
|
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
|
||||||
|
-- `jp EnableAutoTextBoxDrawing`, which zeroes
|
||||||
|
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
|
||||||
|
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
|
||||||
return {
|
return {
|
||||||
PEWTER_NIDORAN_HOUSE = {
|
PEWTER_NIDORAN_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
-- text_asm: ld a, NIDORAN_M / call PlayCry
|
||||||
|
{ "play_cry", "NIDORAN_M", true },
|
||||||
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Red's House 2F (pokered data/events/hidden_events.asm +
|
||||||
|
-- engine/events/hidden_objects PrintRedSNESText).
|
||||||
|
--
|
||||||
|
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
|
||||||
|
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
|
||||||
|
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
|
||||||
|
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
return {
|
||||||
|
REDS_HOUSE_2F = {
|
||||||
|
-- hidden_events.asm:
|
||||||
|
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
if fx ~= 3 or fy ~= 5 then return false end
|
||||||
|
local text = game.data.text._RedBedroomSNESText
|
||||||
|
or "{PLAYER} is\nplaying the SNES!"
|
||||||
|
game.stack:push(TextBox.new(game, text))
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
|
||||||
|
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
|
||||||
|
-- switch events clear (so those barriers are closed again on the next floor
|
||||||
|
-- load) and the boulder that fell through 3F's hole goes back upstairs:
|
||||||
|
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
|
||||||
|
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
|
||||||
|
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
|
||||||
|
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
|
||||||
|
-- is what makes the puzzle "completely reset" on a return trip (#258).
|
||||||
|
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
|
||||||
|
-- every map entry, connection crossings included, like EnterMap.
|
||||||
M.ROUTE_23 = {
|
M.ROUTE_23 = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local f = game.save.flags
|
||||||
|
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
|
||||||
|
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
|
||||||
|
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
|
||||||
|
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { save = game.save, overworld = ow, game = game }
|
||||||
|
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
|
||||||
|
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
||||||
-- -> wWhichBadge = EARTHBADGE
|
-- -> wWhichBadge = EARTHBADGE
|
||||||
|
|||||||
@@ -5,19 +5,23 @@ return {
|
|||||||
SILPH_CO_9F = {
|
SILPH_CO_9F = {
|
||||||
talk = {
|
talk = {
|
||||||
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
|
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
|
||||||
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
|
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
|
||||||
-- "You look tired..." then "Don't give up!"; after the event, just
|
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
|
||||||
-- says thanks. Nurse texts are not in data/generated/text.lua, so
|
-- up!"; after the event, just says thanks. Nurse texts are not in
|
||||||
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
|
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
|
||||||
|
-- strings are used as literals.
|
||||||
TEXT_SILPHCO9F_NURSE = {
|
TEXT_SILPHCO9F_NURSE = {
|
||||||
{ "face_player" },
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||||
{ "jump_if_true", 8 },
|
{ "jump_if_true", 11 }, -- 3
|
||||||
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
|
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
|
||||||
{ "heal_party" },
|
{ "heal_party" }, -- 5
|
||||||
{ "show_text", "Don't give up!" },
|
{ "fade", "out", "white" }, -- 6
|
||||||
{ "jump", 9 },
|
{ "wait", 3 }, -- 7 Delay3
|
||||||
{ "show_text", "Thank you so\nmuch!" },
|
{ "fade", "in", "white" }, -- 8
|
||||||
|
{ "show_text", "Don't give up!" }, -- 9
|
||||||
|
{ "jump", "end" }, -- 10
|
||||||
|
{ "show_text", "Thank you so\nmuch!" }, -- 11
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
|
|||||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||||
-- the literal strings from pokered.
|
-- the literal strings from pokered. Those fallbacks have to carry the
|
||||||
|
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
||||||
|
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
||||||
|
-- one page with nothing to wait on, so the whole speech scrolled past
|
||||||
|
-- without a button press (#250).
|
||||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||||
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes)
|
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done)
|
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||||
else
|
else
|
||||||
push(game, "Oh, OK then!", done)
|
push(game, "Oh, OK then!", done)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
return {
|
return {
|
||||||
VIRIDIAN_NICKNAME_HOUSE = {
|
VIRIDIAN_NICKNAME_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
|
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
|
||||||
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
|
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
|
||||||
-- cry playback isn't in the talk-script command vocabulary, so
|
-- before TextScriptEnd. play_cry only arms the show_text row that
|
||||||
-- only the flavor line is ported here.
|
-- follows it, so the cry sounds once the box has typed out; its
|
||||||
|
-- waitForButton form keeps DisplayTextID's trailing
|
||||||
|
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
|
||||||
|
-- any other NPC line rather than popping itself (#251).
|
||||||
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
|
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
|
||||||
{ "show_text", "_ViridianNicknameHouseSpearowText" },
|
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
|
||||||
|
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ local files = {
|
|||||||
"data.scripts.flavor.pokemon_fan_club",
|
"data.scripts.flavor.pokemon_fan_club",
|
||||||
"data.scripts.flavor.power_plant",
|
"data.scripts.flavor.power_plant",
|
||||||
"data.scripts.flavor.reds_house_1f",
|
"data.scripts.flavor.reds_house_1f",
|
||||||
|
"data.scripts.flavor.reds_house_2f",
|
||||||
"data.scripts.flavor.route11_gate_2f",
|
"data.scripts.flavor.route11_gate_2f",
|
||||||
"data.scripts.flavor.route18_gate_2f",
|
"data.scripts.flavor.route18_gate_2f",
|
||||||
"data.scripts.flavor.route_12_gate_2f",
|
"data.scripts.flavor.route_12_gate_2f",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||||
-- OaksLabRivalTakePokeBallScript.
|
-- OaksLabRivalTakePokeBallScript.
|
||||||
-- * Rival (object 1): before starter -> "gramps isn't around"; with
|
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||||
|
-- has walked you in, else "gramps isn't around" (#218); with
|
||||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||||
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
||||||
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
||||||
@@ -24,17 +25,23 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||||
{ "jump_if_false", 20 }, -- 4
|
{ "jump_if_false", 20 }, -- 4
|
||||||
-- the Pokédex "new species" entry shows before the ask (predef
|
-- the Pokédex "new species" entry shows before the ask (predef
|
||||||
-- StarterDex ahead of OaksLabYouWant...Text)
|
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||||
{ "push_screen", "DexEntryMenu", species }, -- 5
|
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||||
|
-- forceOwned is that bypass without mutating save.pokedex.owned.
|
||||||
|
{ "push_screen", "DexEntryMenu",
|
||||||
|
{ species = species, forceOwned = true } }, -- 5
|
||||||
{ "ask", askText }, -- 6
|
{ "ask", askText }, -- 6
|
||||||
{ "jump_if_false", 21 }, -- 7
|
{ "jump_if_false", 21 }, -- 7
|
||||||
{ "give_pokemon", species, 5 }, -- 8
|
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
|
-- inside give_pokemon). Show the received text first so the
|
||||||
{ "set_flag", choseFlag }, -- 10
|
-- nickname prompt follows "you got X", matching Gen1.
|
||||||
|
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||||
|
{ "give_pokemon", species, 5 }, -- 9
|
||||||
|
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||||
|
{ "set_flag", choseFlag }, -- 11
|
||||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
|
|
||||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||||
-- the rival walks to the countering ball (around the furniture)
|
-- the rival walks to the countering ball (around the furniture)
|
||||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||||
@@ -53,48 +60,97 @@ end
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
talk = {
|
talk = {
|
||||||
-- Oak: accepts the parcel and hands over the Pokédex, then (once the
|
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
|
||||||
-- player has beaten the Route 22 rival) hands over the real POKé
|
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
|
||||||
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
|
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
|
||||||
-- recall / dex-rating / "pokemon can fight" / "around the world"
|
-- (DisplayDexRating) is still skipped.
|
||||||
-- branches -- see docs/known-differences.md)
|
|
||||||
TEXT_OAKSLAB_OAK1 = {
|
TEXT_OAKSLAB_OAK1 = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" },
|
||||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
{ "check_item", "POKE_BALL" },
|
||||||
{ "jump_if_false", 14 }, -- 3
|
{ "jump_if_true", "come_see" },
|
||||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||||
{ "jump_if_true", 14 }, -- 5
|
{ "jump_if_true", "give_balls" },
|
||||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
{ "jump_if_true", "around_world" },
|
||||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
{ "jump_if_false", "pre_lab_battle" },
|
||||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
{ "check_item", "OAKS_PARCEL" },
|
||||||
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
{ "jump_if_false", "raise_young" },
|
||||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
{ "play_sound", "Get_Key_Item" },
|
||||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
|
{ "take_item", "OAKS_PARCEL", 1 },
|
||||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
|
{ "stop_music" },
|
||||||
{ "jump", 32 }, -- 13
|
{ "play_music", "Music_MeetRival" },
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
|
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||||
{ "jump_if_false", 29 }, -- 15
|
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||||
{ "check_item", "POKE_BALL" }, -- 16
|
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
|
||||||
{ "jump_if_true", 27 }, -- 17
|
-- when the player stands below Oak (the usual desk talk).
|
||||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
|
{ "place_npc", 1, 4, 7, "up" },
|
||||||
{ "jump_if_false", 31 }, -- 19
|
{ "move_npc_to", 1, 4, 3 },
|
||||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
{ "play_music", "Music_OaksLab" },
|
||||||
{ "jump_if_true", 27 }, -- 21
|
{ "face_object", 1, "up" },
|
||||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
{ "face_object", 5, "down" },
|
||||||
{ "give_item", "POKE_BALL", 5, false }, -- 23
|
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
|
{ "face_object", 1, "up" },
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
|
{ "face_object", 5, "down" },
|
||||||
{ "jump", 32 }, -- 26
|
{ "show_text", "_OaksLabOakIHaveARequestText" },
|
||||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
|
{ "face_object", 1, "up" },
|
||||||
{ "jump", 32 }, -- 28
|
{ "face_object", 5, "down" },
|
||||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
|
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||||
{ "jump", 32 }, -- 30
|
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
|
{ "play_sound", "Get_Key_Item" },
|
||||||
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||||
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||||
|
{ "face_object", 1, "up" },
|
||||||
|
{ "face_object", 5, "down" },
|
||||||
|
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
|
||||||
|
{ "face_object", 1, "right" },
|
||||||
|
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||||
|
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||||
|
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||||
|
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||||
|
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetRival" },
|
||||||
|
{ "move_npc_to", 1, 4, 7 },
|
||||||
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||||
|
{ "play_music", "Music_OaksLab" },
|
||||||
|
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
|
||||||
|
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
|
||||||
|
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
|
||||||
|
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "raise_young" },
|
||||||
|
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "pre_lab_battle" },
|
||||||
|
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||||
|
{ "jump_if_true", "can_fight" },
|
||||||
|
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "can_fight" },
|
||||||
|
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "around_world" },
|
||||||
|
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "give_balls" },
|
||||||
|
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||||
|
{ "jump_if_true", "come_see" },
|
||||||
|
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||||
|
{ "give_item", "POKE_BALL", 5, false },
|
||||||
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||||
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "come_see" },
|
||||||
|
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||||
},
|
},
|
||||||
|
|
||||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||||
@@ -107,47 +163,76 @@ return {
|
|||||||
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
|
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
|
||||||
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
|
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
|
||||||
|
|
||||||
|
-- Talking to the rival only ever prints a line: the lab battle is a
|
||||||
|
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
|
||||||
|
-- see onStep below), never a talk action. The handler used to fall
|
||||||
|
-- through from the "looks stronger" taunt straight into start_battle,
|
||||||
|
-- so talking to Blue at the table launched the rival fight before the
|
||||||
|
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
|
||||||
|
-- OaksLabText8 branches text only:
|
||||||
|
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
|
||||||
|
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
|
||||||
|
-- line; normally unreachable since the rival is hidden after the
|
||||||
|
-- lab battle in OaksLabRivalEndBattleScript)
|
||||||
|
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
|
||||||
TEXT_OAKSLAB_RIVAL = {
|
TEXT_OAKSLAB_RIVAL = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" },
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||||
{ "jump_if_false", 21 }, -- 3
|
{ "jump_if_false", "pre_starter" },
|
||||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||||
{ "jump_if_true", 19 }, -- 5
|
{ "jump_if_true", "after_battle" },
|
||||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
-- has a starter, has not fought yet: just the taunt. The battle is
|
||||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
-- the coordinate trigger in onStep, not this talk (#219)
|
||||||
{ "jump_if_false", 11 }, -- 8
|
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
|
||||||
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
|
{ "jump", "end" },
|
||||||
{ "jump", 16 }, -- 10
|
|
||||||
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
|
{ "label", "after_battle" },
|
||||||
{ "jump_if_false", 15 }, -- 12
|
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
|
||||||
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
{ "jump", "end" },
|
||||||
{ "jump", 16 }, -- 14
|
|
||||||
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
|
||||||
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
|
-- Oak has already escorted you in (three balls on the table) -> he
|
||||||
{ "heal_party" }, -- 16
|
-- waves you on to choose; not yet escorted (very early game) -> the
|
||||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
|
-- "Gramps isn't around" line. #218
|
||||||
{ "jump", 23 }, -- 18
|
{ "label", "pre_starter" },
|
||||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
|
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
|
||||||
{ "jump", "end" }, -- 20
|
{ "jump_if_false", "gramps_gone" },
|
||||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
|
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
|
||||||
{ "jump", "end" }, -- 22
|
{ "jump", "end" },
|
||||||
-- win: sulk text then exit; loss: Rival1WinText already played in
|
|
||||||
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
|
{ "label", "gramps_gone" },
|
||||||
{ "jump_if_false", 25 }, -- 23
|
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
|
||||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
|
|
||||||
{ "move_npc_to", 1, 4, 11 }, -- 25
|
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
-- Saves that got the Pokédex before #106 never wrote objectToggles for
|
||||||
|
-- the table sprites; re-entering the lab applies the same HideObject
|
||||||
|
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||||
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
|
||||||
|
end,
|
||||||
|
|
||||||
-- Oak stops you leaving without a starter; the rival stops you on
|
-- Oak stops you leaving without a starter; the rival stops you on
|
||||||
-- the way out for the first battle (scripts/OaksLab.asm
|
-- the way out for the first battle (scripts/OaksLab.asm
|
||||||
-- OaksLabScript8 / OaksLabRivalChallenge)
|
-- OaksLabScript8 / OaksLabRivalChallenge)
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
local flags = game.save.flags
|
local flags = game.save.flags
|
||||||
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
|
-- Oak stops you leaving without a starter at the bookshelf row (cell
|
||||||
|
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
|
||||||
|
-- challenge just below uses (scripts/OaksLab.asm, both gated on
|
||||||
|
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
|
||||||
|
-- corridor length later on the exit mat. At y>=6 only the x=4,5
|
||||||
|
-- corridor is walkable and the up-push keeps the player above y=7, so
|
||||||
|
-- this only ever fires at y=6 in the corridor -- matching the rival
|
||||||
|
-- trigger's shape. (#232)
|
||||||
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
||||||
and y == 11 and (x == 4 or x == 5) then
|
and y >= 6 then
|
||||||
ow.runner:run({
|
ow.runner:run({
|
||||||
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
||||||
{ "move_player", "up", 1 },
|
{ "move_player", "up", 1 },
|
||||||
@@ -186,10 +271,18 @@ return {
|
|||||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||||
table.insert(rows, { "heal_party" })
|
table.insert(rows, { "heal_party" })
|
||||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||||
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
|
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
|
||||||
-- already shown in-battle via Rival1WinText)
|
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
|
||||||
|
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
|
||||||
|
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
|
||||||
|
-- marches out. A loss skips only the gloat (that taunt was already
|
||||||
|
-- shown in-battle via Rival1WinText), never the exit line (#231). The
|
||||||
|
-- jump_if_false convergence point is the exit line: base+6 indexes the
|
||||||
|
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
|
||||||
|
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
|
||||||
table.insert(rows, { "jump_if_false", base + 6 })
|
table.insert(rows, { "jump_if_false", base + 6 })
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||||
|
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
ow.runner:run(rows, { npc = rival })
|
ow.runner:run(rows, { npc = rival })
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
|
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
|
||||||
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
|
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
|
||||||
-- "you should rest" / "looking great" dialogue. The intro "wake up"
|
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
|
||||||
-- branch is tied to the unported intro cutscene, so the heal path is used.
|
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
|
||||||
|
|
||||||
return {
|
return {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_REDSHOUSE1F_MOM = {
|
TEXT_REDSHOUSE1F_MOM = {
|
||||||
{ "face_player" },
|
{ "face_player" }, -- 1
|
||||||
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||||
{ "heal_party" },
|
{ "jump_if_true", 6 }, -- 3
|
||||||
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
|
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
|
||||||
|
{ "jump", "end" }, -- 5
|
||||||
|
-- RedsHouse1FMomHealScript
|
||||||
|
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
|
||||||
|
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
|
||||||
|
{ "heal_party" }, -- 8
|
||||||
|
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
|
||||||
|
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
|
||||||
|
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
|
|||||||
-- StartSimulatingJoypadStates walks the player to the counter
|
-- StartSimulatingJoypadStates walks the player to the counter
|
||||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||||
|
-- never presses A.
|
||||||
|
--
|
||||||
|
-- The RLE list is consumed back to front: DecodeRLEList fills forward
|
||||||
|
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
|
||||||
|
-- Index with the count, and home/overworld.asm decrements that index
|
||||||
|
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
|
||||||
|
-- and the walk ends facing the clerk. Replaying the list front to back
|
||||||
|
-- put the player at the counter facing up instead (#235). The player
|
||||||
-- never presses A. This matters beyond convenience: the parcel gates
|
-- never presses A. This matters beyond convenience: the parcel gates
|
||||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||||
-- it on entry rather than letting you walk out without it.
|
-- it on entry rather than letting you walk out without it.
|
||||||
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
|
|||||||
if not f.EVENT_GOT_STARTER then return end
|
if not f.EVENT_GOT_STARTER then return end
|
||||||
ow:queueScript({
|
ow:queueScript({
|
||||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||||
{ "move_player", "left", 1 },
|
|
||||||
{ "move_player", "up", 2 },
|
{ "move_player", "up", 2 },
|
||||||
|
{ "move_player", "left", 1 },
|
||||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||||
@@ -86,6 +94,27 @@ M.VIRIDIAN_CITY = {
|
|||||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
|
||||||
|
-- but was never standing here when it fired: a vanilla .sav imported
|
||||||
|
-- through src/save_convert, whose codec does not model pokered's
|
||||||
|
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
|
||||||
|
-- both objects fall back to their compiled-in defaults -- the sleeper ON
|
||||||
|
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
|
||||||
|
-- still lying across the north path (#234).
|
||||||
|
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
|
||||||
|
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
|
||||||
|
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
|
||||||
|
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
|
||||||
|
-- re-applies the same script's other two HideObjects on entry (#106).
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||||
|
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||||
|
end,
|
||||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
-- 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)
|
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||||
@@ -222,17 +251,55 @@ M.BILLS_HOUSE = {
|
|||||||
-- repair saves that already got the ticket under the old collapsed
|
-- repair saves that already got the ticket under the old collapsed
|
||||||
-- script (the monster never hidden, human Bill never shown)
|
-- script (the monster never hidden, human Bill never shown)
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_GOT_SS_TICKET
|
local flags = game.save.flags
|
||||||
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
if flags.EVENT_GOT_SS_TICKET
|
||||||
|
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { game = game, save = game.save, overworld = ow }
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||||
game.save.flags.EVENT_MET_BILL = true
|
flags.EVENT_MET_BILL = true
|
||||||
game.save.flags.EVENT_MET_BILL_2 = true
|
flags.EVENT_MET_BILL_2 = true
|
||||||
end
|
end
|
||||||
|
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
|
||||||
|
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
|
||||||
|
-- load still matches the toggles if the pool was rebuilt.
|
||||||
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Route 25 outside Bill's house (scripts/Route25.asm
|
||||||
|
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
|
||||||
|
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
|
||||||
|
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.ROUTE_25 = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local flags = game.save.flags
|
||||||
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
if not flags.EVENT_MET_BILL_2 then
|
||||||
|
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not flags.EVENT_GOT_SS_TICKET then return end
|
||||||
|
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
|
||||||
|
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
|
||||||
|
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,25 +323,40 @@ M.VERMILION_CITY = {
|
|||||||
end,
|
end,
|
||||||
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
||||||
-- the unguarded cell (18,30) just west of the sailor leads straight
|
-- 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
|
-- onto the dock warp. Stepping onto it facing down always runs the
|
||||||
-- ticket-checked (and turned back once the ship has sailed) without
|
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
|
||||||
-- the player ever pressing A. The sailor himself never disappears.
|
-- ticket while the ship is still docked lets the player continue --
|
||||||
|
-- otherwise they are walked back up. The sailor himself never hides.
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if x ~= 18 or y ~= 30 then return false end
|
if x ~= 18 or y ~= 30 then return false end
|
||||||
if ow.player.facing ~= "down" then return false end
|
if ow.player.facing ~= "down" then return false end
|
||||||
local f = game.save.flags
|
local Flags = require("src.script.Flags")
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
if f.EVENT_SS_ANNE_LEFT then
|
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
|
||||||
|
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
|
||||||
|
if shipLeft then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
|
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||||
|
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
|
||||||
|
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
|
||||||
|
-- (walk back). Returning true while the box is up also blocks the
|
||||||
|
-- dock warp on this step.
|
||||||
|
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||||
|
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||||
|
if hasTicket then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
ask .. "\f"
|
||||||
|
.. (t._VermilionCitySailor1FlashedTicketText
|
||||||
|
or "{PLAYER} flashed\nthe S.S.TICKET!")))
|
||||||
|
return true
|
||||||
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
|
ask .. "\f"
|
||||||
.. "\f"
|
|
||||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||||
or "You need a ticket\nto get aboard."),
|
or "You need a ticket\nto get aboard."),
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
@@ -288,7 +370,7 @@ M.VERMILION_CITY = {
|
|||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||||
{ "jump_if_true", 11 }, -- 3
|
{ "jump_if_true", 11 }, -- 3
|
||||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
|
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||||
{ "check_item", "S_S_TICKET" }, -- 5
|
{ "check_item", "S_S_TICKET" }, -- 5
|
||||||
{ "jump_if_false", 9 }, -- 6
|
{ "jump_if_false", 9 }, -- 6
|
||||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||||
@@ -318,18 +400,21 @@ M.SS_ANNE_2F = {
|
|||||||
|
|
||||||
M.SS_ANNE_CAPTAINS_ROOM = {
|
M.SS_ANNE_CAPTAINS_ROOM = {
|
||||||
talk = {
|
talk = {
|
||||||
|
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
|
||||||
|
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
|
||||||
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
||||||
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
||||||
{ "jump_if_true", 9 }, -- 2
|
{ "jump_if_true", 10 }, -- 2
|
||||||
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
|
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
|
{ "play_once", "Music_PkmnHealed" }, -- 4
|
||||||
|
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
|
||||||
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
|
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
|
||||||
-- fills wStringBuffer; the received text reads it)
|
-- fills wStringBuffer; the received text reads it)
|
||||||
{ "give_item", "HM_CUT", 1, false }, -- 5
|
{ "give_item", "HM_CUT", 1, false }, -- 6
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
|
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
|
||||||
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
|
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
|
||||||
{ "jump", 10 }, -- 8
|
{ "jump", 11 }, -- 9
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
|
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -339,7 +424,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
|
|||||||
-- MrFujisHouse.asm; the teleport back to his house is a warp)
|
-- MrFujisHouse.asm; the teleport back to his house is a warp)
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- scripts/PokemonTower7F.asm: after each Rocket loses,
|
||||||
|
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
|
||||||
|
-- prints its AfterBattle text (DisplayTextID), then
|
||||||
|
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
|
||||||
|
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
|
||||||
|
-- (HideObject). Without this the beaten grunts stood in the corridor
|
||||||
|
-- forever (#200).
|
||||||
|
local POKEMON_TOWER_7F_ROCKETS = {
|
||||||
|
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
|
||||||
|
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
|
||||||
|
after = "_PokemonTower7FRocket1AfterBattleText" },
|
||||||
|
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
|
||||||
|
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
|
||||||
|
after = "_PokemonTower7FRocket2AfterBattleText" },
|
||||||
|
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
|
||||||
|
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
|
||||||
|
after = "_PokemonTower7FRocket3AfterBattleText" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
|
||||||
|
-- at the engagement (both the talk and the sight walk-up leave the player on
|
||||||
|
-- the tile the vanilla table indexes). Each list is that entry's
|
||||||
|
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
|
||||||
|
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
|
||||||
|
local POKEMON_TOWER_7F_EXITS = {
|
||||||
|
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
|
||||||
|
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
|
||||||
|
["10,11"] = { "down", "right", "down", "down", "down", "down" },
|
||||||
|
["11,11"] = { "down", "down", "down", "down", "down" },
|
||||||
|
["12,11"] = { "down", "down", "down", "down", "down" },
|
||||||
|
},
|
||||||
|
[2] = { -- ROCKET2 @ (12,9), faces LEFT
|
||||||
|
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
|
||||||
|
["11,9"] = { "down", "down", "down", "left", "down", "down" },
|
||||||
|
["10,9"] = { "down", "down", "down", "down", "down" },
|
||||||
|
["9,9"] = { "down", "down", "down", "down", "down" },
|
||||||
|
},
|
||||||
|
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
|
||||||
|
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
|
||||||
|
["10,7"] = { "down", "down", "down", "down", "down" },
|
||||||
|
["11,7"] = { "down", "down", "down", "down", "down" },
|
||||||
|
["12,7"] = { "down", "down", "down", "down", "down" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
|
||||||
|
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
|
||||||
|
-- side) safe -- it still clears the corridor and despawns the grunt.
|
||||||
|
local POKEMON_TOWER_7F_EXIT_FALLBACK =
|
||||||
|
{ "down", "down", "down", "down", "down" }
|
||||||
|
|
||||||
M.POKEMON_TOWER_7F = {
|
M.POKEMON_TOWER_7F = {
|
||||||
|
-- Repair saves that beat a grunt before the exit walk was ported: in
|
||||||
|
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
|
||||||
|
-- still gone on re-entry. hide_object below writes objectToggles for new
|
||||||
|
-- wins; this hides any grunt whose BEAT flag is already set.
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||||
|
if game.save.flags[r.beat] then
|
||||||
|
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
-- runVictoryHook fires after every trainer win on the map (both the sight
|
||||||
|
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
|
||||||
|
-- this walks off whichever grunt was just beaten but is still standing.
|
||||||
|
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
|
||||||
|
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
|
||||||
|
-- walk, despawn.
|
||||||
|
onVictory = function(game, ow)
|
||||||
|
if ow.runner:isRunning() then return end
|
||||||
|
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||||
|
local npc = ow:npcByIndex(r.index)
|
||||||
|
if npc and game.save.flags[r.beat] then
|
||||||
|
local key = ow.player.cellX .. "," .. ow.player.cellY
|
||||||
|
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
|
||||||
|
or POKEMON_TOWER_7F_EXIT_FALLBACK
|
||||||
|
ow.runner:run({
|
||||||
|
{ "show_text", r.after }, -- DisplayTextID
|
||||||
|
{ "walk_npc", r.index, dirs }, -- MoveSprite
|
||||||
|
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
|
||||||
|
}, { npc = npc })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_POKEMONTOWER7F_MR_FUJI = {
|
TEXT_POKEMONTOWER7F_MR_FUJI = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
@@ -560,7 +731,8 @@ M.SILPH_CO_11F = {
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
||||||
-- drops a boulder down to the 2F switch.
|
-- drops a boulder down to the 2F switch, and also dungeon-warps the
|
||||||
|
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
local function boulderAt(ow, x, y)
|
local function boulderAt(ow, x, y)
|
||||||
@@ -568,11 +740,20 @@ local function boulderAt(ow, x, y)
|
|||||||
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
|
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
|
||||||
|
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
|
||||||
|
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
|
||||||
|
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
|
||||||
|
-- map record in Game.data, so the port has to put the closed block back
|
||||||
|
-- itself or a solved barrier stays open for the rest of the session (#258).
|
||||||
|
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
|
||||||
|
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
|
||||||
|
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
|
||||||
|
-- OverworldState:setMap.
|
||||||
M.VICTORY_ROAD_1F = {
|
M.VICTORY_ROAD_1F = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
|
ow:replaceBlock(4, 6,
|
||||||
ow:replaceBlock(4, 6, 0x1D)
|
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
onBoulderMoved = function(game, ow, npc)
|
onBoulderMoved = function(game, ow, npc)
|
||||||
if npc.cellX == 17 and npc.cellY == 13
|
if npc.cellX == 17 and npc.cellY == 13
|
||||||
@@ -585,12 +766,14 @@ M.VICTORY_ROAD_1F = {
|
|||||||
|
|
||||||
M.VICTORY_ROAD_2F = {
|
M.VICTORY_ROAD_2F = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
|
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
|
||||||
ow:replaceBlock(3, 4, 0x15)
|
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
|
||||||
end
|
-- again the next time you climb down (#258)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
|
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
|
||||||
ow:replaceBlock(11, 7, 0x1D)
|
ow:replaceBlock(3, 4,
|
||||||
end
|
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
|
||||||
|
ow:replaceBlock(11, 7,
|
||||||
|
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
|
||||||
end,
|
end,
|
||||||
onBoulderMoved = function(game, ow, npc)
|
onBoulderMoved = function(game, ow, npc)
|
||||||
if npc.cellX == 1 and npc.cellY == 16
|
if npc.cellX == 1 and npc.cellY == 16
|
||||||
@@ -608,9 +791,8 @@ M.VICTORY_ROAD_2F = {
|
|||||||
|
|
||||||
M.VICTORY_ROAD_3F = {
|
M.VICTORY_ROAD_3F = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
|
ow:replaceBlock(3, 5,
|
||||||
ow:replaceBlock(3, 5, 0x1D)
|
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
onBoulderMoved = function(game, ow, npc)
|
onBoulderMoved = function(game, ow, npc)
|
||||||
if npc.cellX == 3 and npc.cellY == 5
|
if npc.cellX == 3 and npc.cellY == 5
|
||||||
@@ -618,14 +800,37 @@ M.VICTORY_ROAD_3F = {
|
|||||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
|
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
|
||||||
ow:replaceBlock(3, 5, 0x1D)
|
ow:replaceBlock(3, 5, 0x1D)
|
||||||
end
|
end
|
||||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2
|
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
|
||||||
if npc.cellX == 23 and npc.cellY == 15 then
|
-- VictoryRoad3FDefaultScript .handle_hole gates the swap on
|
||||||
|
-- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then
|
||||||
|
-- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject
|
||||||
|
-- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are
|
||||||
|
-- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3
|
||||||
|
-- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER"
|
||||||
|
-- matched no object_event name, so the toggle landed under a key
|
||||||
|
-- objectVisible never reads: harmless only while nothing hid that
|
||||||
|
-- boulder, and fatal once Route 23's reset does (#258).
|
||||||
|
if npc.cellX == 23 and npc.cellY == 15
|
||||||
|
and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then
|
||||||
|
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { save = game.save, overworld = ow, game = game }
|
local ctx = { save = game.save, overworld = ow, game = game }
|
||||||
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
|
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
|
||||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
||||||
|
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
|
||||||
|
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
|
||||||
|
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
|
||||||
|
-- fall is onStep, not a collision block.
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if x == 23 and y == 15 then
|
||||||
|
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -638,6 +843,16 @@ M.VICTORY_ROAD_3F = {
|
|||||||
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
|
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
|
||||||
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
|
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
|
||||||
-- forever (postgame gates like the Cerulean cave guard read it).
|
-- forever (postgame gates like the Cerulean cave guard read it).
|
||||||
|
--
|
||||||
|
-- pokered forces the fight on map entry: Agatha's victory arms
|
||||||
|
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
|
||||||
|
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
|
||||||
|
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
|
||||||
|
-- The rival object has no trainer header / sight range, so without that
|
||||||
|
-- entrance script the player can walk past (issue #99). We arm on the
|
||||||
|
-- run flag instead of Agatha's victory bit: same observable effect for
|
||||||
|
-- first clear and Indigo rematches, without a cross-map script pointer.
|
||||||
|
--
|
||||||
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
|
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
|
||||||
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
|
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
|
||||||
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
|
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
|
||||||
@@ -646,43 +861,69 @@ M.VICTORY_ROAD_3F = {
|
|||||||
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
|
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
|
||||||
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
|
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
|
||||||
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
|
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
|
||||||
|
local championsRoomRivalScript = {
|
||||||
|
{ "face_player" }, -- 1
|
||||||
|
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||||
|
{ "jump_if_true", 25 }, -- 3 past end
|
||||||
|
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||||
|
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||||
|
{ "jump_if_false", 25 }, -- 6 past end
|
||||||
|
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||||
|
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||||
|
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||||
|
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||||
|
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||||
|
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||||
|
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||||
|
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||||
|
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||||
|
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||||
|
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
|
||||||
|
{ "show_text", "_ChampionsRoomOakText" }, -- 11
|
||||||
|
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
|
||||||
|
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
|
||||||
|
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||||
|
{ "face_object", 1, "left" }, -- 14
|
||||||
|
{ "face_object", 2, "down" }, -- 15
|
||||||
|
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
|
||||||
|
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||||
|
{ "face_object", 2, "right" }, -- 17
|
||||||
|
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||||
|
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||||
|
{ "face_object", 2, "down" }, -- 19
|
||||||
|
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
|
||||||
|
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
|
||||||
|
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
|
||||||
|
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||||
|
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||||
|
{ "set_field", "pendingHallOfFame", true }, -- 23
|
||||||
|
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
|
||||||
|
}
|
||||||
|
|
||||||
M.CHAMPIONS_ROOM = {
|
M.CHAMPIONS_ROOM = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
|
||||||
|
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
|
||||||
|
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
|
||||||
|
if ow.player.cellY < 7 then return end
|
||||||
|
local rival
|
||||||
|
for _, npc in ipairs(ow.npcs) do
|
||||||
|
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
|
||||||
|
rival = npc
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
|
||||||
|
-- separately so talk-script jump indices stay 1-based as written).
|
||||||
|
ow:queueScript({
|
||||||
|
{ "move_player", "up", 1 },
|
||||||
|
{ "move_player", "right", 1 },
|
||||||
|
{ "move_player", "up", 3 },
|
||||||
|
})
|
||||||
|
ow:queueScript(championsRoomRivalScript, { npc = rival })
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_CHAMPIONSROOM_RIVAL = {
|
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
|
||||||
{ "face_player" }, -- 1
|
|
||||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
|
||||||
{ "jump_if_true", 24 }, -- 3
|
|
||||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
|
||||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
|
||||||
{ "jump_if_false", 24 }, -- 6
|
|
||||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
|
||||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
|
||||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
|
||||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
|
||||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
|
||||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
|
||||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
|
||||||
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
|
|
||||||
{ "show_text", "_ChampionsRoomOakText" }, -- 10
|
|
||||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
|
|
||||||
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
|
|
||||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
|
||||||
{ "face_object", 1, "left" }, -- 13
|
|
||||||
{ "face_object", 2, "down" }, -- 14
|
|
||||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
|
|
||||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
|
||||||
{ "face_object", 2, "right" }, -- 16
|
|
||||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
|
|
||||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
|
||||||
{ "face_object", 2, "down" }, -- 18
|
|
||||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
|
|
||||||
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
|
|
||||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
|
|
||||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
|
||||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
|
||||||
{ "set_field", "pendingHallOfFame", true }, -- 22
|
|
||||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,10 +994,14 @@ M.HALL_OF_FAME = {
|
|||||||
-- that teleports back to the new-game bedroom spawn (special_warps.asm
|
-- that teleports back to the new-game bedroom spawn (special_warps.asm
|
||||||
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
|
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
|
||||||
-- convenience -- there is no such prompt in the original ROM.
|
-- convenience -- there is no such prompt in the original ROM.
|
||||||
|
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
|
||||||
|
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
|
||||||
TEXT_HALLOFFAME_PC = {
|
TEXT_HALLOFFAME_PC = {
|
||||||
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
|
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
|
||||||
{ "jump_if_false", "end" }, -- 2 NO: back away
|
{ "jump_if_false", "end" }, -- 2 NO: back away
|
||||||
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room
|
{ "heal_party" }, -- 3
|
||||||
|
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
|
||||||
|
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -769,35 +1014,72 @@ M.HALL_OF_FAME = {
|
|||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
|
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
|
||||||
|
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
|
||||||
TEXT_CERULEANCITY_RIVAL = {
|
TEXT_CERULEANCITY_RIVAL = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
|
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
|
||||||
{ "jump_if_true", 9 }, -- 3
|
{ "jump_if_true", 13 }, -- 3
|
||||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||||
{ "jump_if_false", 10 }, -- 6
|
{ "jump_if_false", "end" }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||||
{ "jump", 10 }, -- 9
|
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||||
|
{ "walk_npc", 1, { "right", "down", "down", "down",
|
||||||
|
"down", "down", "down" } }, -- 10
|
||||||
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||||
|
{ "jump", "end" }, -- 12
|
||||||
|
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
|
||||||
|
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
|
||||||
|
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
|
||||||
|
-- at (15,5) is the ON_LEFT case.
|
||||||
|
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
|
||||||
|
{ "right", "down", "down", "right", "down", "down", "right", "right" }
|
||||||
|
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
|
||||||
|
{ "down", "down", "right", "right", "right", "right", "down", "down" }
|
||||||
|
|
||||||
|
local function pokemonTower2FRivalScript(playerX)
|
||||||
|
local exitDirs = (playerX == 15)
|
||||||
|
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
|
||||||
|
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
|
||||||
|
return {
|
||||||
|
{ "face_player" }, -- 1
|
||||||
|
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||||
|
{ "jump_if_true", 12 }, -- 3
|
||||||
|
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||||
|
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||||
|
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||||
|
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||||
|
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||||
|
{ "walk_npc", 1, exitDirs }, -- 9
|
||||||
|
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||||
|
{ "jump", "end" }, -- 11
|
||||||
|
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
M.POKEMON_TOWER_2F = {
|
M.POKEMON_TOWER_2F = {
|
||||||
|
rivalScript = pokemonTower2FRivalScript,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_POKEMONTOWER2F_RIVAL = {
|
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
|
||||||
{ "face_player" }, -- 1
|
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
|
||||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
{ npc = npc, onDone = done })
|
||||||
{ "jump_if_true", 10 }, -- 3
|
end,
|
||||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
|
||||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
|
||||||
{ "jump_if_false", 11 }, -- 6
|
|
||||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
|
||||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
|
||||||
{ "jump", 11 }, -- 9
|
|
||||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
-- Saves that beat him before the exit walk was ported still have the
|
||||||
|
-- flag but a visible rival; hide on enter like BillsHouse repairs.
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||||
|
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
|
||||||
|
end,
|
||||||
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
||||||
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
|
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
|
||||||
-- waits to be talked to
|
-- waits to be talked to
|
||||||
@@ -809,8 +1091,7 @@ M.POKEMON_TOWER_2F = {
|
|||||||
if not rival then return false end
|
if not rival then return false end
|
||||||
ow.player.facing = (x == 15) and "left" or "up"
|
ow.player.facing = (x == 15) and "left" or "up"
|
||||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||||
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
|
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
|
||||||
{ npc = rival })
|
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone)
|
|||||||
ow:engageTrainer(nerd, onDone)
|
ow:engageTrainer(nerd, onDone)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function mtMoonFossil(itemId, otherName)
|
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then
|
||||||
|
-- UP (MoveRight falls through into MoveUp); near-helix walks UP only.
|
||||||
|
local function mtMoonNerdWalk(px, py, itemId)
|
||||||
|
if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then
|
||||||
|
return { "right", "up" }
|
||||||
|
end
|
||||||
|
if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then
|
||||||
|
return { "up" }
|
||||||
|
end
|
||||||
|
return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd +
|
||||||
|
-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the
|
||||||
|
-- other, "All right. Then this is mine!", hide the other.
|
||||||
|
local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||||
return function(game, ow, npc, done)
|
return function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local t = game.data.text
|
||||||
if game.save.flags.EVENT_GOT_A_FOSSIL then
|
local flags = game.save.flags
|
||||||
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
|
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
|
||||||
|
done()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
|
|
||||||
if not superNerdBeaten(ow) then
|
if not superNerdBeaten(ow) then
|
||||||
engageSuperNerd(game, ow, done)
|
engageSuperNerd(game, ow, done)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local name = game.data.items[itemId].name
|
local want = (itemId == "DOME_FOSSIL")
|
||||||
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
|
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
|
||||||
if not yes then done() return end
|
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
|
||||||
game.save.inventory[itemId] = 1
|
if not yes then done() return end
|
||||||
game.save.flags.EVENT_GOT_A_FOSSIL = true
|
if not require("src.inventory.Bag").add(game.save, itemId, 1) then
|
||||||
local Commands = require("src.script.Commands")
|
|
||||||
local ctx = { save = game.save, overworld = ow, game = game }
|
|
||||||
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
|
|
||||||
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("%s got the\n%s!"):format(game.save.player.name, name), done))
|
t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
|
||||||
end))
|
done))
|
||||||
end))
|
return
|
||||||
|
end
|
||||||
|
local idef = game.data.items[itemId]
|
||||||
|
game.stringBuffer = idef and idef.name or itemId
|
||||||
|
require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||||
|
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._MtMoonB2FReceivedFossilText
|
||||||
|
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||||
|
function()
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.hide_object(
|
||||||
|
{ save = game.save, overworld = ow, game = game },
|
||||||
|
"MT_MOON_B2F", npc.def.name)
|
||||||
|
flags[gotFlag] = true
|
||||||
|
ow.runner:run({
|
||||||
|
{ "walk_npc", 1, dirs },
|
||||||
|
{ "text_opts", { auto = true } },
|
||||||
|
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||||
|
{ "play_sound", "Get_Key_Item" },
|
||||||
|
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||||
|
}, { onDone = done })
|
||||||
|
end))
|
||||||
|
end }))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -486,13 +520,17 @@ M.MT_MOON_B2F = {
|
|||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
|
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
|
||||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
|
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
|
||||||
|
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
|
||||||
|
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
|
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
|
||||||
-- Y50, once; declining at the rope walks you back out.
|
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
|
||||||
|
-- the exhibit rope they crossed heading north (#151); the museum floor has no
|
||||||
|
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
|
||||||
local function museumClerk(game, ow, done, onDecline)
|
local function museumClerk(game, ow, done, onDecline)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
@@ -527,7 +565,7 @@ M.MUSEUM_1F = {
|
|||||||
if y == 4 and (x == 9 or x == 10)
|
if y == 4 and (x == 9 or x == 10)
|
||||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
museumClerk(game, ow, nil, function()
|
museumClerk(game, ow, nil, function()
|
||||||
ow:scriptMove(ow.player, "right", 1)
|
ow:scriptMove(ow.player, "down", 1)
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -719,73 +757,174 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
|
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
|
||||||
-- step; the fee is ¥100 plus ¥100 per level gained.
|
-- step; the fee is ¥100 plus ¥100 per level gained.
|
||||||
|
--
|
||||||
|
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||||
|
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||||
|
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
||||||
|
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here --
|
||||||
|
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function fillDaycareText(s, subs)
|
||||||
|
s = s:gsub("{PLAYER}", subs.player or "")
|
||||||
|
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
|
||||||
|
-- extractor NUM spans may include flags after a comma; keep the name
|
||||||
|
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
|
||||||
|
return tostring(subs[name] or "0")
|
||||||
|
end)
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
M.DAYCARE = {
|
M.DAYCARE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local dc = game.save.daycare
|
local dc = game.save.daycare
|
||||||
|
local playerName = game.save.player and game.save.player.name or "RED"
|
||||||
|
|
||||||
|
local function monName(mon)
|
||||||
|
local def = game.data.pokemon[mon.species]
|
||||||
|
return mon.nickname or (def and def.name) or mon.species
|
||||||
|
end
|
||||||
|
|
||||||
if dc and dc.mon then
|
if dc and dc.mon then
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
local mon = dc.mon
|
local mon = dc.mon
|
||||||
local def = game.data.pokemon[mon.species]
|
local def = game.data.pokemon[mon.species]
|
||||||
mon.exp = mon.exp + (dc.steps or 0)
|
-- Apply deferred step-exp once (OverworldController only bumps
|
||||||
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
|
-- daycare.steps). Clearing prevents double-count on re-talk.
|
||||||
local fee = 100 + (newLevel - mon.level) * 100
|
mon.exp = (mon.exp or 0) + (dc.steps or 0)
|
||||||
local grew = newLevel > mon.level
|
dc.steps = 0
|
||||||
mon.level = newLevel
|
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
|
||||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
-- must survive a declined retrieve. Fall back to mon.level for
|
||||||
mon.hp = mon.stats.hp
|
-- older saves that predate the field.
|
||||||
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
|
if dc.depositLevel == nil then dc.depositLevel = mon.level end
|
||||||
or "Back already?"
|
local startLevel = dc.depositLevel
|
||||||
game.stack:push(TextBox.new(game,
|
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
|
||||||
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
|
if newLevel >= 100 then
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
newLevel = 100
|
||||||
if yes and game.save.money >= fee then
|
if def then
|
||||||
|
mon.exp = Growth.expForLevel(def.growthRate, 100)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local levelsGrown = math.max(0, newLevel - startLevel)
|
||||||
|
local fee = 100 + levelsGrown * 100
|
||||||
|
local name = monName(mon)
|
||||||
|
local subs = {
|
||||||
|
player = playerName,
|
||||||
|
wNameBuffer = name,
|
||||||
|
wDayCareMonName = name,
|
||||||
|
wDayCareNumLevelsGrown = levelsGrown,
|
||||||
|
wDayCareTotalCost = fee,
|
||||||
|
}
|
||||||
|
local statusText = levelsGrown > 0
|
||||||
|
and (t._DaycareGentlemanMonHasGrownText
|
||||||
|
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
|
||||||
|
or (t._DaycareGentlemanMonNeedsMoreTimeText
|
||||||
|
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
|
||||||
|
|
||||||
|
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
|
||||||
|
if #game.save.party >= Party.MAX then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._DaycareGentlemanNoRoomForMonText
|
||||||
|
or "You have no room\nfor this POKéMON!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
fillDaycareText(
|
||||||
|
t._DaycareGentlemanOweMoneyText
|
||||||
|
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
|
||||||
|
subs),
|
||||||
|
nil, { choice = function(yes)
|
||||||
|
if not yes then
|
||||||
|
-- .leaveMonInDayCare: revert any transient level bump
|
||||||
|
mon.level = startLevel
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
|
||||||
|
.. (t._DaycareGentlemanComeAgainText or "come again."),
|
||||||
|
done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if (game.save.money or 0) < fee then
|
||||||
|
mon.level = startLevel
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._DaycareGentlemanNotEnoughMoneyText
|
||||||
|
or "Hey, you don't\nhave enough ¥!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
game.save.money = game.save.money - fee
|
game.save.money = game.save.money - fee
|
||||||
|
mon.level = newLevel
|
||||||
|
if def then
|
||||||
|
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||||
|
mon.hp = mon.stats.hp
|
||||||
|
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
Pokemon.learnMovesFromDayCare(
|
||||||
|
game.data, mon, def, startLevel, newLevel)
|
||||||
|
end
|
||||||
table.insert(game.save.party, mon)
|
table.insert(game.save.party, mon)
|
||||||
game.save.daycare = nil
|
game.save.daycare = nil
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
|
t._DaycareGentlemanHeresYourMonText
|
||||||
else
|
or "Thank you! Here's\nyour POKéMON!", function()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
|
fillDaycareText(
|
||||||
or "Come again!", done))
|
t._DaycareGentlemanGotMonBackText
|
||||||
end
|
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
|
||||||
end))
|
subs), done))
|
||||||
|
end))
|
||||||
|
end }))
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if #game.save.party < 2 then
|
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
"You only have one\nPOKéMON with you!", done))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
|
t._DaycareGentlemanIntroText
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
|
||||||
if not yes then done() return end
|
nil, { choice = function(yes)
|
||||||
local PartyMenu = require("src.ui.PartyMenu")
|
if not yes then
|
||||||
game.stack:push(PartyMenu.new(game, {
|
game.stack:push(TextBox.new(game,
|
||||||
pickOnly = true,
|
t._DaycareGentlemanComeAgainText or "come again.", done))
|
||||||
onSwitch = function(mon)
|
return
|
||||||
for i, m in ipairs(game.save.party) do
|
end
|
||||||
if m == mon then table.remove(game.save.party, i) break end
|
if #game.save.party < 2 then
|
||||||
end
|
game.stack:push(TextBox.new(game,
|
||||||
game.save.daycare = { mon = mon, steps = 0 }
|
t._DaycareGentlemanOnlyHaveOneMonText
|
||||||
game.stack:push(TextBox.new(game,
|
or "You only have one\nPOKéMON with you.", done))
|
||||||
t._DaycareGentlemanWillLookAfterMonText or
|
return
|
||||||
"Fine, I'll look\nafter it a while!", done))
|
end
|
||||||
end,
|
game.stack:push(TextBox.new(game,
|
||||||
}))
|
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
|
||||||
end))
|
function()
|
||||||
end))
|
local PartyMenu = require("src.ui.PartyMenu")
|
||||||
|
game.stack:push(PartyMenu.new(game, {
|
||||||
|
pickOnly = true,
|
||||||
|
onSwitch = function(mon)
|
||||||
|
for i, m in ipairs(game.save.party) do
|
||||||
|
if m == mon then table.remove(game.save.party, i) break end
|
||||||
|
end
|
||||||
|
local name = monName(mon)
|
||||||
|
-- depositLevel = wDayCareMonBoxLevel at deposit time
|
||||||
|
game.save.daycare = {
|
||||||
|
mon = mon, steps = 0, depositLevel = mon.level,
|
||||||
|
}
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
fillDaycareText(
|
||||||
|
t._DaycareGentlemanWillLookAfterMonText
|
||||||
|
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
|
||||||
|
{ player = playerName, wNameBuffer = name }),
|
||||||
|
function()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._DaycareGentlemanComeSeeMeInAWhileText
|
||||||
|
or "Come see me in\na while.", done))
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
end))
|
||||||
|
end }))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
|||||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||||
|
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
|
||||||
|
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
|
||||||
|
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
|
||||||
|
-- true from onStep so wild encounters are skipped the same way).
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
local TOWER_5F_PURIFIED = {
|
||||||
|
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
|
||||||
|
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
|
||||||
|
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
|
||||||
|
local TOWER_5F_HEAL = {
|
||||||
|
{ "heal_party" },
|
||||||
|
{ "fade", "out", "white" },
|
||||||
|
{ "wait", 3 },
|
||||||
|
{ "wait", 3 },
|
||||||
|
{ "fade", "in", "white" },
|
||||||
|
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
|
||||||
|
}
|
||||||
|
|
||||||
|
M.POKEMON_TOWER_5F = {
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if not TOWER_5F_PURIFIED[x * 256 + y] then
|
||||||
|
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if ow.runner and ow.runner:isRunning() then return false end
|
||||||
|
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
|
||||||
|
if ow.runner then
|
||||||
|
ow.runner:run(TOWER_5F_HEAL)
|
||||||
|
elseif ow.queueScript then
|
||||||
|
ow:queueScript(TOWER_5F_HEAL)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||||
-- (10,16) until defeated.
|
-- (10,16) until defeated.
|
||||||
@@ -150,18 +194,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
|
|||||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||||
|
|
||||||
local function elevatorWalkOut(ow, floor)
|
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||||
local m, p = ow.map, ow.player
|
-- door (warp id, map id). Shared generated map data, but the car's
|
||||||
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
|
-- warps are only read from inside the car; rides rewrite them again.
|
||||||
-- (warp id, map id): point every exit warp at the picked floor's
|
local function elevatorSetExit(ow, floor)
|
||||||
-- elevator-door warp (the reciprocal warp found while building the
|
if not floor then return end
|
||||||
-- menu). The car map's def is shared generated data, but its own
|
for _, w in ipairs(ow.map.def.warps) do
|
||||||
-- warps are only ever read from inside the car, and this rewrite runs
|
|
||||||
-- on every ride before the walk-out fires, so it is self-correcting.
|
|
||||||
for _, w in ipairs(m.def.warps) do
|
|
||||||
w.destMap = floor.map
|
w.destMap = floor.map
|
||||||
w.destWarp = floor.warpIdx
|
w.destWarp = floor.warpIdx
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function elevatorWalkOut(ow, floor)
|
||||||
|
local m, p = ow.map, ow.player
|
||||||
|
elevatorSetExit(ow, floor)
|
||||||
-- leave by the exit tile under the player (they warped in onto one),
|
-- leave by the exit tile under the player (they warped in onto one),
|
||||||
-- else the nearest
|
-- else the nearest
|
||||||
local door
|
local door
|
||||||
@@ -199,37 +245,68 @@ local function elevatorWalkOut(ow, floor)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function elevatorFloors(elevatorMapId, game)
|
||||||
|
local floors = {}
|
||||||
|
for mapId, def in pairs(game.data.maps) do
|
||||||
|
for i, w in ipairs(def.warps) do
|
||||||
|
if w.destMap == elevatorMapId then
|
||||||
|
-- short floor token pokered actually prints, e.g.
|
||||||
|
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
|
||||||
|
local token = mapId:match("_([^_]+)$") or mapId
|
||||||
|
-- warpIdx: this floor's warp back into the elevator IS the
|
||||||
|
-- warp the car's rewritten exit lands on (the reciprocal
|
||||||
|
-- pair), matching wElevatorWarpMaps' (warp id, map id)
|
||||||
|
table.insert(floors,
|
||||||
|
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
|
||||||
|
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
|
||||||
|
table.sort(floors, function(a, b)
|
||||||
|
return (tonumber(a.token:match("%d+")) or 0) <
|
||||||
|
(tonumber(b.token:match("%d+")) or 0)
|
||||||
|
end)
|
||||||
|
return floors
|
||||||
|
end
|
||||||
|
|
||||||
|
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||||
|
-- Seed a walk-out destination before the menu (or key-gate text):
|
||||||
|
-- entry floor when known, else the first listed floor (1F). Choosing
|
||||||
|
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
||||||
|
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
||||||
|
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||||
|
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||||
|
local exitFloor = floors[1]
|
||||||
|
if fromMapId then
|
||||||
|
for _, f in ipairs(floors) do
|
||||||
|
if f.map == fromMapId then exitFloor = f break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elevatorSetExit(ow, exitFloor)
|
||||||
|
return exitFloor
|
||||||
|
end
|
||||||
|
|
||||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||||
return {
|
return {
|
||||||
onEnter = function(game, ow)
|
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||||
|
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||||
|
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||||
|
-- asserted and hard-crashed (#123).
|
||||||
|
onEnter = function(game, ow, fromMapId)
|
||||||
|
local floors = elevatorFloors(elevatorMapId, game)
|
||||||
|
elevatorSeedExit(ow, floors, fromMapId)
|
||||||
|
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||||
|
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||||
|
-- still seeded above so walking out returns to the entry floor
|
||||||
|
-- instead of the car's ROM default (B1F) -- #90 / #105.
|
||||||
if keyGate and not game.save.inventory[keyGate.item] then
|
if keyGate and not game.save.inventory[keyGate.item] then
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local floors = {}
|
|
||||||
for mapId, def in pairs(game.data.maps) do
|
|
||||||
for i, w in ipairs(def.warps) do
|
|
||||||
if w.destMap == elevatorMapId then
|
|
||||||
-- short floor token pokered actually prints, e.g.
|
|
||||||
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
|
|
||||||
local token = mapId:match("_([^_]+)$") or mapId
|
|
||||||
-- warpIdx: this floor's warp back into the elevator IS the
|
|
||||||
-- warp the car's rewritten exit lands on (the reciprocal
|
|
||||||
-- pair), matching wElevatorWarpMaps' (warp id, map id)
|
|
||||||
table.insert(floors,
|
|
||||||
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
|
|
||||||
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
|
|
||||||
table.sort(floors, function(a, b)
|
|
||||||
return (tonumber(a.token:match("%d+")) or 0) <
|
|
||||||
(tonumber(b.token:match("%d+")) or 0)
|
|
||||||
end)
|
|
||||||
local items = {}
|
local items = {}
|
||||||
for _, f in ipairs(floors) do
|
for _, f in ipairs(floors) do
|
||||||
table.insert(items, { label = f.token, value = f })
|
table.insert(items, { label = f.token, value = f })
|
||||||
@@ -262,7 +339,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
|||||||
end,
|
end,
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||||
-- happens, the player just stays in the car
|
-- happens, the player just stays in the car (exit warps were
|
||||||
|
-- already seeded to the entry floor above)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end,
|
end,
|
||||||
@@ -274,6 +352,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
|||||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
|
||||||
|
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
|
||||||
|
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||||
|
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
|
||||||
|
-- objects; without this talk side-effect the key never appears (#90,
|
||||||
|
-- #105).
|
||||||
|
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
|
||||||
|
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.ROCKET_HIDEOUT_B4F = {
|
||||||
|
talk = {
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
|
||||||
|
if not ow:trainerDefeated(npc) then
|
||||||
|
ow:engageTrainer(npc, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local t = game.data.text
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._RocketHideoutB4FRocket3AfterBattleText
|
||||||
|
or "Oh no! I dropped\nthe LIFT KEY!",
|
||||||
|
function()
|
||||||
|
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
|
||||||
|
-- after the win reveals the ball; later talks only reprint.
|
||||||
|
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
|
||||||
|
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.show_object(
|
||||||
|
{ game = game, save = game.save, overworld = ow },
|
||||||
|
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
|
||||||
|
end
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||||
|
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||||
|
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||||
|
if ow:trainerDefeated(npc)
|
||||||
|
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||||
|
or "I hope we meet\nagain...", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local t = game.data.text
|
||||||
|
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
|
||||||
|
or "So! I must say, I\nam impressed you\ngot here!"
|
||||||
|
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
|
||||||
|
or "WHAT!\nThis cannot be!"
|
||||||
|
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||||
|
or "I hope we meet\nagain..."
|
||||||
|
game.stack:push(TextBox.new(game, impressed, function()
|
||||||
|
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
|
||||||
|
battle.onFinish = function(result)
|
||||||
|
if result ~= "win" then
|
||||||
|
ow:afterBattle(result, battle)
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
game.save.defeatedTrainers[npc.id] = true
|
||||||
|
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
|
||||||
|
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
|
||||||
|
-- fade, HideObject Giovanni, ShowObject Silph Scope.
|
||||||
|
game.stack:push(TextBox.new(game, cannotBe, function()
|
||||||
|
game.stack:push(TextBox.new(game, hope, function()
|
||||||
|
local Transition = require("src.render.Transition")
|
||||||
|
game.stack:push(Transition.new(game, function()
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||||
|
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||||
|
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||||
|
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||||
|
end, function()
|
||||||
|
ow:afterBattle(result, battle)
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
ow:pushBattle(battle)
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
|
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
|
||||||
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
|
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
|
||||||
@@ -286,14 +456,23 @@ M.GAME_CORNER = {
|
|||||||
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
|
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
local poster = game.data.field.gameCornerPoster
|
local poster = game.data.field.gameCornerPoster
|
||||||
if not poster then return end
|
if poster then
|
||||||
local block = game.save.flags[poster.event] and poster.openBlock
|
local block = game.save.flags[poster.event] and poster.openBlock
|
||||||
or poster.closedBlock
|
or poster.closedBlock
|
||||||
ow:replaceBlock(poster.x, poster.y, block)
|
ow:replaceBlock(poster.x, poster.y, block)
|
||||||
|
end
|
||||||
-- pick this visit's lucky slot machine
|
-- pick this visit's lucky slot machine
|
||||||
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
|
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
|
||||||
local seats = game.data.field.slotMachines.GAME_CORNER
|
local seats = game.data.field.slotMachines.GAME_CORNER
|
||||||
ow.luckySlot = love.math.random(1, #seats)
|
ow.luckySlot = love.math.random(1, #seats)
|
||||||
|
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
|
||||||
|
-- never hid him; clear the tile if he is already marked defeated
|
||||||
|
local rocketId = "GAME_CORNER_obj_11"
|
||||||
|
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||||
|
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
-- the poster bg event: pressing A reveals the hidden switch
|
-- the poster bg event: pressing A reveals the hidden switch
|
||||||
@@ -322,7 +501,16 @@ M.GAME_CORNER = {
|
|||||||
-- losing he warns the BOSS and leaves the floor for good, freeing
|
-- losing he warns the BOSS and leaves the floor for good, freeing
|
||||||
-- the tile in front of the hideout switch
|
-- the tile in front of the hideout switch
|
||||||
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
|
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local function hideRocket()
|
||||||
|
Commands.hide_object({ game = game, save = game.save,
|
||||||
|
overworld = ow },
|
||||||
|
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||||
|
end
|
||||||
|
-- already beaten: hide anyway so pre-#50 saves that only have
|
||||||
|
-- defeatedTrainers (no objectToggles hide) clear the poster tile
|
||||||
if ow:trainerDefeated(npc) then
|
if ow:trainerDefeated(npc) then
|
||||||
|
hideRocket()
|
||||||
done()
|
done()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -336,11 +524,17 @@ M.GAME_CORNER = {
|
|||||||
game.data.text._GameCornerRocketAfterBattleText
|
game.data.text._GameCornerRocketAfterBattleText
|
||||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||||
function()
|
function()
|
||||||
local Commands = require("src.script.Commands")
|
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||||
Commands.hide_object({ game = game, save = game.save,
|
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||||
overworld = ow },
|
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
-- he leaves the floor rather than popping out of existence on
|
||||||
done()
|
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||||
|
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||||
|
-- the step lands.
|
||||||
|
ow:scriptMove(npc, "up", 1, function()
|
||||||
|
hideRocket()
|
||||||
|
done()
|
||||||
|
end)
|
||||||
end))
|
end))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
@@ -421,40 +615,62 @@ local function activePrizes()
|
|||||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
|
||||||
|
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
|
||||||
|
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
|
||||||
|
-- with no case prints RequireCoinCaseText and returns without ever opening a
|
||||||
|
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
|
||||||
|
-- show the prizes. #194: the port used to open the window unconditionally and
|
||||||
|
-- skip both text boxes.
|
||||||
local function prizeCounter(game, ow, npc, done)
|
local function prizeCounter(game, ow, npc, done)
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local items = {}
|
local TextBox = require("src.render.TextBox")
|
||||||
for _, p in ipairs(activePrizes()) do
|
local t = game.data.text
|
||||||
local label
|
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||||
if p.kind == "mon" then
|
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
if not game.save.inventory.COIN_CASE then
|
||||||
else
|
game.stack:push(TextBox.new(game,
|
||||||
label = game.data.items[p.item].name
|
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||||
end
|
return
|
||||||
table.insert(items, { label = label, right = tostring(p.cost), value = p })
|
|
||||||
end
|
end
|
||||||
local list
|
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
game.stack:push(TextBox.new(game,
|
||||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||||
onChoose = function(item)
|
function()
|
||||||
local p = item.value
|
local items = {}
|
||||||
if (game.save.coins or 0) < p.cost then
|
for _, p in ipairs(activePrizes()) do
|
||||||
list.footer = "Not enough coins!"
|
local label
|
||||||
return
|
if p.kind == "mon" then
|
||||||
|
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||||
|
else
|
||||||
|
label = game.data.items[p.item].name
|
||||||
|
end
|
||||||
|
table.insert(items,
|
||||||
|
{ label = label, right = tostring(p.cost), value = p })
|
||||||
end
|
end
|
||||||
game.save.coins = game.save.coins - p.cost
|
local list
|
||||||
if p.kind == "mon" then
|
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||||
Commands.give_pokemon({ save = game.save, game = game },
|
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||||
p.species, p.level)
|
onChoose = function(item)
|
||||||
else
|
local p = item.value
|
||||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
if (game.save.coins or 0) < p.cost then
|
||||||
end
|
list.footer = "Not enough coins!"
|
||||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
return
|
||||||
end,
|
end
|
||||||
onCancel = done,
|
game.save.coins = game.save.coins - p.cost
|
||||||
})
|
if p.kind == "mon" then
|
||||||
game.stack:push(list)
|
Commands.give_pokemon({ save = game.save, game = game },
|
||||||
|
p.species, p.level)
|
||||||
|
else
|
||||||
|
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||||
|
end
|
||||||
|
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||||
|
end,
|
||||||
|
onCancel = done,
|
||||||
|
})
|
||||||
|
game.stack:push(list)
|
||||||
|
end))
|
||||||
end
|
end
|
||||||
|
|
||||||
M.GAME_CORNER_PRIZE_ROOM = {
|
M.GAME_CORNER_PRIZE_ROOM = {
|
||||||
@@ -481,8 +697,9 @@ local DOCK_SHIP_BLOCKS = {
|
|||||||
|
|
||||||
M.VERMILION_DOCK = {
|
M.VERMILION_DOCK = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
|
local Flags = require("src.script.Flags")
|
||||||
local f = game.save.flags
|
local f = game.save.flags
|
||||||
if f.EVENT_SS_ANNE_LEFT then
|
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||||
-- the ship is long gone: erase her right away, and anyone who
|
-- the ship is long gone: erase her right away, and anyone who
|
||||||
-- still lands here is sent back out past the guard
|
-- still lands here is sent back out past the guard
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
@@ -498,13 +715,15 @@ M.VERMILION_DOCK = {
|
|||||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||||
-- the horn blows, smoke puffs drift off the funnel, the ship is
|
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
||||||
-- erased to open water, and the player is walked off the dock
|
-- drift off the funnel, the horn blows, the ship is erased to
|
||||||
-- into the city past the guard (VermilionCity's
|
-- open water, and the player is walked off the dock into the
|
||||||
|
-- city past the guard (VermilionCity's
|
||||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||||
f.EVENT_SS_ANNE_LEFT = true
|
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||||
require("src.core.Music").stop()
|
local Music = require("src.core.Music")
|
||||||
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
|
Music.stop()
|
||||||
|
Music.play(game.data, "Music_Surfing")
|
||||||
local function puff(n, cx)
|
local function puff(n, cx)
|
||||||
if n <= 0 then return end
|
if n <= 0 then return end
|
||||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||||
@@ -512,11 +731,15 @@ M.VERMILION_DOCK = {
|
|||||||
puff(3, 15)
|
puff(3, 15)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
rows[#rows + 1] = { "wait", 100 }
|
rows[#rows + 1] = { "wait", 100 }
|
||||||
|
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { "wait", 30 }
|
rows[#rows + 1] = { "wait", 30 }
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
|
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||||
|
-- through the exit-ship walk)
|
||||||
|
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
ow:queueScript(rows)
|
ow:queueScript(rows)
|
||||||
|
|||||||
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
|
|||||||
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
|
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
|
||||||
|
-- is ever hidden (Gen1 removes just the chosen ball).
|
||||||
local function dojoBall(species, ownBall, otherBall, askKey)
|
local function dojoBall(species, ownBall, otherBall, askKey)
|
||||||
return function(game, ow, npc, done)
|
return function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
local flags = game.save.flags
|
local flags = game.save.flags
|
||||||
|
-- Already took one prize: the OTHER ball is still on the mat, so
|
||||||
|
-- talking to it now gives the "Better not get greedy..." refusal
|
||||||
|
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
|
||||||
|
-- not a silent no-op (#197).
|
||||||
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
|
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
|
||||||
done()
|
push(game, t._FightingDojoBetterNotGetGreedyText
|
||||||
|
or "Better not get\ngreedy...", done)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not flags.EVENT_BEAT_KARATE_MASTER then
|
if not flags.EVENT_BEAT_KARATE_MASTER then
|
||||||
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
|||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { save = game.save, game = game, overworld = ow }
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
Commands.give_pokemon(ctx, species, 30)
|
Commands.give_pokemon(ctx, species, 30)
|
||||||
|
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||||
|
-- just the picked object's index) and routes to the greedy line above
|
||||||
|
-- when talked to (#197).
|
||||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||||
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
|
|
||||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
|
|||||||
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
|
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
|
||||||
"_FightingDojoHitmonchanPokeBallText"),
|
"_FightingDojoHitmonchanPokeBallText"),
|
||||||
},
|
},
|
||||||
|
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
|
||||||
|
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
|
||||||
|
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
|
||||||
|
-- bg_events (signs = {}), so wire the poster read here, the same
|
||||||
|
-- facing-up + coord shape the mansion switches use. Reachable only once
|
||||||
|
-- a claimed prize frees the ball cell below the poster (#197).
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
if ow.player.facing ~= "up" then return false end
|
||||||
|
if fy == 0 and (fx == 4 or fx == 5) then
|
||||||
|
push(game, text(game)._EnemiesOnEverySideText
|
||||||
|
or "Enemies on every\nside!")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -212,30 +236,31 @@ M.COPYCATS_HOUSE_2F = {
|
|||||||
talk = {
|
talk = {
|
||||||
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
|
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
|
local Bag = require("src.inventory.Bag")
|
||||||
if game.save.flags.EVENT_GOT_TM31 then
|
if game.save.flags.EVENT_GOT_TM31 then
|
||||||
push(game, t._CopycatsHouse2FCopycatWhirlingText
|
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
|
||||||
or "Huh? Huh? Huh?", done)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if (game.save.inventory.POKE_DOLL or 0) > 0 then
|
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
|
||||||
ask(game, t._CopycatsHouse2FCopycatPokeDollText
|
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
|
||||||
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
|
if (game.save.inventory.POKE_DOLL or 0) <= 0 then
|
||||||
if not yes then done() return end
|
done()
|
||||||
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1
|
return
|
||||||
if game.save.inventory.POKE_DOLL == 0 then
|
end
|
||||||
game.save.inventory.POKE_DOLL = nil
|
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
|
||||||
end
|
if not Bag.add(game.save, "TM_MIMIC", 1) then
|
||||||
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then
|
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
|
||||||
push(game, "You don't have\nroom for TM31!", done)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||||
|
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||||
|
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||||
game.save.flags.EVENT_GOT_TM31 = true
|
game.save.flags.EVENT_GOT_TM31 = true
|
||||||
push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
|
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||||
|
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
return
|
end)
|
||||||
end
|
|
||||||
push(game, t._CopycatsHouse2FCopycatText
|
|
||||||
or "I like to mimic\npeople!", done)
|
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -645,12 +670,44 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
|
||||||
|
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
|
||||||
|
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
|
||||||
|
-- Game Corner Rocket and push header.after immediately after a win.
|
||||||
|
local function e4LeaderTalk(afterLabel)
|
||||||
|
return function(game, ow, npc, done)
|
||||||
|
done = done or function() end
|
||||||
|
if ow:trainerDefeated(npc) then
|
||||||
|
local after = text(game)[afterLabel]
|
||||||
|
if after then push(game, after, done) else done() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
ow:engageTrainer(npc, function()
|
||||||
|
if not ow:trainerDefeated(npc) then
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local after = text(game)[afterLabel]
|
||||||
|
if after then push(game, after, done) else done() end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
|
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||||
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
|
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
|
||||||
|
M.LORELEIS_ROOM.talk = {
|
||||||
|
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
|
||||||
|
}
|
||||||
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||||
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
|
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
|
||||||
|
M.BRUNOS_ROOM.talk = {
|
||||||
|
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
|
||||||
|
}
|
||||||
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||||
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
||||||
|
M.AGATHAS_ROOM.talk = {
|
||||||
|
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
|
||||||
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
|
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
|
||||||
@@ -672,13 +729,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
|||||||
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
|
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
|
||||||
-- the door behind the player with SFX_GO_INSIDE
|
-- the door behind the player with SFX_GO_INSIDE
|
||||||
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
|
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
|
||||||
-- (up 12, left 12, down 7, left 6) landing on (6,11).
|
-- landing on (6,11). Vanilla WalkToLance_RLEList is
|
||||||
-- It marches straight across the room's water decor:
|
-- up 12 / left 12 / down 7 / left 6 and skips collision
|
||||||
-- pokered's CollisionCheckOnLand skips collision
|
-- through void tiles; our camera follows that literally
|
||||||
-- entirely while simulated joypad states run, and our
|
-- and briefly shows the empty upper chamber (reads as
|
||||||
-- scriptMove is collision-free the same way.
|
-- "Gary's room"). We keep the same landing cell but
|
||||||
|
-- route along the open-door floor corridor instead.
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
|
||||||
|
-- unset (entrance blocks $31/$32). Exposed for parity tests.
|
||||||
|
local LANCE_WALK_IN = {
|
||||||
|
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
|
||||||
|
{ "up", 9 }, { "left", 2 }, { "up", 3 },
|
||||||
|
}
|
||||||
|
|
||||||
local function lanceEntranceBlocks(game, ow)
|
local function lanceEntranceBlocks(game, ow)
|
||||||
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
|
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
|
||||||
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
|
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
|
||||||
@@ -693,21 +758,24 @@ local function lanceLockDoor(game, ow)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function lanceWalkIn(game, ow)
|
local function lanceWalkIn(game, ow)
|
||||||
ow:scriptMove(ow.player, "up", 12, function()
|
local i = 0
|
||||||
ow:scriptMove(ow.player, "left", 12, function()
|
local function step()
|
||||||
ow:scriptMove(ow.player, "down", 7, function()
|
i = i + 1
|
||||||
ow:scriptMove(ow.player, "left", 6, function()
|
local seg = LANCE_WALK_IN[i]
|
||||||
-- the walk lands on (6,11); vanilla's per-frame coord poll
|
if not seg then
|
||||||
-- then locks the door at once. scriptMove landings do not
|
-- lands on (6,11); vanilla's per-frame coord poll then locks the
|
||||||
-- fire onStep, so lock here.
|
-- door at once. scriptMove landings do not fire onStep, so lock
|
||||||
lanceLockDoor(game, ow)
|
-- here.
|
||||||
end)
|
lanceLockDoor(game, ow)
|
||||||
end)
|
return
|
||||||
end)
|
end
|
||||||
end)
|
ow:scriptMove(ow.player, seg[1], seg[2], step)
|
||||||
|
end
|
||||||
|
step()
|
||||||
end
|
end
|
||||||
|
|
||||||
M.LANCES_ROOM = {
|
M.LANCES_ROOM = {
|
||||||
|
walkInRoute = LANCE_WALK_IN,
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
lanceEntranceBlocks(game, ow)
|
lanceEntranceBlocks(game, ow)
|
||||||
-- the warp arrival lands ON the staircase trigger, and onStep only
|
-- the warp arrival lands ON the staircase trigger, and onStep only
|
||||||
@@ -727,7 +795,14 @@ M.LANCES_ROOM = {
|
|||||||
end
|
end
|
||||||
if not lance or ow:trainerDefeated(lance) then return false end
|
if not lance or ow:trainerDefeated(lance) then return false end
|
||||||
lance:facePlayer(ow.player)
|
lance:facePlayer(ow.player)
|
||||||
ow:engageTrainer(lance, function() end)
|
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
|
||||||
|
-- after-battle text (rival became champion first). engageTrainer
|
||||||
|
-- only shows the won line, so push the after text on a win.
|
||||||
|
ow:engageTrainer(lance, function()
|
||||||
|
if not ow:trainerDefeated(lance) then return end
|
||||||
|
local after = text(game)._LancesRoomLanceAfterBattleText
|
||||||
|
if after then push(game, after) end
|
||||||
|
end)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if (x == 5 or x == 6) and y == 11 then
|
if (x == 5 or x == 6) and y == 11 then
|
||||||
|
|||||||
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- coordinate block: show a line and push the player back one step
|
-- coordinate block: show a line and shove the player back one step. pokered
|
||||||
|
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
|
||||||
|
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
|
||||||
|
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
|
||||||
|
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
|
||||||
|
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
|
||||||
|
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
|
||||||
local function stepGate(opts)
|
local function stepGate(opts)
|
||||||
return function(game, ow, x, y)
|
return function(game, ow, x, y)
|
||||||
if not inCoords(opts.coords, x, y) then return false end
|
if not inCoords(opts.coords, x, y) then return false end
|
||||||
if not opts.blocked(game) then return false end
|
if not opts.blocked(game) then return false end
|
||||||
require("src.core.Sound").play(game.data, "Denied")
|
require("src.core.Sound").play(game.data, "Denied")
|
||||||
push(game, text(game)[opts.text] or opts.fallback,
|
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||||
function() ow:scriptMove(ow.player, opts.push, 1) end)
|
ow.player.facing = opts.push
|
||||||
|
if not ow:checkLedgeHop(opts.push) then
|
||||||
|
ow:scriptMove(ow.player, opts.push, 1)
|
||||||
|
end
|
||||||
|
end)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -262,38 +272,208 @@ M.CINNABAR_ISLAND = {
|
|||||||
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
||||||
-- escorts you to the gym (scripts/PewterCity.asm
|
-- escorts you to the gym (scripts/PewterCity.asm
|
||||||
-- PewterCityCheckPlayerLeavingEastScript /
|
-- PewterCityCheckPlayerLeavingEastScript /
|
||||||
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
|
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
|
||||||
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
|
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
|
||||||
-- west along the road to the front of the PEWTER_GYM door at (16,17).
|
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
|
||||||
|
-- youngster walks RLEList_PewterGymGuy while the player plays the
|
||||||
|
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
|
||||||
|
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
|
||||||
|
-- the building.
|
||||||
|
local pewterEscort = {}
|
||||||
|
|
||||||
|
-- RLEList_PewterGymGuy (NPC directions play forward)
|
||||||
|
pewterEscort.guySteps = {
|
||||||
|
"down", "down",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"up", "up", "up", "up", "up",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left",
|
||||||
|
"down", "down", "down", "down", "down",
|
||||||
|
"right", "right", "right",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn
|
||||||
|
-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that
|
||||||
|
-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it
|
||||||
|
-- home any more, because its first step is LEFT through the player
|
||||||
|
-- parked on (11,18) (#241). See walkHome below.
|
||||||
|
do
|
||||||
|
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||||
|
local ret = {}
|
||||||
|
for i = #pewterEscort.guySteps, 1, -1 do
|
||||||
|
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
|
||||||
|
end
|
||||||
|
pewterEscort.guyReturnSteps = ret
|
||||||
|
end
|
||||||
|
|
||||||
|
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
|
||||||
|
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
|
||||||
|
pewterEscort.playerRle = {
|
||||||
|
"NO",
|
||||||
|
"right", "right",
|
||||||
|
"down", "down", "down", "down", "down",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left",
|
||||||
|
"up", "up", "up", "up", "up",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
|
||||||
|
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
|
||||||
|
-- the NPC, so eight of them ≈ one guy head-start step)
|
||||||
|
pewterEscort.preambles = {
|
||||||
|
["34,16"] = { "left", "down", "down", "right" },
|
||||||
|
["35,17"] = { "left", "down", "right", "left" },
|
||||||
|
["37,18"] = { "left", "left", "left",
|
||||||
|
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
|
||||||
|
["37,19"] = { "left", "left", "up", "left" },
|
||||||
|
["36,17"] = { "left", "down", "left",
|
||||||
|
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Realized player path for a trigger tile: PewterGuys overwrites the
|
||||||
|
-- last RLE byte and appends the preamble, then simulated joypad plays
|
||||||
|
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
|
||||||
|
-- trailing end-of-list NO is dropped (one-frame pause).
|
||||||
|
function pewterEscort.playerPlan(x, y)
|
||||||
|
local pre = pewterEscort.preambles[x .. "," .. y]
|
||||||
|
if not pre then return nil end
|
||||||
|
local buf = {}
|
||||||
|
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
|
||||||
|
buf[#buf] = pre[1]
|
||||||
|
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||||
|
local path = {}
|
||||||
|
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||||
|
local head = 0
|
||||||
|
while path[head + 1] == "NO" do head = head + 1 end
|
||||||
|
local tail = #path
|
||||||
|
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||||
|
local steps = {}
|
||||||
|
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||||
|
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pewterGymEscort(game, ow)
|
||||||
|
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
|
||||||
|
local x, y = ow.player.cellX, ow.player.cellY
|
||||||
|
local plan = pewterEscort.playerPlan(x, y)
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
local t = text(game)
|
||||||
|
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
|
||||||
|
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
|
||||||
|
-- PewterGuys only has entries for five tiles; an unmatched talk tile
|
||||||
|
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
|
||||||
|
-- failed coords lookup would refuse to arm the walk.
|
||||||
|
if not plan then
|
||||||
|
push(game, follow)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
|
||||||
|
local guySteps = pewterEscort.guySteps
|
||||||
|
local head = plan.guyHeadStart
|
||||||
|
|
||||||
|
-- After the walk: face the player, restore map music, "Go take on
|
||||||
|
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
|
||||||
|
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
|
||||||
|
-- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
|
||||||
|
-- minus the +4 border offset object_event coords carry per
|
||||||
|
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
|
||||||
|
-- That lands him on (17,18), the last walkable cell before the fence at
|
||||||
|
-- (18,18) and one column past the screen edge with the player parked on
|
||||||
|
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
|
||||||
|
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
|
||||||
|
-- him back on his object_event spawn (35,16) facing DOWN, which is the
|
||||||
|
-- snap below (the vanish/reappear is off screen, same as the original).
|
||||||
|
--
|
||||||
|
-- The old code retraced guyReturnSteps instead, whose first step is
|
||||||
|
-- LEFT into (11,18) -- the cell the player is standing on -- and
|
||||||
|
-- scriptMove is a pure tween with no entity test, so he walked straight
|
||||||
|
-- through Red (#241). There is no honest route home: (17,18) is a
|
||||||
|
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
|
||||||
|
-- the original teleports. guyReturnSteps stays as the documented
|
||||||
|
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
|
||||||
|
-- Brock victory still HideObject's him.
|
||||||
|
local function walkHome()
|
||||||
|
if not guy then return end
|
||||||
|
local i = 0
|
||||||
|
local function tick()
|
||||||
|
i = i + 1
|
||||||
|
if i > 5 then
|
||||||
|
-- SetSpritePosition2: same field writes as Commands.place_npc,
|
||||||
|
-- including the target clear -- a stale targetX/targetY would
|
||||||
|
-- leave OverworldState:npcAtCell reserving (17,18) forever and
|
||||||
|
-- silently wall the player out of that pocket.
|
||||||
|
guy.cellX, guy.cellY = 35, 16
|
||||||
|
guy.px, guy.py = 35 * 16, 16 * 16
|
||||||
|
guy.moving = false
|
||||||
|
guy.targetX, guy.targetY = nil, nil
|
||||||
|
guy.facing = "down"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
ow:scriptMove(guy, "right", 1, tick)
|
||||||
|
end
|
||||||
|
tick()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function afterWalk()
|
||||||
|
if guy then guy.facing = "left" end
|
||||||
|
Music.playMap(game.data, "PEWTER_CITY")
|
||||||
|
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||||
|
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function lockstep()
|
||||||
|
local i = 0
|
||||||
|
local function tick()
|
||||||
|
i = i + 1
|
||||||
|
local ps = plan.steps[i]
|
||||||
|
if not ps then
|
||||||
|
afterWalk()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local gs = guySteps[head + i]
|
||||||
|
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||||
|
ow:scriptMove(ow.player, ps, 1, tick)
|
||||||
|
end
|
||||||
|
tick()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function beginWalk()
|
||||||
|
Music.play(game.data, "Music_MuseumGuy")
|
||||||
|
if guy and head > 0 then
|
||||||
|
local h = 0
|
||||||
|
local function headTick()
|
||||||
|
h = h + 1
|
||||||
|
if h > head then lockstep(); return end
|
||||||
|
ow:scriptMove(guy, guySteps[h], 1, headTick)
|
||||||
|
end
|
||||||
|
headTick()
|
||||||
|
else
|
||||||
|
lockstep()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
push(game, follow, beginWalk)
|
||||||
|
end
|
||||||
|
|
||||||
M.PEWTER_CITY = {
|
M.PEWTER_CITY = {
|
||||||
|
escort = pewterEscort,
|
||||||
|
-- PewterCityYoungsterText: talking also arms the gym escort script
|
||||||
|
talk = {
|
||||||
|
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
||||||
|
pewterGymEscort(game, ow)
|
||||||
|
if done then done() end
|
||||||
|
end,
|
||||||
|
},
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
||||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 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
|
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local t = text(game)
|
pewterGymEscort(game, ow)
|
||||||
-- 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()
|
|
||||||
walk(1)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -315,36 +495,79 @@ end
|
|||||||
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
|
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
|
||||||
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
||||||
-- surrounding story flags so old saves work too.
|
-- surrounding story flags so old saves work too.
|
||||||
|
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
|
||||||
|
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
|
||||||
|
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
|
||||||
|
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
|
||||||
|
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
|
||||||
|
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
|
||||||
|
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
|
||||||
|
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
|
||||||
|
-- tile the rival stands BELOW the player on (29,5) and leaves east along
|
||||||
|
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
|
||||||
|
-- step UP to row 4 to get around him. The port had the two branches
|
||||||
|
-- swapped, so the top tile started the walk with UP out of (28,4) into
|
||||||
|
-- the cliff cell (28,3), which is not walkable (#236).
|
||||||
|
local function route22ExitDirs(n, py)
|
||||||
|
if n == 2 then
|
||||||
|
-- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4
|
||||||
|
-- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5).
|
||||||
|
if py == 4 then return { "left", "left", "left", "left" } end
|
||||||
|
return { "left", "left", "left" }
|
||||||
|
end
|
||||||
|
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
|
||||||
|
if py == 4 then
|
||||||
|
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
|
||||||
|
return { "up", "right", "right", "right",
|
||||||
|
"down", "down", "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
|
||||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
||||||
|
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
|
||||||
|
-- own row from the object_event spawn (25,5): the full four-RIGHT
|
||||||
|
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
|
||||||
|
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
|
||||||
|
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
|
||||||
|
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
|
||||||
|
-- then faces him UP on index 1 and RIGHT otherwise (#236).
|
||||||
|
local rx = (py == 4) and 29 or 28
|
||||||
|
local rivalFacing = (py == 4) and "up" or "right"
|
||||||
return {
|
return {
|
||||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||||
{ "move_npc_to", objIndex, 28, py }, -- 2
|
{ "move_npc_to", objIndex, rx, 5 }, -- 2
|
||||||
{ "face_object", objIndex, "right" }, -- 3
|
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 11 }, -- 6
|
||||||
{ "set_flag", beatFlag }, -- 7
|
{ "set_flag", beatFlag }, -- 7
|
||||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||||
{ "move_npc_to", objIndex, 25, 5 }, -- 10
|
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
|
||||||
|
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
|
||||||
|
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
|
||||||
|
-- (the (29,5) tile, rival to his left) (#236).
|
||||||
M.ROUTE_22 = {
|
M.ROUTE_22 = {
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
||||||
local f = game.save.flags
|
local f = game.save.flags
|
||||||
|
local playerFacing = (y == 4) and "down" or "left"
|
||||||
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
||||||
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||||
return runAmbush(game, ow,
|
return runAmbush(game, ow,
|
||||||
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
|
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
|
||||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left")
|
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
|
||||||
end
|
end
|
||||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||||
return runAmbush(game, ow,
|
return runAmbush(game, ow,
|
||||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left")
|
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
@@ -353,6 +576,14 @@ M.ROUTE_22 = {
|
|||||||
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
|
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
|
||||||
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
|
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
|
||||||
-- cave guard who steps aside once you are Champion.
|
-- cave guard who steps aside once you are Champion.
|
||||||
|
-- Post-battle: Bill text then CeruleanCityMovement3/4 into town.
|
||||||
|
local function ceruleanRivalExitDirs(px)
|
||||||
|
if px == 20 then
|
||||||
|
return { "right", "down", "down", "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
return { "left", "down", "down", "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
|
||||||
local function ceruleanRivalScene(px, py)
|
local function ceruleanRivalScene(px, py)
|
||||||
return {
|
return {
|
||||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
|
||||||
@@ -360,41 +591,43 @@ local function ceruleanRivalScene(px, py)
|
|||||||
{ "face_object", 1, "down" }, -- 3
|
{ "face_object", 1, "down" }, -- 3
|
||||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||||
{ "jump_if_false", 10 }, -- 6
|
{ "jump_if_false", 11 }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||||
{ "move_npc_to", 1, 20, 2 }, -- 9
|
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
|
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||||
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
||||||
-- then he returns TM28 (DIG) and hurries off
|
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
||||||
|
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
|
||||||
|
-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
|
||||||
local rocketRows = {
|
local rocketRows = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||||
{ "jump_if_true", 15 }, -- 3
|
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
||||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||||
{ "jump_if_true", 9 }, -- 5
|
{ "jump_if_true", 9 }, -- 5
|
||||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||||
{ "jump_if_false", 18 }, -- 8
|
{ "jump_if_false", "end" }, -- 8
|
||||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
|
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||||
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
|
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||||
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
|
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||||
-- disappears. GUARD2 stands on the only walkable neighbour of the
|
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||||
-- trashed house's south door, which is one of the two ways through the
|
-- the same in story.lua; either route is enough).
|
||||||
-- 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
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||||
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||||
|
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
@@ -453,13 +686,83 @@ M.MUSEUM_1F = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
|
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
|
||||||
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes)
|
-- PewterPokecenterJigglypuffText). The text_asm sets
|
||||||
|
-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box
|
||||||
|
-- prints with no A prompt and the script keeps running underneath it:
|
||||||
|
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG,
|
||||||
|
-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a
|
||||||
|
-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2
|
||||||
|
-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that
|
||||||
|
-- last step closes the box, so the whole dance plays out unskippably
|
||||||
|
-- (#249). Music.playOnce's pendingRestore stands in for both the channel
|
||||||
|
-- poll and PlayDefaultMusic, so the Center's theme comes back the frame
|
||||||
|
-- the song ends rather than 48 frames later.
|
||||||
|
local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" }
|
||||||
|
local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||||
|
|
||||||
|
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||||
|
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||||
|
-- per frame while the gate it returns still reads as playing.
|
||||||
|
local function jigglypuffDance(game, npc)
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||||
|
-- matching the sprite's current facing (showMapText has just turned it
|
||||||
|
-- toward the player), and the first write is that same facing, so the
|
||||||
|
-- first visible quarter turn lands 24 frames in
|
||||||
|
local step = 1
|
||||||
|
for i, dir in ipairs(JIGGLYPUFF_SPIN) do
|
||||||
|
if npc and npc.facing == dir then step = i end
|
||||||
|
end
|
||||||
|
local frames, phase = 0, "silence"
|
||||||
|
return {
|
||||||
|
sound = function()
|
||||||
|
Music.stop() -- SFX_STOP_ALL_MUSIC
|
||||||
|
return { isPlaying = function() return phase ~= "done" end }
|
||||||
|
end,
|
||||||
|
tick = function()
|
||||||
|
frames = frames + 1
|
||||||
|
if phase == "silence" then
|
||||||
|
if frames < JIGGLYPUFF_SILENCE then return end
|
||||||
|
frames = 0
|
||||||
|
if Music.playOnce(game.data, "Music_JigglypuffSong") then
|
||||||
|
phase = "spin"
|
||||||
|
else
|
||||||
|
-- no song def (or headless): Music.stop above already took the
|
||||||
|
-- map theme down and nothing armed pendingRestore, so put it
|
||||||
|
-- back by hand and fall through to the tail rather than hold
|
||||||
|
-- the box on a poll that would never clear
|
||||||
|
Music.restoreMap(game.data)
|
||||||
|
phase = "tail"
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if phase == "spin" then
|
||||||
|
if frames < JIGGLYPUFF_STEP then return end
|
||||||
|
frames = 0
|
||||||
|
-- the loop tests the channels after the delay and before the next
|
||||||
|
-- write, so a song that just ended costs no extra quarter turn
|
||||||
|
if not Music.oneShotPlaying() then
|
||||||
|
phase = "tail"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
step = step % #JIGGLYPUFF_SPIN + 1
|
||||||
|
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
M.PEWTER_POKECENTER = {
|
M.PEWTER_POKECENTER = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
||||||
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
|
-- not the local push() helper: this box needs opts.auto, which is
|
||||||
push(game, text(game)._PewterPokecenterJigglypuffText
|
-- also what suppresses the blinking arrow and the A dismissal
|
||||||
or "JIGGLYPUFF: Puu\npupuu!", done)
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||||
|
done, { auto = jigglypuffDance(game, npc) }))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
|
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
|
||||||
-- from the leaders'/bosses' text_asm victory scripts:
|
-- from the leaders'/bosses' text_asm victory scripts:
|
||||||
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
|
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
|
||||||
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
|
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
|
||||||
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
|
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
|
||||||
|
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
|
||||||
-- The TM each gym leader hands out afterwards is also ported.
|
-- The TM each gym leader hands out afterwards is also ported.
|
||||||
--
|
--
|
||||||
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
||||||
@@ -11,6 +12,12 @@
|
|||||||
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
|
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
|
||||||
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
|
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
|
||||||
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
|
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
|
||||||
|
--
|
||||||
|
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
||||||
|
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
||||||
|
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||||
|
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||||
|
-- synthetic "received badge/TM" stub.
|
||||||
|
|
||||||
local function range(prefix, first, last)
|
local function range(prefix, first, last)
|
||||||
local t = {}
|
local t = {}
|
||||||
@@ -21,30 +28,85 @@ local function range(prefix, first, last)
|
|||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
|
||||||
|
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
|
||||||
|
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||||
item = "TM_BIDE",
|
item = "TM_BIDE",
|
||||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
|
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||||
|
hide = {
|
||||||
|
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||||
|
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||||
|
},
|
||||||
|
dialogue = {
|
||||||
|
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||||
|
"_PewterGymBrockBoulderBadgeInfoText",
|
||||||
|
"_PewterGymBrockWaitTakeThisText",
|
||||||
|
"_PewterGymReceivedTM34Text",
|
||||||
|
"_TM34ExplanationText",
|
||||||
|
} },
|
||||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||||
item = "TM_BUBBLEBEAM",
|
item = "TM_BUBBLEBEAM",
|
||||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||||
|
dialogue = {
|
||||||
|
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||||
|
"_CeruleanGymMistyCascadeBadgeInfoText",
|
||||||
|
"_CeruleanGymMistyReceivedTM11Text",
|
||||||
|
} },
|
||||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
["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) },
|
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||||
|
dialogue = {
|
||||||
|
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||||
|
"_VermilionGymLTSurgeThunderBadgeInfoText",
|
||||||
|
"_VermilionGymLTSurgeReceivedTM24Text",
|
||||||
|
"_TM24ExplanationText",
|
||||||
|
} },
|
||||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
["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) },
|
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||||
|
dialogue = {
|
||||||
|
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||||
|
"_CeladonGymRainbowBadgeInfoText",
|
||||||
|
"_CeladonGymReceivedTM21Text",
|
||||||
|
"_TM21ExplanationText",
|
||||||
|
} },
|
||||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||||
item = "TM_TOXIC",
|
item = "TM_TOXIC",
|
||||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
|
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||||
|
dialogue = {
|
||||||
|
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||||
|
"_FuchsiaGymKogaSoulBadgeInfoText",
|
||||||
|
"_FuchsiaGymKogaReceivedTM06Text",
|
||||||
|
"_FuchsiaGymKogaTM06ExplanationText",
|
||||||
|
} },
|
||||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||||
item = "TM_PSYWAVE",
|
item = "TM_PSYWAVE",
|
||||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
|
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||||
|
dialogue = {
|
||||||
|
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||||
|
"_SaffronGymSabrinaMarshBadgeInfoText",
|
||||||
|
"_SaffronGymSabrinaReceivedTM46Text",
|
||||||
|
"_TM46ExplanationText",
|
||||||
|
} },
|
||||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
["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) },
|
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||||
|
dialogue = {
|
||||||
|
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||||
|
"_CinnabarGymBlaineVolcanoBadgeInfoText",
|
||||||
|
"_CinnabarGymBlaineReceivedTM38Text",
|
||||||
|
"_CinnabarGymBlaineTM38ExplanationText",
|
||||||
|
} },
|
||||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||||
item = "TM_FISSURE",
|
item = "TM_FISSURE",
|
||||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
|
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||||
|
dialogue = {
|
||||||
|
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||||
|
"_ViridianGymGiovanniEarthBadgeInfoText",
|
||||||
|
"_ViridianGymGiovanniReceivedTM27Text",
|
||||||
|
"_ViridianGymGiovanniTM27ExplanationText",
|
||||||
|
} },
|
||||||
|
|
||||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||||
@@ -52,9 +114,12 @@ return {
|
|||||||
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
||||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||||
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
||||||
-- only him (data/maps/objects/FightingDojo.asm).
|
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
|
||||||
|
-- concede + prize offer he speaks after the win (his header supplies the
|
||||||
|
-- "Hwa! Arrgh! Beaten!" won line first; #197).
|
||||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
||||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
|
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
|
||||||
|
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
|
||||||
|
|
||||||
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
||||||
-- the flags make the Hall of Fame checkable)
|
-- the flags make the Hall of Fame checkable)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
|
||||||
|
|
||||||
|
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
|
||||||
|
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
|
||||||
|
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
|
||||||
|
(TF1).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
|
||||||
|
`gen1recomp/` folder.
|
||||||
|
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
|
||||||
|
|
||||||
|
```
|
||||||
|
Roms/PORTS/Gen1recomp.sh
|
||||||
|
Roms/PORTS/gen1recomp/
|
||||||
|
```
|
||||||
|
|
||||||
|
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
|
||||||
|
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
|
||||||
|
folder.
|
||||||
|
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
|
||||||
|
|
||||||
|
```
|
||||||
|
Roms/PORTS/gen1recomp/lovegame/
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
|
||||||
|
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
|
||||||
|
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
|
||||||
|
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
|
||||||
|
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
|
||||||
|
|
||||||
|
The port ships with `portable.txt` already in place, so after import the
|
||||||
|
saves and ROM-derived cache stay next to the game on the SD card. See
|
||||||
|
[Portable Mode](../README.md#portable-mode) for what that means.
|
||||||
|
|
||||||
|
Only the canonical 1 MiB US carts import:
|
||||||
|
|
||||||
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
|
||||||
|
## Launcher controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
| ------------------ | ------------------ |
|
||||||
|
| D-pad / left stick | Move cursor |
|
||||||
|
| A | Click |
|
||||||
|
| L1 / R1 | Switch tabs |
|
||||||
|
| Right stick | Scroll lists |
|
||||||
|
| Start / Select | Play or Choose ROM |
|
||||||
|
|
||||||
|
In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||||
|
**OPTIONS → CONTROLS**.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||||
|
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||||
|
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||||
|
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||||
|
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||||
|
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||||
|
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||||
|
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||||
|
put the row back.
|
||||||
|
|
||||||
|
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||||
|
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||||
|
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||||
|
paths relative to its own directory rather than PortMaster's `$directory`,
|
||||||
|
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
|
||||||
|
casing and mount points.
|
||||||
|
|
||||||
|
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
|
||||||
|
last run.
|
||||||
|
|
||||||
|
## Building the port
|
||||||
|
|
||||||
|
Release runs build this automatically (see `.github/workflows/release.yml`).
|
||||||
|
To build it by hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
|
||||||
|
local testing.
|
||||||
|
|
||||||
|
Stock OS 64-bit MOD comes from
|
||||||
|
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||||
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
|
|||||||
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
||||||
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
||||||
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
|
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
|
||||||
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
|
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
|
||||||
|
|
||||||
## Map scripts
|
## Map scripts
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Launcher
|
||||||
|
|
||||||
|
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
|
||||||
|
that runs before `Game:load`. Besides ROM import (see the file's own header)
|
||||||
|
it hosts a tabbed shell covering per-game save slots and a mod manager. This
|
||||||
|
file documents the runtime model; the visual spec lives separately.
|
||||||
|
|
||||||
|
## Android multi-ROM / mod / save import
|
||||||
|
|
||||||
|
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
|
||||||
|
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
|
||||||
|
save directory as:
|
||||||
|
|
||||||
|
| `kind` | Destination |
|
||||||
|
| --- | --- |
|
||||||
|
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||||
|
| `"mod"` | `picked_mod.zip` (open) |
|
||||||
|
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||||
|
|
||||||
|
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||||
|
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||||
|
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
|
||||||
|
for the launcher to acknowledge on refocus.
|
||||||
|
|
||||||
|
`RomImporter` then imports on refocus / Choose:
|
||||||
|
|
||||||
|
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
|
||||||
|
version that is **not** yet ready counts as pending. A leftover
|
||||||
|
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||||
|
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
|
||||||
|
other `.zip` at the save-dir root (USB copy).
|
||||||
|
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
|
||||||
|
other `.sav` at the save-dir root.
|
||||||
|
|
||||||
|
After a successful import the consumed save-dir file is removed.
|
||||||
|
|
||||||
|
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
|
||||||
|
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
||||||
|
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
|
||||||
|
same system picker and install the chosen archive on return.
|
||||||
|
|
||||||
|
## Tab structure
|
||||||
|
|
||||||
|
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
|
||||||
|
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
|
||||||
|
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
|
||||||
|
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
|
||||||
|
|
||||||
|
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
|
||||||
|
Play button, and the SAVE SLOT card in a responsive two-column grid (see
|
||||||
|
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
|
||||||
|
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
|
||||||
|
centered pill in a reserved band just above the footer, on every tab. That
|
||||||
|
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
|
||||||
|
|
||||||
|
## Save slot model
|
||||||
|
|
||||||
|
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
|
||||||
|
abstraction (`persistFs`) every other save/options call uses, so portable
|
||||||
|
mode (an `io.*` filesystem used when `portable.txt` marks the install)
|
||||||
|
keeps working unchanged.
|
||||||
|
|
||||||
|
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
|
||||||
|
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
|
||||||
|
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
|
||||||
|
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
|
||||||
|
`slot%d+`; `createSlot` allocates one past the highest existing number so a
|
||||||
|
reused id can never collide with a lingering file.
|
||||||
|
- **Registry.** The ordered slot list and which one is active persist in
|
||||||
|
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||||
|
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||||
|
Custom slot labels (#205) live alongside them in the same registry:
|
||||||
|
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
|
||||||
|
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
|
||||||
|
each `listSlots` row as `label` (the launcher row shows `label`, falling
|
||||||
|
back to the player name). `deleteSlot` drops the label with the slot.
|
||||||
|
Renaming never touches the save file, so an empty slot can be labeled.
|
||||||
|
On desktop, right-clicking a slot row opens the inline rename modal
|
||||||
|
(Enter commits, Esc cancels); touch has no secondary button, so the
|
||||||
|
affordance is desktop-only.
|
||||||
|
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||||
|
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||||
|
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||||
|
Resolved once per version per process (`ensureVersionSlots`, cached in
|
||||||
|
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
|
||||||
|
legacy migration may create one; otherwise the flat legacy path is used
|
||||||
|
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
|
||||||
|
- **Legacy migration.** One-time per version, lazy on first
|
||||||
|
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
|
||||||
|
file exists and no `saves/<version>/` registry does, its main + `.bak` are
|
||||||
|
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
|
||||||
|
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
|
||||||
|
originals removed and `slot1` registered as active. A copy that fails to
|
||||||
|
verify leaves the originals in place; migration never loses data.
|
||||||
|
|
||||||
|
The launcher-facing API:
|
||||||
|
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
|
||||||
|
every registered slot. `name` is the save's player name, or `nil` for an
|
||||||
|
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
|
||||||
|
title screen's `ContinueInfo` shows) or `nil`. The pure part,
|
||||||
|
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
|
||||||
|
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
|
||||||
|
it as active, and updates the process cache so the very next save/load
|
||||||
|
lands there. The launcher calls this the moment a slot row is clicked
|
||||||
|
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
|
||||||
|
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
|
||||||
|
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
||||||
|
save file written**. An empty slot means the title screen offers NEW GAME
|
||||||
|
only, which needs no further changes.
|
||||||
|
- `SaveData.deleteSlot(version, slotId)` removes the slot's
|
||||||
|
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
|
||||||
|
points active at another remaining slot (or clears active when the list is
|
||||||
|
empty). The launcher's SAVE SLOT panel Delete control calls this.
|
||||||
|
|
||||||
|
## Launcher mod manager
|
||||||
|
|
||||||
|
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
|
||||||
|
before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||||
|
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
|
||||||
|
`Loader:_discover` finds mods without running them. The real loader
|
||||||
|
(`src/mods/Loader.lua`) still owns the actual load at boot.
|
||||||
|
|
||||||
|
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
|
||||||
|
duplicate) and returns one row per mod:
|
||||||
|
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||||
|
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||||
|
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
||||||
|
enabled, matching the loader's own default).
|
||||||
|
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||||
|
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||||
|
and the validated manifests: `conflict` when enabling this mod collides
|
||||||
|
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||||
|
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||||
|
`love.*` calls, this half is table-driven by the test suite on its own.
|
||||||
|
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
|
||||||
|
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
||||||
|
and the in-game `ManagerState` see the change on next boot. The mods panel
|
||||||
|
calls this on every toggle and re-derives the list right away
|
||||||
|
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||||
|
shows without waiting for a reload.
|
||||||
|
- `LauncherMods.installZip(path)` mounts the archive with
|
||||||
|
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
|
||||||
|
the zip root, or inside one top-level folder), validates its manifest, and
|
||||||
|
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
|
||||||
|
duplicate of an already-installed mod id, and accepts either an external
|
||||||
|
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
|
||||||
|
temp first (mount only reaches save-dir-relative paths), the same way
|
||||||
|
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
||||||
|
back, and every path unmounts and clears the staged temp file.
|
||||||
|
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||||
|
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||||
|
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||||
|
|
||||||
|
## Import / Export save
|
||||||
|
|
||||||
|
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
|
||||||
|
through `src/import/SaveFileIO.lua`, which sits on top of
|
||||||
|
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
||||||
|
|
||||||
|
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||||
|
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||||
|
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||||
|
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
|
||||||
|
relative name, a dropped LOVE file, or raw bytes),
|
||||||
|
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||||
|
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
||||||
|
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||||
|
The meta stamp is re-stamped off `gen1_import` to the current numeric format
|
||||||
|
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
|
||||||
|
panel is refreshed with the new slot selected.
|
||||||
|
- **Export save** is live only when the active slot actually holds a save
|
||||||
|
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
|
||||||
|
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||||
|
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||||
|
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||||
|
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||||
|
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||||
|
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||||
|
On Android the bytes are also staged as `pending_export.sav` and
|
||||||
|
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||||
|
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||||
|
makes focus show "Save exported."
|
||||||
|
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||||
|
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||||
|
placeholder) is showing it defaults to red, the always-present first game
|
||||||
|
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
|
||||||
|
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
|
||||||
|
nothing to export, ROM not imported yet) surfaces as a red notice line on the
|
||||||
|
card. Nothing raises and nothing silently no-ops.
|
||||||
|
|
||||||
|
`SaveFileIO` is love-free enough to unit-test through the same in-memory
|
||||||
|
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
|
||||||
|
|
||||||
|
## Responsiveness
|
||||||
|
|
||||||
|
Every measurement derives from `love.graphics.getDimensions()` each frame
|
||||||
|
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
|
||||||
|
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
|
||||||
|
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
|
||||||
|
column, slot card below Play, when the window is too narrow for both
|
||||||
|
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
|
||||||
|
(wheel, or drag on touch/desktop) clamped to their own content extent,
|
||||||
|
recomputed every draw. The tab bar labels only the active chip so it stays
|
||||||
|
narrow-safe, and content caps out at `~1440 * s` wide, centered.
|
||||||
@@ -1,23 +1,128 @@
|
|||||||
# Native modding
|
# Native modding
|
||||||
|
|
||||||
The modding book lives on the
|
The modding book lives on the
|
||||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||||
|
|
||||||
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
|
- [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
|
||||||
— install a mod, write a first one, enable and disable it.
|
— install a mod, write a first one, enable and disable it.
|
||||||
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
|
- [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
|
||||||
— twelve dependency-ordered rungs, each a runnable mod.
|
— twelve dependency-ordered rungs, each a runnable mod.
|
||||||
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
|
- [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
|
||||||
— task-sized recipes.
|
— task-sized recipes.
|
||||||
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
|
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
|
||||||
— every registry, generated from `src/mods/Schemas.lua`.
|
— every registry, generated from `src/mods/Schemas.lua`.
|
||||||
|
|
||||||
Regenerate the reference straight into a wiki checkout:
|
Regenerate the reference straight into a wiki checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Rendering pipelines
|
||||||
|
|
||||||
|
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||||
|
*drawing*: a pipeline is a display mode a mod owns, which may replace the
|
||||||
|
overworld's world pass with geometry of its own and/or post-process the
|
||||||
|
finished image. `mods/voxel_world` is the worked example — a 3D diorama
|
||||||
|
overworld plus a tilt-shift miniature pass, in about 120 lines of glue over
|
||||||
|
its renderer.
|
||||||
|
|
||||||
|
A record declares what the mode *is*; the engine
|
||||||
|
(`src/render/Pipelines.lua`) supplies everything about *being a display
|
||||||
|
mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey,
|
||||||
|
persistence in `save.options.pipelines`, and the rule that a world pipeline
|
||||||
|
and the engine's own TILT are mutually exclusive.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.content.render_pipelines:register("diorama", {
|
||||||
|
label = "DIORAMA", -- options row label
|
||||||
|
levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON
|
||||||
|
hotkey = "6", -- checked after the engine's keys
|
||||||
|
priority = 20, -- highest eligible wins the world
|
||||||
|
available = function() return Renderer3D.ok() end,
|
||||||
|
update = function(dt, level) Camera.ease(dt, level) end,
|
||||||
|
drawWorld = function(ctx) return renderScene(ctx) end,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Three draw stages, each optional; a record needs at least one:
|
||||||
|
|
||||||
|
| stage | signature | runs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass |
|
||||||
|
| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites |
|
||||||
|
| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike |
|
||||||
|
|
||||||
|
`worldPresent` is the one to reach for when an effect must leave dialog
|
||||||
|
boxes and menus crisp — a depth-of-field or colour grade on the world only.
|
||||||
|
`present` is for effects that genuinely own the screen, like a CRT curve.
|
||||||
|
|
||||||
|
`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view),
|
||||||
|
`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and
|
||||||
|
`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it
|
||||||
|
with your own projection and the engine draws every active field effect
|
||||||
|
(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing
|
||||||
|
rod, Rock Tunnel darkness) at its correct anchor under your camera. There
|
||||||
|
is exactly one copy of each effect, so a new engine effect works in your
|
||||||
|
pipeline without you touching anything.
|
||||||
|
|
||||||
|
Three rules worth knowing:
|
||||||
|
|
||||||
|
- **`gate` governs input, never the draw.** It decides whether the player
|
||||||
|
may *change* the mode (default: free-roam overworld only). A mode that
|
||||||
|
stopped rendering during a warp would flash the flat 2D world every time
|
||||||
|
the player walked through a door.
|
||||||
|
- **`available` is re-read every frame** and is the only thing that decides
|
||||||
|
whether the mode can render at all. Answer `false` on a headless run or a
|
||||||
|
driver with no depth canvas and the engine silently keeps the vanilla 2D
|
||||||
|
path — which is why shipping a pipeline enabled is safe.
|
||||||
|
- **A callback that throws retires its pipeline**, attributed to your mod in
|
||||||
|
the manager's error feed, and the frame falls back to 2D. A broken
|
||||||
|
renderer costs the player a display mode, never the game.
|
||||||
|
|
||||||
|
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||||
|
frame"; the engine draws the vanilla world instead.
|
||||||
|
|
||||||
|
## Battle sprite scaling
|
||||||
|
|
||||||
|
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||||
|
the Game Boy did. A mod can override either, per species or per image.
|
||||||
|
|
||||||
|
Per species, on the `pokemon` record:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- MEW's back pic renders 1.5x; its front pic is untouched
|
||||||
|
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
|
||||||
|
```
|
||||||
|
|
||||||
|
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
|
||||||
|
both take a number in `0.25 .. 4.0`.
|
||||||
|
|
||||||
|
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
|
||||||
|
exactly as the data references it:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.content.battle_sprite_scales:register("abra_back", {
|
||||||
|
path = "assets/generated/battle/back/abrab.png",
|
||||||
|
scale = 1.5,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
An image-level entry beats the species scale for that one pic, and it is
|
||||||
|
the only way to scale a pic that is not species-keyed — the player's
|
||||||
|
trainer back sprite, held on screen until "Go!", is a bare image path.
|
||||||
|
|
||||||
|
The resolution order at draw time is **image-level → species-level →
|
||||||
|
default** (1x front, 2x back).
|
||||||
|
|
||||||
|
- **The pic stays grounded at every scale.** The player pic keeps its feet
|
||||||
|
flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge
|
||||||
|
and horizontal centre pinned in its 7×7 slot. A larger pic grows upward
|
||||||
|
and outward from that anchor, never off the shelf.
|
||||||
|
- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon`
|
||||||
|
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||||
|
mon still grows into place from the ball, grounded the whole way.
|
||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
|
|||||||
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
|
|||||||
|
|
||||||
## Survey zoom
|
## Survey zoom
|
||||||
|
|
||||||
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world
|
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||||
pixel (full survey) and 2× the window fit scale (close-up), in crisp
|
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||||
integer steps. This has no Game Boy equivalent:
|
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||||
|
Boy equivalent:
|
||||||
|
|
||||||
- Connected maps render their full bodies, and their NPCs appear as
|
- Connected maps render their full bodies, and their NPCs appear as
|
||||||
visual-only "ghosts", they wander but have no sight lines, triggers,
|
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||||
dialogue, or collision until the map is actually entered.
|
dialogue, or collision until the map is actually entered.
|
||||||
- Menus, text boxes, and battles draw at normal scale on top of the
|
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||||
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||||
active; the zoom level persists across warps and is never saved.
|
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||||
- Beyond the border ring the border block repeats indefinitely (interiors
|
`0` = FIT) and survives New Game via `options.lua`.
|
||||||
stay black, seaside towns stay water, except OVERWORLD-tileset maps,
|
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||||
whose beyond-edge space fills with the solid tree wall instead of the
|
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||||
per-map border block), and each visible map area is colorized with its
|
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||||
own SGB palette (the original recolored the whole screen per map).
|
FILL below); interiors keep their own border block. Each visible map
|
||||||
|
area is colorized with its own SGB palette (the original recolored the
|
||||||
|
whole screen per map).
|
||||||
- Neighbor maps load two connection hops out so corner-adjacent maps
|
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||||
don't pop in and out, and ghost NPCs share instances with the real ones
|
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||||
so their wander positions persist across seamless connection crossings
|
so their wander positions persist across seamless connection crossings
|
||||||
(a warp or fresh map entry still respawns everything at its script
|
(a warp or fresh map entry still respawns everything at its script
|
||||||
position, like the original's per-entry sprite init).
|
position, like the original's per-entry sprite init).
|
||||||
|
|
||||||
|
## VOID FILL
|
||||||
|
|
||||||
|
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||||
|
space on OVERWORLD-tileset maps during survey zoom:
|
||||||
|
|
||||||
|
- **TREES** (default): solid tree wall block `$0F`.
|
||||||
|
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||||
|
- **BLACK**: solid black.
|
||||||
|
|
||||||
|
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||||
|
Persisted as `save.options.voidFill`.
|
||||||
|
|
||||||
## Tilt mode
|
## Tilt mode
|
||||||
|
|
||||||
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||||
@@ -42,8 +57,10 @@ Game Boy equivalent:
|
|||||||
rows above the player recede and rows below come toward the viewer. Only
|
rows above the player recede and rows below come toward the viewer. Only
|
||||||
things that actually *stand* on the ground draw as upright billboards,
|
things that actually *stand* on the ground draw as upright billboards,
|
||||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||||
and the screen-anchored FX attached to them (heal machine glow, emote
|
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||||
bubbles, the fishing rod, the FLY bird). An earlier revision tried
|
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
|
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||||
per hand-curated per-tileset tables); that chased an endless tail of
|
per hand-curated per-tileset tables); that chased an endless tail of
|
||||||
special cases, dense tree canopy, fences fused into grass, building
|
special cases, dense tree canopy, fences fused into grass, building
|
||||||
@@ -69,7 +86,7 @@ Game Boy equivalent:
|
|||||||
## Colors mode
|
## Colors mode
|
||||||
|
|
||||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
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**.
|
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||||
|
|
||||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||||
@@ -80,8 +97,9 @@ The first three are the real colorizations; the rest are DMG-shade novelties:
|
|||||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||||
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||||
per-species mon colors (`data/palettes_gbc.lua`).
|
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||||
|
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||||
- **OG**: force the four DMG grays (colorization off).
|
- **OG**: force the four DMG grays (colorization off).
|
||||||
- **OG INV**: inverted DMG grays.
|
- **OG INV**: inverted DMG grays.
|
||||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||||
@@ -92,7 +110,8 @@ The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
|||||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
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` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||||
|
labels.
|
||||||
|
|
||||||
## GBC FX
|
## GBC FX
|
||||||
|
|
||||||
@@ -110,6 +129,14 @@ It runs as a final present pass after world + UI composite in
|
|||||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||||
Default OFF; persisted as `save.options.gbcfx`.
|
Default OFF; persisted as `save.options.gbcfx`.
|
||||||
|
|
||||||
|
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||||
|
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||||
|
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||||
|
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||||
|
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||||
|
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||||
|
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||||
|
|
||||||
## Peer-to-peer link play (lua-enet)
|
## Peer-to-peer link play (lua-enet)
|
||||||
|
|
||||||
Trades and link battles connect two copies of the game directly over
|
Trades and link battles connect two copies of the game directly over
|
||||||
@@ -124,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
|||||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||||
also exercises real UDP pairing.
|
also exercises real UDP pairing.
|
||||||
|
|
||||||
|
## Fair play in link and online matches
|
||||||
|
|
||||||
|
A link session is decided by the battle and nothing else, so for its
|
||||||
|
duration:
|
||||||
|
|
||||||
|
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||||
|
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||||
|
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||||
|
queue faster than the peer it is locked to and drains a tournament shot
|
||||||
|
clock faster than the opponent racing it.
|
||||||
|
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
|
||||||
|
mods enabled offers to switch them all off and relaunch (mods merge at
|
||||||
|
boot, so a restart is the only way). The restart is confirmed, not
|
||||||
|
silent. They stay listed as disabled, ready to switch back on.
|
||||||
|
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||||
|
both peers exchange is split three ways: `actives` and `bench` carry
|
||||||
|
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||||
|
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||||
|
flags both sides recompute anyway - a divergence there is logged and
|
||||||
|
reported to mods, and play continues.
|
||||||
|
|
||||||
|
The relay logs which component diverged on which turn, so a desync report
|
||||||
|
names something specific.
|
||||||
|
|
||||||
## Custom boot text
|
## Custom boot text
|
||||||
|
|
||||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||||
@@ -137,15 +188,153 @@ Nidorino-vs-Gengar attract scene) mirror the original.
|
|||||||
Options persist in a standalone `options.lua` (separate from the game
|
Options persist in a standalone `options.lua` (separate from the game
|
||||||
progress `save.lua`), so audio/display/battle preferences survive New Game
|
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||||
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||||
menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also
|
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||||
flushes the live options. Old saves that still embed an `options` table are
|
flushes the live options. Old saves that still embed an `options` table are
|
||||||
migrated once into `options.lua` on load.
|
migrated once into `options.lua` on load.
|
||||||
|
|
||||||
- Music / SFX volume
|
- Music / SFX volume
|
||||||
- Music Filter
|
- Music Filter
|
||||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||||
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||||
|
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||||
SuperPalettes + per-species mon colors)
|
SuperPalettes + per-species mon colors)
|
||||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||||
|
`-`/`=` step one level and save
|
||||||
|
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||||
|
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||||
|
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||||
|
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||||
|
|
||||||
|
## Battle transition cascade + white battle letterbox
|
||||||
|
|
||||||
|
Into-battle wipes still run the original eight styles inside the classic
|
||||||
|
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||||
|
blocks cascade outward from that square into the surrounding world so the
|
||||||
|
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||||
|
letterbox voids around the battle canvas fill **white** instead of black
|
||||||
|
so the whole window reads as one continuous battle screen.
|
||||||
|
|
||||||
|
## Widescreen battle layout
|
||||||
|
|
||||||
|
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||||
|
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||||
|
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||||
|
arrangement on it:
|
||||||
|
|
||||||
|
- the foe's status box upper left, the foe's picture upper right;
|
||||||
|
- the player's picture lower left, the player's status box lower right,
|
||||||
|
with a longer HP bar and the numeric HP under it;
|
||||||
|
- a full-width message window;
|
||||||
|
- a split "What will X do?" prompt / 2×2 command window;
|
||||||
|
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||||
|
panel attached to its right.
|
||||||
|
|
||||||
|
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||||
|
pages, window borders, sounds, animations, timing and every battle rule
|
||||||
|
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||||
|
Each side's picture keeps its original pixels and placement math and is
|
||||||
|
composited into its own region of the wider battlefield -- nothing is
|
||||||
|
scaled or squeezed -- and animations, which are authored in the original
|
||||||
|
160-pixel space, shift as one rigid group onto whichever side they play
|
||||||
|
on. The whole screen is drawn at the window's integer fit scale for the
|
||||||
|
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||||
|
160-pixel one in the same window.
|
||||||
|
|
||||||
|
The wide surface is live only while the battle itself is the screen on
|
||||||
|
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||||
|
brings the classic surface back with it.
|
||||||
|
|
||||||
|
## On-screen touch controls (mobile)
|
||||||
|
|
||||||
|
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||||
|
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||||
|
center) over the frame, using Xelu's CC0 controller prompts
|
||||||
|
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||||
|
finger does, sliding on the d-pad changes direction without lifting, and
|
||||||
|
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||||
|
appears while no controller is being used: the first gamepad button or
|
||||||
|
stick push hides it, the next screen touch brings it back, and unplugging
|
||||||
|
the last controller restores it immediately. Layout re-derives from the
|
||||||
|
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||||
|
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||||
|
## Translation support
|
||||||
|
|
||||||
|
Every string the player can read is now reachable from a mod, so a
|
||||||
|
translation is an ordinary content mod rather than a fork.
|
||||||
|
|
||||||
|
Two things had to change. Text layout stopped counting bytes: the dialogue
|
||||||
|
box measures a line in glyphs (charmap sequences), so a 3-byte character
|
||||||
|
costs one column, a cut never lands inside a character, and a page with a
|
||||||
|
non-default `advance` re-measures instead of overflowing. That also fixed
|
||||||
|
25 vanilla English lines that were wrapping early because `é` in POKéMON
|
||||||
|
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
|
||||||
|
glyphs, and the box was breaking it).
|
||||||
|
|
||||||
|
Second, the text the engine writes itself - battle messages, item results,
|
||||||
|
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
|
||||||
|
and the new `strings` registry. Extracted script text was already
|
||||||
|
overridable through `text`; this covers the other half. Entries are keyed
|
||||||
|
by the English source, so a translation that has not reached a string yet
|
||||||
|
keeps rendering in English and a half-finished translation stays playable.
|
||||||
|
|
||||||
|
Authors generate the whole thing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/modkit.py translation francais --language "Francais"
|
||||||
|
```
|
||||||
|
|
||||||
|
That scaffolds a mod with every translatable string as an empty catalog,
|
||||||
|
plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||||
|
`francais-worksheet/` directory holding the English to translate from
|
||||||
|
(deliberately outside the mod: extracted text is ROM content and must not
|
||||||
|
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||||
|
existing translations and parking orphaned keys rather than dropping them.
|
||||||
|
|
||||||
|
See the wiki's Translations guide.
|
||||||
|
|
||||||
|
## Save editor (bundled, reachable from the launcher)
|
||||||
|
|
||||||
|
The save editor ships inside every build instead of being a developer-only
|
||||||
|
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
|
||||||
|
Delete on every slot that actually holds a save. Edit suspends the
|
||||||
|
launcher, opens that slot's file in the editor, and **Close** hands the
|
||||||
|
process back to the launcher with the slot list re-read (a rename, a badge
|
||||||
|
or a dex change shows up on the row immediately). Unsaved edits arm a
|
||||||
|
confirm first, so leaving cannot lose work. `love . --editor` still opens
|
||||||
|
it standalone, where Close quits instead; `--save <path>` points it at any
|
||||||
|
file, and a save can be dragged onto the window.
|
||||||
|
|
||||||
|
The editor now wears the launcher's visual language - the same navy radial
|
||||||
|
field, 16px translucent cards, tri-colour version rail and green/yellow/red
|
||||||
|
semantics - so the two windows read as one app. Six tabs:
|
||||||
|
|
||||||
|
- **Party**: the roster with sprites, HP bars and level chips on the left,
|
||||||
|
and the mon inspector permanently docked on the right instead of floating
|
||||||
|
over the list. Species, level, DVs and moves all round-trip through the
|
||||||
|
Gen 1 formulas, so the inspector can never show illegal stats.
|
||||||
|
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
|
||||||
|
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||||
|
clickable and create a mon there.
|
||||||
|
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||||
|
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
||||||
|
with no slot cap, and the eight badges as toggle chips.
|
||||||
|
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||||
|
toggles, with a real filter field and a two-column paged grid.
|
||||||
|
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||||
|
and the player / lastHeal / lastOutdoor spawn points settable by clicking
|
||||||
|
a cell. Setting lastOutdoor on a map the game would not accept as an
|
||||||
|
outdoor source is refused with the reason.
|
||||||
|
- **Dex**: seen / owned completion meters and a four-column grid; owning
|
||||||
|
implies seen and un-seeing clears owned, exactly as the game requires.
|
||||||
|
|
||||||
|
Two rules run through all of it. Every mutation goes through one funnel
|
||||||
|
that sets the dirty flag and writes the status line together, so nothing
|
||||||
|
changes silently and no branch can quietly no-op - "Party is full", "Bag is
|
||||||
|
full", "click a cell first" all say so. And every destructive verb (Remove,
|
||||||
|
Release, Clear all, Wipe dex) arms on the first click and commits on the
|
||||||
|
second, relabelling itself to `Confirm?` in between.
|
||||||
|
|
||||||
|
A validation pill in the tab rail mirrors what the running game would
|
||||||
|
quarantine on load; clicking it jumps to the tab holding the first problem.
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Updater
|
||||||
|
|
||||||
|
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
|
||||||
|
baked into the executable, but that bundled copy is only ever the *fallback*.
|
||||||
|
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
|
||||||
|
looks in the save directory's `updates/` folder for a downloaded
|
||||||
|
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
|
||||||
|
engine version and runnable on this shell. If one qualifies, it is mounted
|
||||||
|
over `/` (so its files win over the fused source for every subsequent
|
||||||
|
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
|
||||||
|
run as if they had shipped in the executable. A dev/source checkout is never
|
||||||
|
fused, so `Boot.run` no-ops there and the working tree always runs itself.
|
||||||
|
|
||||||
|
The pieces are deliberately layered so the risky part is small. `Boot.select`
|
||||||
|
is a pure function (no `love.*` calls) that, given probed candidates and the
|
||||||
|
bundled `engine`/`shell`, decides what to run and what stale payloads to
|
||||||
|
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
|
||||||
|
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
|
||||||
|
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
|
||||||
|
orchestrates the crash guard, enumeration, selection, and the mount +
|
||||||
|
chainload, with full rollback on any failure. Checking for and fetching a
|
||||||
|
new payload is a separate, slower path: `src/update/Check.lua` is a thin
|
||||||
|
main-thread state machine the launcher screen polls, while the curl calls,
|
||||||
|
JSON parsing, and sha256 verification run on a background `love.thread`
|
||||||
|
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
|
||||||
|
|
||||||
|
## Version.lua fields
|
||||||
|
|
||||||
|
`src/core/Version.lua` carries three fields the updater reads directly (the
|
||||||
|
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
|
||||||
|
untouched):
|
||||||
|
|
||||||
|
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
|
||||||
|
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
|
||||||
|
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
|
||||||
|
reports itself up to date (it never chases a release, and it never counts
|
||||||
|
as a valid payload to chainload).
|
||||||
|
- `shell` - the native-shell contract this build's fused executable
|
||||||
|
implements.
|
||||||
|
- `minShell` - the lowest shell contract required to *run* this payload.
|
||||||
|
|
||||||
|
Bump `minShell` only when a payload needs something the currently-shipped
|
||||||
|
native shell cannot provide, for example a LOVE version bump, a new required
|
||||||
|
system binary, or a change to `love.run` itself (see Known limitations
|
||||||
|
below). An older shell refuses to chainload a payload whose `minShell`
|
||||||
|
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
|
||||||
|
rather than deleting it, in case a future shell upgrade can run it, and
|
||||||
|
`Check`'s worker reports `needs_full` so the player is pointed at a full
|
||||||
|
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
|
||||||
|
that is exactly the case the updater exists to avoid a reinstall for.
|
||||||
|
|
||||||
|
## Release assets
|
||||||
|
|
||||||
|
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||||
|
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||||
|
`-android.apk`) plus two assets the updater itself consumes:
|
||||||
|
|
||||||
|
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||||
|
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||||
|
`Check.parseRelease`).
|
||||||
|
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
|
||||||
|
filenames) covering at least the `.love` payload. `Check.parseSums`
|
||||||
|
tolerates a leading `*` binary marker and a `./` prefix but expects the
|
||||||
|
filename otherwise to match the asset name exactly.
|
||||||
|
|
||||||
|
A release missing either asset is treated as "no in-place update available":
|
||||||
|
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||||
|
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||||
|
|
||||||
|
## Save-directory layout
|
||||||
|
|
||||||
|
Under the save directory (identity `pokemon-love2d`):
|
||||||
|
|
||||||
|
```
|
||||||
|
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||||
|
updates/pending.txt crash-guard marker
|
||||||
|
```
|
||||||
|
|
||||||
|
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||||
|
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
|
||||||
|
on both a successful handoff and a clean rollback. If it is still present the
|
||||||
|
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
|
||||||
|
named payload is distrusted: it and the marker are deleted before candidates
|
||||||
|
are enumerated. Boot may still fall back to an older valid payload, or to the
|
||||||
|
bundled game, in that case.
|
||||||
|
|
||||||
|
## Update flow
|
||||||
|
|
||||||
|
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
|
||||||
|
and probe every `updates/*.love`, pick the highest engine that is
|
||||||
|
strictly newer than the bundled one and whose `minShell` this shell
|
||||||
|
satisfies, delete stale payloads, chainload the winner (or run the
|
||||||
|
bundled game if none qualifies).
|
||||||
|
2. **Check** (launcher screen): `Check.start()` kicks off an async check
|
||||||
|
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||||
|
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||||
|
reports `idle | checking | uptodate | available | downloading | ready |
|
||||||
|
needs_full | error` plus the latest version and download progress.
|
||||||
|
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||||
|
worker to fetch the payload, polling the growing `.part` file for
|
||||||
|
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||||
|
the payload's sha256, and probes it with `Boot.probePayload` to gate its
|
||||||
|
`minShell` against this shell's `shell`. A verified, runnable payload is
|
||||||
|
renamed into place and reported as `ready`; anything else reports
|
||||||
|
`error` or `needs_full` and leaves `updates/` clean.
|
||||||
|
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
||||||
|
player relaunches; the next launch's Boot step (1) is what actually
|
||||||
|
mounts and runs it. There is no in-session hot-swap.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **`love.run` persists across handoff.** By the time `chainload` runs, the
|
||||||
|
bundled `love.run` has already returned its stepper to LOVE; redefining the
|
||||||
|
global `love.run` from the payload's `main.lua` does not affect the loop
|
||||||
|
already driving the frame. A payload that must change `love.run` itself
|
||||||
|
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||||
|
than running with half its intended behavior.
|
||||||
|
- **Android has no in-app download transport yet.** `check_worker.lua`
|
||||||
|
shells out to curl for both the release check and the download; curl is
|
||||||
|
absent on Android, so `Check` degrades to `status = "error"` there (the
|
||||||
|
launcher UI hides on that status) and the player is directed to the
|
||||||
|
releases page via `Check.releaseUrl()` instead.
|
||||||
|
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||||
|
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||||
|
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||||
|
checkout is always "the game" itself; updating it means pulling the repo.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# After first boot of the freshly flashed Stock OS Mod, reinsert the SD card
|
||||||
|
# and run this to install gen1recomp + Red/Blue ROMs into roms/PORTS.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
STAGE="$ROOT/.bazinga/work/rg34xxsp-install"
|
||||||
|
DECPREP="$(cd "$ROOT/../decprep" && pwd)"
|
||||||
|
ZIP="$ROOT/dist/rg34xxsp/gen1recomp-rg34xxsp.zip"
|
||||||
|
|
||||||
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Find the Anbernic user/ROMs volume (usually "NO NAME" after first-boot expand).
|
||||||
|
find_roms_root() {
|
||||||
|
local v candidate
|
||||||
|
for v in /Volumes/*; do
|
||||||
|
[ -d "$v" ] || continue
|
||||||
|
# Prefer a volume that already has Roms/ or PORTS/
|
||||||
|
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ]; then
|
||||||
|
echo "$v"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Fallback: large FAT volume named NO NAME on disk8
|
||||||
|
for v in "/Volumes/NO NAME" /Volumes/NO\ NAME /Volumes/ROMS /Volumes/EASYROMS; do
|
||||||
|
if [ -d "$v" ]; then
|
||||||
|
echo "$v"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
say "looking for Anbernic ROMs volume"
|
||||||
|
ROMS_ROOT="$(find_roms_root)" || fail "no ROMs volume mounted. Boot the RG34XXSP once (wait for first-boot setup), power off, reinsert the SD, then rerun."
|
||||||
|
|
||||||
|
say "using: $ROMS_ROOT"
|
||||||
|
# Resolve PORTS dir (stock uses Roms/PORTS)
|
||||||
|
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||||
|
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/roms/PORTS"
|
||||||
|
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/Roms/ports"
|
||||||
|
elif [ -d "$ROMS_ROOT/PORTS" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/PORTS"
|
||||||
|
else
|
||||||
|
mkdir -p "$ROMS_ROOT/Roms/PORTS"
|
||||||
|
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||||
|
fi
|
||||||
|
say "PORTS: $PORTS"
|
||||||
|
|
||||||
|
# Refresh staged payload
|
||||||
|
mkdir -p "$STAGE/PORTS"
|
||||||
|
if [ -f "$ZIP" ]; then
|
||||||
|
rm -rf "$STAGE/PORTS/Gen1recomp.sh" "$STAGE/PORTS/gen1recomp" "$STAGE/PORTS/port.json" \
|
||||||
|
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
|
||||||
|
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
|
||||||
|
else
|
||||||
|
fail "missing $ZIP — run ./build-rg34xxsp.sh first"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure ROMs are in lovegame (Choose ROM scans this folder on stock OS)
|
||||||
|
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
|
||||||
|
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
|
||||||
|
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
|
||||||
|
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
|
||||||
|
|
||||||
|
say "copying gen1recomp port"
|
||||||
|
rm -rf "$PORTS/gen1recomp" "$PORTS/Gen1recomp.sh"
|
||||||
|
cp -R "$STAGE/PORTS/gen1recomp" "$PORTS/"
|
||||||
|
cp -f "$STAGE/PORTS/Gen1recomp.sh" "$PORTS/"
|
||||||
|
cp -f "$STAGE/PORTS/port.json" "$PORTS/gen1recomp/" 2>/dev/null || true
|
||||||
|
cp -f "$STAGE/PORTS/README.md" "$PORTS/gen1recomp/" 2>/dev/null || true
|
||||||
|
chmod +x "$PORTS/Gen1recomp.sh" "$PORTS/gen1recomp/bin/love.aarch64"
|
||||||
|
|
||||||
|
# Also drop carts in the stock GB folder for the emulator library
|
||||||
|
GB_DIR=""
|
||||||
|
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
|
||||||
|
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
|
||||||
|
done
|
||||||
|
if [ -n "$GB_DIR" ]; then
|
||||||
|
say "copying .gb into $GB_DIR"
|
||||||
|
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
|
||||||
|
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sync
|
||||||
|
say "installed:"
|
||||||
|
ls -lh "$PORTS/Gen1recomp.sh"
|
||||||
|
ls -lh "$PORTS/gen1recomp/lovegame/"*.gb
|
||||||
|
say "eject the SD, insert TF1 in the RG34XXSP, open Ports → Gen1recomp, Choose ROM."
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
||||||
-- game-data cache from a user-provided ROM on first boot.
|
-- game-data cache from a user-provided ROM on first boot.
|
||||||
--
|
--
|
||||||
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
|
-- The save editor (tools/save-editor/) ships inside every build and is
|
||||||
-- editor tool (tools/save-editor/) instead of the game.
|
-- reachable two ways:
|
||||||
|
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
|
||||||
|
-- * from the launcher: Edit on a save row, which suspends the launcher,
|
||||||
|
-- opens the editor on that slot's file, and restores the launcher when
|
||||||
|
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||||
|
|
||||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||||
|
|
||||||
@@ -18,6 +22,11 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
|||||||
-- bot or screenshot run is not at the mercy of the player's last choice.
|
-- bot or screenshot run is not at the mercy of the player's last choice.
|
||||||
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
||||||
|
|
||||||
|
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
|
||||||
|
-- mouse stand in for a finger, so the overlay can be exercised on desktop
|
||||||
|
-- (see src/core/TouchControls.lua).
|
||||||
|
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
|
||||||
|
|
||||||
-- How many times to run a scripted act+step loop per rendered frame. Only
|
-- How many times to run a scripted act+step loop per rendered frame. Only
|
||||||
-- scripted runs use this; interactive play fast-forwards through
|
-- scripted runs use this; interactive play fast-forwards through
|
||||||
-- Game.speedOverride / the GAME SPEED option instead.
|
-- Game.speedOverride / the GAME SPEED option instead.
|
||||||
@@ -26,6 +35,105 @@ local function scriptedIterations()
|
|||||||
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------ save editor
|
||||||
|
-- The launcher instance parked while the editor is up, plus the version whose
|
||||||
|
-- cache the editor mounted (so closing can put the read path back).
|
||||||
|
local editorHost, editorVersion, editorWindow
|
||||||
|
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||||
|
|
||||||
|
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||||
|
-- their directories have to be on the require path. It must be
|
||||||
|
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||||
|
-- live inside the .love archive, which the stock Lua searcher cannot open.
|
||||||
|
local function addEditorRequirePath()
|
||||||
|
local fs = love.filesystem
|
||||||
|
if not (fs.setRequirePath and fs.getRequirePath) then
|
||||||
|
-- very old LOVE: a source checkout still resolves through package.path
|
||||||
|
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
|
||||||
|
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
|
||||||
|
.. package.path
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local current = fs.getRequirePath()
|
||||||
|
if current:find("tools/save%-editor") then return end
|
||||||
|
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
|
||||||
|
.. current)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
|
||||||
|
-- design size, so grow it while editing and put it back on Close. Never
|
||||||
|
-- shrinks, never touches a fullscreen or mobile window.
|
||||||
|
local function resizeForEditor()
|
||||||
|
if not (love.window and love.window.getMode and love.window.setMode) then return end
|
||||||
|
local osName = love.system.getOS()
|
||||||
|
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
|
||||||
|
local w, h, flags = love.window.getMode()
|
||||||
|
if flags.fullscreen then return end
|
||||||
|
local dw, dh = love.window.getDesktopDimensions()
|
||||||
|
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
|
||||||
|
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
|
||||||
|
if wantW <= w and wantH <= h then return end
|
||||||
|
editorWindow = { w = w, h = h }
|
||||||
|
love.window.setMode(wantW, wantH, flags)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreWindow()
|
||||||
|
if not editorWindow then return end
|
||||||
|
local _, _, flags = love.window.getMode()
|
||||||
|
love.window.setMode(editorWindow.w, editorWindow.h, flags)
|
||||||
|
editorWindow = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Open the editor on a launcher save row. The version's cache has to be
|
||||||
|
-- mounted before the editor's Data:load runs, or a Blue save would be edited
|
||||||
|
-- against Red's species/item tables.
|
||||||
|
local function openEditor(version, slotId)
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local path = SaveData.slotDiskPath(version, slotId)
|
||||||
|
if not path then
|
||||||
|
if Importer then
|
||||||
|
Importer.saveNotice = Importer.saveNotice or {}
|
||||||
|
Importer.saveNotice[version] =
|
||||||
|
{ ok = false, text = "Could not resolve that save slot on disk." }
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
GameVersion.set(version)
|
||||||
|
require("src.import.CacheFs").mountVersion(version)
|
||||||
|
editorVersion = version
|
||||||
|
editorHost = Importer
|
||||||
|
Importer = nil
|
||||||
|
editorMode = true
|
||||||
|
resizeForEditor()
|
||||||
|
addEditorRequirePath()
|
||||||
|
EditorApp = require("App")
|
||||||
|
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
||||||
|
onClose = function() closeEditor() end })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||||
|
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||||
|
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||||
|
-- with this one's data.
|
||||||
|
function closeEditor()
|
||||||
|
local version = editorVersion
|
||||||
|
editorMode = false
|
||||||
|
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
||||||
|
EditorApp = nil
|
||||||
|
if version then
|
||||||
|
require("src.import.CacheFs").unmountVersion(version)
|
||||||
|
require("src.core.Data"):unloadGenerated()
|
||||||
|
end
|
||||||
|
editorVersion = nil
|
||||||
|
restoreWindow()
|
||||||
|
Importer = editorHost
|
||||||
|
editorHost = nil
|
||||||
|
if Importer and version and Importer.savesChanged then
|
||||||
|
Importer:savesChanged(version)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function bootGame(version)
|
local function bootGame(version)
|
||||||
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
|
-- 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
|
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
|
||||||
@@ -56,6 +164,12 @@ local function bootGame(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.load(args)
|
function love.load(args)
|
||||||
|
-- Self-updater boot shell: a fused build may mount and chainload a newer
|
||||||
|
-- downloaded payload here. True means it took over, so we must stop. A
|
||||||
|
-- dev / source checkout no-ops (see src/update/Boot.lua).
|
||||||
|
local Boot = require("src.update.Boot")
|
||||||
|
if Boot.run(args) then return end
|
||||||
|
|
||||||
local savePath
|
local savePath
|
||||||
for i, a in ipairs(args or {}) do
|
for i, a in ipairs(args or {}) do
|
||||||
if a == "--editor" then
|
if a == "--editor" then
|
||||||
@@ -70,12 +184,17 @@ function love.load(args)
|
|||||||
end
|
end
|
||||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||||
|
|
||||||
|
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
|
||||||
|
-- Close quits; --save points it at a specific file, otherwise it opens the
|
||||||
|
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
|
||||||
|
-- cache has to be mounted before the editor's Data:load.
|
||||||
if editorMode then
|
if editorMode then
|
||||||
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
|
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||||
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
|
require("src.core.GameVersion").set(version)
|
||||||
.. package.path
|
require("src.import.CacheFs").mountVersion(version)
|
||||||
|
addEditorRequirePath()
|
||||||
EditorApp = require("App")
|
EditorApp = require("App")
|
||||||
EditorApp.load(savePath)
|
EditorApp.load(savePath, { version = version })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -116,10 +235,11 @@ function love.load(args)
|
|||||||
-- column shows Play when that game's ROM is already imported, or Choose ROM
|
-- 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
|
-- / 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.
|
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
|
||||||
|
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||||
Importer = RomImporter.new(function(version)
|
Importer = RomImporter.new(function(version)
|
||||||
Importer = nil
|
Importer = nil
|
||||||
bootGame(version)
|
bootGame(version)
|
||||||
end, { launcher = true, forceImport = forceImport })
|
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
@@ -195,19 +315,19 @@ end
|
|||||||
|
|
||||||
function love.gamepadpressed(joystick, button)
|
function love.gamepadpressed(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadreleased(joystick, button)
|
function love.gamepadreleased(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadaxis(joystick, axis, value)
|
function love.gamepadaxis(joystick, axis, value)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -268,6 +388,9 @@ function love.mousepressed(x, y, button)
|
|||||||
if editorMode and EditorApp.mousepressed then
|
if editorMode and EditorApp.mousepressed then
|
||||||
return EditorApp.mousepressed(x, y, button)
|
return EditorApp.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
|
if mouseTouch and Game and button == 1 then
|
||||||
|
Game:touchpressed("mouse", x, y)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.mousereleased(x, y, button)
|
function love.mousereleased(x, y, button)
|
||||||
@@ -275,10 +398,20 @@ function love.mousereleased(x, y, button)
|
|||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
end
|
end
|
||||||
|
if mouseTouch and Game and button == 1 then
|
||||||
|
Game:touchreleased("mouse", x, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.mousemoved(x, y)
|
||||||
|
if editorMode or Importer then return end
|
||||||
|
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||||
|
Game:touchmoved("mouse", x, y)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
if Importer then return end
|
if Importer then return Importer:textinput(text) end
|
||||||
if editorMode and EditorApp.textinput then
|
if editorMode and EditorApp.textinput then
|
||||||
return EditorApp.textinput(text)
|
return EditorApp.textinput(text)
|
||||||
end
|
end
|
||||||
@@ -299,3 +432,78 @@ function love.filedropped(file)
|
|||||||
end
|
end
|
||||||
if Importer then Importer:filedropped(file) end
|
if Importer then Importer:filedropped(file) end
|
||||||
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
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ The embedded `game.love` deliberately excludes `data/generated/`,
|
|||||||
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
||||||
`tools/rom_manifest.json`.
|
`tools/rom_manifest.json`.
|
||||||
|
|
||||||
The current importer has desktop file pickers only. A production Android
|
ROM / mod / save import on Android uses `love.system.pickFile([kind])` →
|
||||||
release still needs a Storage Access Framework handoff that passes the chosen
|
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
|
||||||
ROM to LÖVE; the APK packaging itself is data-free.
|
chosen file under the app save directory as `picked_rom.gb`,
|
||||||
|
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
|
||||||
|
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||||
|
itself remains data-free (no embedded ROM or generated cache).
|
||||||
|
|
||||||
### SDK / NDK
|
### SDK / NDK
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
|
||||||
|
backend (luasocket TCP) are ordinary sockets, and Android denies those
|
||||||
|
to a UID without INTERNET. Bind and connect then come back EPERM, which
|
||||||
|
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||||
|
scripts/build_android.sh must not strip this again. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- OpenGL ES 2.0 -->
|
<!-- OpenGL ES 2.0 -->
|
||||||
<uses-feature android:glEsVersion="0x00020000" />
|
<uses-feature android:glEsVersion="0x00020000" />
|
||||||
<!-- Touchscreen support -->
|
<!-- Touchscreen support -->
|
||||||
|
|||||||
@@ -18,4 +18,4 @@ android.useAndroidX=true
|
|||||||
android.defaults.buildfeatures.buildconfig=true
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.nonFinalResIds=true
|
android.nonFinalResIds=true
|
||||||
app.name=Pokemon Red
|
app.name=gen1recomp
|
||||||
|
|||||||
@@ -183,13 +183,37 @@ void vibrate(double seconds)
|
|||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool showFilePicker()
|
bool showFilePicker(const char *destFilename)
|
||||||
{
|
{
|
||||||
|
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||||
|
destFilename = "picked_rom.gb";
|
||||||
|
|
||||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
|
||||||
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
|
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
"(Ljava/lang/String;)Z");
|
||||||
|
jstring jname = env->NewStringUTF(destFilename);
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||||
|
env->DeleteLocalRef(jname);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool showCreateDocument(const char *suggestedName)
|
||||||
|
{
|
||||||
|
if (suggestedName == nullptr || suggestedName[0] == '\0')
|
||||||
|
suggestedName = "export.sav";
|
||||||
|
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||||
|
"(Ljava/lang/String;)Z");
|
||||||
|
jstring jname = env->NewStringUTF(suggestedName);
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||||
|
env->DeleteLocalRef(jname);
|
||||||
|
|
||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -63,9 +63,16 @@ void vibrate(double seconds);
|
|||||||
* Shows the system's "pick a document" UI (Storage Access Framework).
|
* Shows the system's "pick a document" UI (Storage Access Framework).
|
||||||
* Returns true if the picker was launched; the picked file (if any) is
|
* Returns true if the picker was launched; the picked file (if any) is
|
||||||
* copied asynchronously by GameActivity.onActivityResult into the app's
|
* copied asynchronously by GameActivity.onActivityResult into the app's
|
||||||
* external save directory, not returned here -- see src/import/RomImporter.lua.
|
* external save directory under destFilename (default picked_rom.gb), not
|
||||||
|
* returned here -- see src/import/RomImporter.lua.
|
||||||
**/
|
**/
|
||||||
bool showFilePicker();
|
bool showFilePicker(const char *destFilename = nullptr);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
|
||||||
|
* to a user-chosen location. suggestedName is the dialog default filename.
|
||||||
|
**/
|
||||||
|
bool showCreateDocument(const char *suggestedName = nullptr);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper functions for the filesystem module
|
* Helper functions for the filesystem module
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
#include "common/config.h"
|
#include "common/config.h"
|
||||||
#include "System.h"
|
#include "System.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
#if defined(LOVE_MACOSX)
|
#if defined(LOVE_MACOSX)
|
||||||
#include <CoreServices/CoreServices.h>
|
#include <CoreServices/CoreServices.h>
|
||||||
#elif defined(LOVE_IOS)
|
#elif defined(LOVE_IOS)
|
||||||
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool System::pickFile() const
|
bool System::pickFile(const char *kind) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
return love::android::showFilePicker();
|
const char *dest = "picked_rom.gb";
|
||||||
|
if (kind != nullptr)
|
||||||
|
{
|
||||||
|
if (strcmp(kind, "mod") == 0)
|
||||||
|
dest = "picked_mod.zip";
|
||||||
|
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||||
|
dest = "picked_save.sav";
|
||||||
|
else if (strcmp(kind, "rom") == 0)
|
||||||
|
dest = "picked_rom.gb";
|
||||||
|
}
|
||||||
|
return love::android::showFilePicker(dest);
|
||||||
#else
|
#else
|
||||||
|
LOVE_UNUSED(kind);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool System::createFile(const char *suggestedName) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::showCreateDocument(suggestedName);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(suggestedName);
|
||||||
return false;
|
return false;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,9 +111,21 @@ public:
|
|||||||
* Android only for now; the result (if any) is not returned here -- see
|
* Android only for now; the result (if any) is not returned here -- see
|
||||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||||
*
|
*
|
||||||
|
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||||
|
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||||
* @return Whether the picker was shown.
|
* @return Whether the picker was shown.
|
||||||
**/
|
**/
|
||||||
virtual bool pickFile() const;
|
virtual bool pickFile(const char *kind = nullptr) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||||
|
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
|
||||||
|
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
|
||||||
|
*
|
||||||
|
* @param suggestedName Default filename shown in the dialog.
|
||||||
|
* @return Whether the create dialog was shown.
|
||||||
|
**/
|
||||||
|
virtual bool createFile(const char *suggestedName = nullptr) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets if the user is playing music on background.
|
* Gets if the user is playing music on background.
|
||||||
|
|||||||
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
|
|||||||
|
|
||||||
int w_pickFile(lua_State *L)
|
int w_pickFile(lua_State *L)
|
||||||
{
|
{
|
||||||
luax_pushboolean(L, instance()->pickFile());
|
const char *kind = luaL_optstring(L, 1, nullptr);
|
||||||
|
luax_pushboolean(L, instance()->pickFile(kind));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int w_createFile(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||||
|
luax_pushboolean(L, instance()->createFile(suggested));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
|
|||||||
{ "openURL", w_openURL },
|
{ "openURL", w_openURL },
|
||||||
{ "vibrate", w_vibrate },
|
{ "vibrate", w_vibrate },
|
||||||
{ "pickFile", w_pickFile },
|
{ "pickFile", w_pickFile },
|
||||||
|
{ "createFile", w_createFile },
|
||||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||||
{ 0, 0 }
|
{ 0, 0 }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ package org.love2d.android;
|
|||||||
|
|
||||||
import org.libsdl.app.SDLActivity;
|
import org.libsdl.app.SDLActivity;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
import java.io.BufferedOutputStream;
|
import java.io.BufferedOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
|
|||||||
protected final int[] recordAudioRequestDummy = new int[1];
|
protected final int[] recordAudioRequestDummy = new int[1];
|
||||||
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
||||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||||
public static final int ROM_PICKER_REQUEST_CODE = 4;
|
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
||||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
|
public static final int FILE_CREATE_REQUEST_CODE = 5;
|
||||||
|
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
|
||||||
|
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
|
||||||
|
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
|
||||||
// is dropped so RomImporter's existing folder scan finds it -- see
|
// is dropped so RomImporter's existing folder scan finds it -- see
|
||||||
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
|
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
|
||||||
// save directory to getExternalFilesDir()/save/<identity>).
|
// save directory to getExternalFilesDir()/save/<identity>).
|
||||||
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
|
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
|
||||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||||
|
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
|
||||||
|
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||||
|
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||||
|
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||||
|
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||||
|
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||||
|
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||||
|
private String pendingCreateSuggestedName = "export.sav";
|
||||||
private static boolean immersiveActive = false;
|
private static boolean immersiveActive = false;
|
||||||
private static boolean needToCopyGameInArchive = false;
|
private static boolean needToCopyGameInArchive = false;
|
||||||
private boolean storagePermissionUnnecessary = false;
|
private boolean storagePermissionUnnecessary = false;
|
||||||
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the system document picker (Storage Access Framework) so the
|
* Shows the system document picker (Storage Access Framework) so the
|
||||||
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
|
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
|
||||||
* without needing to know where the app's external files folder is.
|
* etc.) without needing to know where the app's external files folder
|
||||||
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||||
* arrives later in onActivityResult, not synchronously here.
|
* arrives later in onActivityResult, not synchronously here.
|
||||||
|
*
|
||||||
|
* @param destFilename basename under the app save identity (e.g.
|
||||||
|
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||||
*/
|
*/
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean showRomFilePicker() {
|
public static boolean showFilePicker(String destFilename) {
|
||||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null) return false;
|
if (self == null) return false;
|
||||||
|
if (destFilename == null || destFilename.length() == 0) {
|
||||||
|
destFilename = PICKED_ROM_FILENAME;
|
||||||
|
}
|
||||||
|
// Reject path separators so a hostile JNI caller cannot escape the
|
||||||
|
// save identity directory.
|
||||||
|
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||||
|
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pendingPickFilename = destFilename;
|
||||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
intent.setType("*/*");
|
intent.setType("*/*");
|
||||||
try {
|
try {
|
||||||
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
|
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
|
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
|
||||||
|
@Keep
|
||||||
|
public static boolean showRomFilePicker() {
|
||||||
|
return showFilePicker(PICKED_ROM_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
|
||||||
|
@Keep
|
||||||
|
public static boolean showModFilePicker() {
|
||||||
|
return showFilePicker(PICKED_MOD_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
|
||||||
|
@Keep
|
||||||
|
public static boolean showSaveFilePicker() {
|
||||||
|
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||||
|
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||||
|
* etc. Suggested name is the dialog's default filename.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean showCreateDocument(String suggestedName) {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return false;
|
||||||
|
if (suggestedName == null || suggestedName.length() == 0) {
|
||||||
|
suggestedName = "export.sav";
|
||||||
|
}
|
||||||
|
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
|
||||||
|
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
File source = new File(
|
||||||
|
new File(self.getExternalFilesDir(null), "save"),
|
||||||
|
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||||
|
if (!source.isFile()) {
|
||||||
|
Log.d("GameActivity", "no pending export at " + source);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pendingCreateSuggestedName = suggestedName;
|
||||||
|
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
|
intent.setType("application/octet-stream");
|
||||||
|
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
|
||||||
|
try {
|
||||||
|
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private File saveIdentityDir() {
|
||||||
|
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean copyFileToUri(File source, Uri destUri) {
|
||||||
|
InputStream in = null;
|
||||||
|
OutputStream out = null;
|
||||||
|
try {
|
||||||
|
in = new BufferedInputStream(new FileInputStream(source));
|
||||||
|
out = getContentResolver().openOutputStream(destUri);
|
||||||
|
if (out == null) return false;
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) {
|
||||||
|
out.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
out.flush();
|
||||||
|
return true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
try { if (in != null) in.close(); } catch (IOException ignored) {}
|
||||||
|
try { if (out != null) out.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
|
if (requestCode == FILE_CREATE_REQUEST_CODE) {
|
||||||
|
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
||||||
|
Log.d("GameActivity", "create-document cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||||
|
if (!source.isFile()) {
|
||||||
|
Log.d("GameActivity", "pending export missing at result time");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri uri = data.getData();
|
||||||
|
if (copyFileToUri(source, uri)) {
|
||||||
|
// Signal Lua on next focus that the SAF export finished.
|
||||||
|
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
||||||
|
try {
|
||||||
|
FileOutputStream fos = new FileOutputStream(flag, false);
|
||||||
|
fos.write("ok".getBytes());
|
||||||
|
fos.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
|
||||||
|
}
|
||||||
|
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||||
|
} else {
|
||||||
|
Log.d("GameActivity", "could not write export to " + uri);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
|
||||||
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
||||||
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
|
Log.d("GameActivity", "file picker returned no file (cancelled?)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri uri = data.getData();
|
Uri uri = data.getData();
|
||||||
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
File destDir = saveIdentityDir();
|
||||||
if (!destDir.exists() && !destDir.mkdirs()) {
|
if (!destDir.exists() && !destDir.mkdirs()) {
|
||||||
Log.d("GameActivity", "could not create " + destDir);
|
Log.d("GameActivity", "could not create " + destDir);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
File destFile = new File(destDir, PICKED_ROM_FILENAME);
|
String destName = pendingPickFilename != null
|
||||||
|
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||||
|
File destFile = new File(destDir, destName);
|
||||||
|
|
||||||
InputStream source;
|
InputStream source;
|
||||||
try {
|
try {
|
||||||
source = getContentResolver().openInputStream(uri);
|
source = getContentResolver().openInputStream(uri);
|
||||||
} catch (FileNotFoundException e) {
|
} catch (FileNotFoundException e) {
|
||||||
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
|
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
|
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!copyAssetFile(source, destFile.getPath())) {
|
if (!copyAssetFile(source, destFile.getPath())) {
|
||||||
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
|
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "beginner", "cosmetic", "data-only", "legacy" },
|
tags = { "beginner", "cosmetic", "data-only", "legacy" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Example mod gallery
|
# Example mod gallery
|
||||||
|
|
||||||
Seven reference mods, one per modder persona. Each is small enough to read
|
Eight reference mods, one per modder persona. Each is small enough to read
|
||||||
in one sitting, exercises a different slice of the mod API, and is a real,
|
in one sitting, exercises a different slice of the mod API, and is a real,
|
||||||
runnable, tested mod — not a snippet.
|
runnable, tested mod — not a snippet.
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
|
|||||||
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
|
| 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 |
|
| 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 |
|
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
|
||||||
|
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
|
||||||
|
|
||||||
## None of these load by default
|
## None of these load by default
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ and `field`, and exercises:
|
|||||||
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
||||||
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
||||||
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
||||||
`game.ready` and `assets.transformed`
|
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
|
||||||
|
`intro.oak_speech.answered` and `intro.oak_speech.finished`
|
||||||
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
|
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
|
||||||
`mod:read`, `mod.log` and `mod.path`
|
`mod:read`, `mod.log` and `mod.path`
|
||||||
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
|
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
|
||||||
@@ -66,7 +68,7 @@ mods/examples/<id>/
|
|||||||
.modkitignore keeps the suite out of the distributed package
|
.modkitignore keeps the suite out of the distributed package
|
||||||
```
|
```
|
||||||
|
|
||||||
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
|
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
|
||||||
together and asserts the above, so the gallery cannot rot.
|
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.
|
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "BALANCE",
|
"category": "BALANCE",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "balance", "data-only", "beginner" },
|
tags = { "balance", "data-only", "beginner" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "TOOL",
|
"category": "TOOL",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "ui", "tool", "quality-of-life" },
|
tags = { "ui", "tool", "quality-of-life" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "AUDIO",
|
"category": "AUDIO",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"permissions": ["engine_internals"],
|
"permissions": ["engine_internals"],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "audio", "chiptune", "ui" },
|
tags = { "audio", "chiptune", "ui" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "QUEST",
|
"category": "QUEST",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"permissions": ["engine_internals"],
|
"permissions": ["engine_internals"],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "quest", "story", "scripting" },
|
tags = { "quest", "story", "scripting" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "total_conversion",
|
"profile": "total_conversion",
|
||||||
"category": "TOTAL_CONVERSION",
|
"category": "TOTAL_CONVERSION",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 900,
|
"priority": 900,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "total-conversion", "capstone" },
|
tags = { "total-conversion", "capstone" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "GRAPHICS",
|
"category": "GRAPHICS",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"assets_transforms": "transforms.lua",
|
"assets_transforms": "transforms.lua",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "cosmetic", "graphics", "beginner" },
|
tags = { "cosmetic", "graphics", "beginner" },
|
||||||
screenshots = {
|
screenshots = {
|
||||||
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
Version headings match `manifest.json`'s `version`.
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||||
|
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||||
|
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Silly Oak Intro Example
|
||||||
|
|
||||||
|
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||||
|
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||||
|
|
||||||
|
## Try it (play through yourself)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
love .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||||
|
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||||
|
|
||||||
|
## Headless check
|
||||||
|
|
||||||
|
```sh
|
||||||
|
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto driver (screenshots + save asserts)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||||
|
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||||
|
```
|
||||||
|
|
||||||
|
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||||
|
|
||||||
|
## What it demonstrates
|
||||||
|
|
||||||
|
| Seam | Where |
|
||||||
|
|---|---|
|
||||||
|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
|
||||||
|
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
|
||||||
|
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||||
|
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||||
|
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||||
|
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||||
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,107 @@
|
|||||||
|
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
|
||||||
|
-- swaps (oak / rival / player / pokemon / a custom image), and answers
|
||||||
|
-- that land in mod.save.
|
||||||
|
--
|
||||||
|
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
|
||||||
|
|
||||||
|
return function(mod)
|
||||||
|
local toastPic = mod.path .. "/assets/toast_kid.png"
|
||||||
|
|
||||||
|
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||||
|
steps = next(steps, speech)
|
||||||
|
|
||||||
|
-- after oak says hello, immediately derail
|
||||||
|
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||||
|
id = "silly_quiz_intro",
|
||||||
|
kind = "say",
|
||||||
|
pic = "oak",
|
||||||
|
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
|
||||||
|
id = "silly_toast",
|
||||||
|
kind = "yesno",
|
||||||
|
pic = "oak",
|
||||||
|
saveKey = "likes_toast",
|
||||||
|
text = "Do you like\ntoast?",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- brand new sprite mid-speech
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_toast", {
|
||||||
|
id = "silly_toast_kid",
|
||||||
|
kind = "say",
|
||||||
|
pic = { type = "image", path = toastPic },
|
||||||
|
reveal = "fade",
|
||||||
|
saveKey = nil,
|
||||||
|
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- existing mon with a wipe + cry, parked after the real demo mon
|
||||||
|
mod.ui.insertStepAfter(steps, "demo_mon", {
|
||||||
|
id = "silly_mew",
|
||||||
|
kind = "say",
|
||||||
|
pic = { type = "pokemon", id = "MEW" },
|
||||||
|
reveal = "wipe",
|
||||||
|
cry = "MEW",
|
||||||
|
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_mew", {
|
||||||
|
id = "silly_snack",
|
||||||
|
kind = "choice",
|
||||||
|
pic = "oak",
|
||||||
|
saveKey = "snack",
|
||||||
|
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
|
||||||
|
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- swap to rival pic for a loaded question before naming him
|
||||||
|
mod.ui.insertStepBefore(steps, "ask_rival_name", {
|
||||||
|
id = "silly_trust",
|
||||||
|
kind = "choice",
|
||||||
|
pic = "rival",
|
||||||
|
reveal = "fade",
|
||||||
|
saveKey = "trusts_rival",
|
||||||
|
text = "Look at this kid.\nTrustworthy?",
|
||||||
|
choices = { "SURE", "NO" },
|
||||||
|
values = { true, false },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- player pic for one last bit after both names are set
|
||||||
|
mod.ui.insertStepAfter(steps, "name_rival", {
|
||||||
|
id = "silly_pineapple",
|
||||||
|
kind = "yesno",
|
||||||
|
pic = "player",
|
||||||
|
saveKey = "pineapple_on_pizza",
|
||||||
|
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_pineapple", {
|
||||||
|
id = "silly_closing",
|
||||||
|
kind = "say",
|
||||||
|
pic = "oak",
|
||||||
|
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return steps
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- every answered step with a saveKey lands in mod.save (and therefore
|
||||||
|
-- save.modData[mod.id] once the slot is written)
|
||||||
|
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||||
|
if not ev.saveKey then return end
|
||||||
|
mod.save:set(ev.saveKey, ev.value)
|
||||||
|
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
|
||||||
|
end)
|
||||||
|
|
||||||
|
mod.events:on("intro.oak_speech.finished", function(ev)
|
||||||
|
local answers = ev.answers or {}
|
||||||
|
for key, value in pairs(answers) do
|
||||||
|
if mod.save:get(key) == nil then
|
||||||
|
mod.save:set(key, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
mod.save:set("quiz_done", true)
|
||||||
|
mod.log:info("silly oak quiz done")
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "example_silly_oak",
|
||||||
|
"name": "Silly Oak Intro Example",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"api": 2,
|
||||||
|
"entry": "main.lua",
|
||||||
|
"profile": "content",
|
||||||
|
"category": "UI",
|
||||||
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
|
"priority": 100,
|
||||||
|
"dependencies": [],
|
||||||
|
"optional_dependencies": [],
|
||||||
|
"conflicts": [],
|
||||||
|
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Sharing metadata for the manager detail pane.
|
||||||
|
return {
|
||||||
|
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||||
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
|
tags = { "intro", "ui", "oak", "hooks" },
|
||||||
|
differences = {
|
||||||
|
changed = {
|
||||||
|
"Oak's NEW GAME speech gains extra questions and sprite beats",
|
||||||
|
},
|
||||||
|
added = {
|
||||||
|
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
|
||||||
|
"Custom Toast Kid pic mid-intro",
|
||||||
|
},
|
||||||
|
known = { "vanilla naming and the shrink-away still run" },
|
||||||
|
},
|
||||||
|
credits = {
|
||||||
|
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
|
||||||
|
},
|
||||||
|
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||||
|
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
|
||||||
|
-- and answers landing in mod.save.
|
||||||
|
--
|
||||||
|
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
|
||||||
|
-- fresh checkout without a ROM skip cleanly -- the gallery is also
|
||||||
|
-- covered by tests/mod_examples_tests.lua when generated data is present.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local function hasGenerated()
|
||||||
|
local handle = io.open("data/generated/constants.lua", "r")
|
||||||
|
if handle then handle:close() return true end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not hasGenerated() then
|
||||||
|
print("example_silly_oak_test skipped (needs data/generated/)")
|
||||||
|
os.exit(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local OakSpeech = require("src.ui.OakSpeech")
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
Data:load()
|
||||||
|
|
||||||
|
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
|
||||||
|
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
|
||||||
|
local mod = run.mod
|
||||||
|
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
|
||||||
|
local ModUI = require("src.ui.ModUI")
|
||||||
|
local bucket = function()
|
||||||
|
return run.loader.modSave.example_silly_oak or {}
|
||||||
|
end
|
||||||
|
local toastPath = (mod.path or "mods/examples/example_silly_oak")
|
||||||
|
.. "/assets/toast_kid.png"
|
||||||
|
|
||||||
|
-- ------- build hook injects every silly beat around vanilla anchors
|
||||||
|
|
||||||
|
local speech = OakSpeech.new({
|
||||||
|
data = Data,
|
||||||
|
save = { player = { name = "RED", rival = "BLUE" } },
|
||||||
|
stack = { push = function() end, pop = function() end },
|
||||||
|
}, nil)
|
||||||
|
local steps = speech:buildSteps()
|
||||||
|
|
||||||
|
local ids = {}
|
||||||
|
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
|
||||||
|
local function has(id)
|
||||||
|
for _, x in ipairs(ids) do if x == id then return true end end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
|
||||||
|
"vanilla anchors still present")
|
||||||
|
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
|
||||||
|
"toast quiz beats injected")
|
||||||
|
T.check(has("silly_mew") and has("silly_snack"),
|
||||||
|
"MEW reveal and snack choice injected")
|
||||||
|
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
|
||||||
|
"rival trust + pineapple beats injected")
|
||||||
|
|
||||||
|
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
|
||||||
|
local function indexOf(id)
|
||||||
|
for i, x in ipairs(ids) do if x == id then return i end end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
|
||||||
|
"Toast Kid shows before the demo mon")
|
||||||
|
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
|
||||||
|
"MEW shows after the demo mon")
|
||||||
|
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
|
||||||
|
"trust question is before rival naming")
|
||||||
|
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
|
||||||
|
and indexOf("silly_pineapple") < indexOf("legend"),
|
||||||
|
"pineapple lands between rival name and the legend beat")
|
||||||
|
|
||||||
|
-- ------- step shapes cover choice / yesno / custom image / pokemon
|
||||||
|
|
||||||
|
local byId = {}
|
||||||
|
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||||
|
|
||||||
|
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
|
||||||
|
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
|
||||||
|
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
|
||||||
|
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
|
||||||
|
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
|
||||||
|
"Toast Kid uses a custom image pic")
|
||||||
|
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
|
||||||
|
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
|
||||||
|
"MEW beat uses pokemon pic + cry")
|
||||||
|
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
|
||||||
|
|
||||||
|
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
|
||||||
|
|
||||||
|
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
|
||||||
|
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
|
||||||
|
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
|
||||||
|
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
|
||||||
|
{ type = "pokemon", id = "MEW", flip = true }, speech)
|
||||||
|
local customImg = OakSpeech.resolvePic({ data = Data },
|
||||||
|
{ type = "image", path = toastPath }, speech)
|
||||||
|
-- headless love stub may return nil images; the call itself must not throw
|
||||||
|
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
|
||||||
|
or type(oakImg) == "table",
|
||||||
|
"oak shorthand resolves without error")
|
||||||
|
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
|
||||||
|
or type(rivalImg) == "table",
|
||||||
|
"rival shorthand resolves without error")
|
||||||
|
T.check(playerImg == speech.playerPic or playerImg == nil
|
||||||
|
or type(playerImg) == "userdata" or type(playerImg) == "table",
|
||||||
|
"player shorthand resolves without error")
|
||||||
|
T.check(mewFlip == true, "pokemon flip flag is honored")
|
||||||
|
T.check(customImg ~= nil or true, "custom image path is accepted")
|
||||||
|
|
||||||
|
-- ------- answered event writes mod.save (loader.modSave bucket)
|
||||||
|
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "likes_toast", value = true, label = "YES", index = 1,
|
||||||
|
step = byId.silly_toast, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
|
||||||
|
step = byId.silly_snack, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
|
||||||
|
step = byId.silly_trust, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
|
||||||
|
step = byId.silly_pineapple, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.finished", {
|
||||||
|
speech = speech, answers = speech.answers,
|
||||||
|
})
|
||||||
|
|
||||||
|
local saved = bucket()
|
||||||
|
T.eq(saved.likes_toast, true, "likes_toast saved")
|
||||||
|
T.eq(saved.snack, "OLD ROD", "snack saved")
|
||||||
|
T.eq(saved.trusts_rival, false, "trusts_rival saved")
|
||||||
|
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
|
||||||
|
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
|
||||||
|
|
||||||
|
-- ------- ModUI step helpers (public surface)
|
||||||
|
|
||||||
|
local tiny = {
|
||||||
|
{ id = "a", kind = "say" },
|
||||||
|
{ id = "b", kind = "say" },
|
||||||
|
}
|
||||||
|
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
|
||||||
|
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
|
||||||
|
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
|
||||||
|
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
|
||||||
|
ModUI.removeStep(tiny, "mid")
|
||||||
|
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
T.finish("example_silly_oak")
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "overhaul",
|
"profile": "overhaul",
|
||||||
"category": "MECHANIC",
|
"category": "MECHANIC",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -56,18 +56,55 @@ done
|
|||||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
||||||
|
|
||||||
# --------------------------------------------------------------- game.love
|
# --------------------------------------------------------------- game.love
|
||||||
|
# tools/save-editor is part of the shipped app, not a dev-only script: the
|
||||||
|
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
||||||
|
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||||
|
# love.filesystem's require path, so it has to live inside the archive.
|
||||||
say "packing game.love"
|
say "packing game.love"
|
||||||
LOVE_FILE="$WORK/game.love"
|
LOVE_FILE="$WORK/game.love"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
|
# The editor is only reachable if its entry point and both module directories
|
||||||
|
# made it in; a silent miss would ship a launcher whose Edit button crashes.
|
||||||
|
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||||
|
tools/save-editor/panels/Party.lua; do
|
||||||
|
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||||
|
|| fail "game.love is missing $required (save editor would not load)"
|
||||||
|
done
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------- stamp release version
|
||||||
|
# The working tree ships Version.lua with engine "0.0.0-dev"; the real release
|
||||||
|
# number only ever lives inside the packed archive. When --version is a strict
|
||||||
|
# X.Y.Z, patch a copy of Version.lua (engine set to that number) under a staging
|
||||||
|
# dir and replace the entry inside game.love in place -- never the source tree.
|
||||||
|
# Short-hash / "dev" builds are left with the "-dev" default so they cannot be
|
||||||
|
# mistaken for a release. The stamp is then read back out of the archive and the
|
||||||
|
# build fails if it did not take.
|
||||||
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
say "stamping engine version $VERSION into game.love"
|
||||||
|
stamp_dir="$WORK/stamp"
|
||||||
|
rm -rf "$stamp_dir"
|
||||||
|
mkdir -p "$stamp_dir/src/core"
|
||||||
|
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||||
|
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||||
|
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||||
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
|
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||||
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||||
|
say "stamped engine version: $VERSION"
|
||||||
|
else
|
||||||
|
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||||
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- macOS
|
# --------------------------------------------------------------- macOS
|
||||||
build_mac() {
|
build_mac() {
|
||||||
say "building macOS app"
|
say "building macOS app"
|
||||||
|
|||||||
@@ -113,11 +113,12 @@ import pathlib, re, sys
|
|||||||
path = pathlib.Path(sys.argv[1])
|
path = pathlib.Path(sys.argv[1])
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
|
|
||||||
# Drop network / mic / legacy storage, not needed for offline play.
|
# Drop mic / legacy storage, not needed by this game.
|
||||||
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads).
|
# Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and
|
||||||
|
# INTERNET: link play is not offline-only any more, and stripping INTERNET
|
||||||
|
# made every LAN host and every relay connect fail with EPERM (issue #287).
|
||||||
# Orientation / label come from gradle.properties placeholders.
|
# Orientation / label come from gradle.properties placeholders.
|
||||||
for perm in (
|
for perm in (
|
||||||
"android.permission.INTERNET",
|
|
||||||
"android.permission.RECORD_AUDIO",
|
"android.permission.RECORD_AUDIO",
|
||||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
):
|
):
|
||||||
@@ -136,15 +137,46 @@ pack_game_love() {
|
|||||||
say "packing game.love for love-android embed flavor"
|
say "packing game.love for love-android embed flavor"
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
|
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||||
|
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
|
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||||
|
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||||
|
|
||||||
|
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||||
|
# stamps the release version the same way: patch a copy of Version.lua
|
||||||
|
# (engine set to $VERSION) under a throwaway staging dir and replace the
|
||||||
|
# entry inside the archive in place -- never the source tree. VERSION is
|
||||||
|
# already validated as X.Y.Z above; when it is empty the packaged game keeps
|
||||||
|
# the "0.0.0-dev" default. The stamp is read back out and the build fails if
|
||||||
|
# it did not take.
|
||||||
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
say "stamping engine version $VERSION into game.love"
|
||||||
|
local stamp_dir
|
||||||
|
stamp_dir="$(mktemp -d)"
|
||||||
|
mkdir -p "$stamp_dir/src/core"
|
||||||
|
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||||
|
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||||
|
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
|
||||||
|
local version_re
|
||||||
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
|
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||||
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||||
|
rm -rf "$stamp_dir"
|
||||||
|
say "stamped engine version: $VERSION"
|
||||||
|
else
|
||||||
|
say "no X.Y.Z --version, shipping default engine (no stamp)"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- SDK check
|
# --------------------------------------------------------------- SDK check
|
||||||
|
|||||||
@@ -164,15 +164,19 @@ pack_game_love() {
|
|||||||
say "packing game.love for love-ios resources"
|
say "packing game.love for love-ios resources"
|
||||||
mkdir -p "$RESOURCES_DIR"
|
mkdir -p "$RESOURCES_DIR"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||||
|
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
|
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||||
|
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,13 @@ if [ -f data/generated/maps.lua ]; then
|
|||||||
echo "-- T3 content: skipped (--quick)"
|
echo "-- T3 content: skipped (--quick)"
|
||||||
else
|
else
|
||||||
run_tier "T3 content behavior (Red)" run_content_behavior
|
run_tier "T3 content behavior (Red)" run_content_behavior
|
||||||
|
# The save editor ships inside every build (the launcher's Edit button on
|
||||||
|
# a save row opens it), so its panel suites run in CI rather than by hand.
|
||||||
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
|
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
|
||||||
|
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
|
||||||
|
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||||
|
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||||
|
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
local MoveEffects = require("src.battle.MoveEffects")
|
local MoveEffects = require("src.battle.MoveEffects")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local EffectRegistry = {}
|
local EffectRegistry = {}
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
-- Jump Kick crash, Explode self-destruct
|
-- Jump Kick crash, Explode self-destruct
|
||||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||||
user.trappingTurns = nil
|
user.trappingTurns = nil
|
||||||
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
end
|
end
|
||||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
dmg = math.min(65535, battle.lastDamage * 2)
|
dmg = math.min(65535, battle.lastDamage * 2)
|
||||||
@@ -163,38 +164,64 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if info.typeMult == 0 then
|
if info.typeMult == 0 then
|
||||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if info.missed then
|
if info.missed then
|
||||||
-- 0.25x floored the damage to zero: the original registers a miss
|
-- 0.25x floored the damage to zero: the original registers a miss
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
|
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
|
||||||
|
|
||||||
-- the hit blink + damage sound ride the queue behind the animation:
|
-- the hit blink + damage sound ride each animation row, placed BEFORE
|
||||||
-- on the move's anim row when one was announced, else on a bare hit
|
-- that hit's drain so the blink precedes the bar. Multi-hit moves
|
||||||
-- row (thrash/rage continuations), placed BEFORE the drain rows the
|
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||||
-- hits loop inserts so the blink precedes the bar drain
|
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||||
local hitRow = battle.moveAnimRow
|
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||||
if not hitRow then
|
-- Thrash/rage continuations have no announcement anim -- a bare
|
||||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
-- hitRow carries the blink instead.
|
||||||
hitRow = { hitRow = true }
|
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||||
end
|
local hitFx = { sfx = hitSfx,
|
||||||
|
blink = battle:animationsOn() and target or nil }
|
||||||
|
|
||||||
local totalDealt = 0
|
local totalDealt = 0
|
||||||
local landed, brokeSub = 0, false
|
local landed, brokeSub = 0, false
|
||||||
for h = 1, hits do
|
for h = 1, hits do
|
||||||
if target.mon.hp <= 0 then break end
|
if target.mon.hp <= 0 then break end
|
||||||
|
local hitRow
|
||||||
|
if h == 1 then
|
||||||
|
hitRow = battle.moveAnimRow
|
||||||
|
if not hitRow then
|
||||||
|
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||||
|
hitRow = { hitRow = true }
|
||||||
|
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||||
|
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
||||||
|
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||||
|
end
|
||||||
local hadSub = target.substituteHP ~= nil
|
local hadSub = target.substituteHP ~= nil
|
||||||
local dealt = battle:applyDamage(target, dmg)
|
local dealt = battle:applyDamage(target, dmg)
|
||||||
totalDealt = totalDealt + dealt
|
totalDealt = totalDealt + dealt
|
||||||
landed = h
|
landed = h
|
||||||
|
if dealt > 0 then hitRow.hit = hitFx end
|
||||||
|
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
||||||
|
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||||
|
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||||
|
-- every strike -- damage was only rolled once
|
||||||
|
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||||
|
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
|
||||||
|
if info.typeMult > 10 then
|
||||||
|
battle:sayNext(Strings("It's super\neffective!"))
|
||||||
|
elseif info.typeMult < 10 then
|
||||||
|
battle:sayNext(Strings("It's not very\neffective..."))
|
||||||
|
end
|
||||||
if Runtime.wants("battle.damage_dealt") then
|
if Runtime.wants("battle.damage_dealt") then
|
||||||
Runtime.emit("battle.damage_dealt", {
|
Runtime.emit("battle.damage_dealt", {
|
||||||
battle = battle, user = user, target = target, move = move,
|
battle = battle, user = user, target = target, move = move,
|
||||||
@@ -208,29 +235,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
hits = landed > 0 and landed or hits
|
hits = landed > 0 and landed or hits
|
||||||
if totalDealt > 0 then
|
|
||||||
-- the original's per-hit sound: normal / super / not-very-effective
|
|
||||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
|
||||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
|
||||||
hitRow.hit = { sfx = hitSfx,
|
|
||||||
blink = battle:animationsOn() and target or nil }
|
|
||||||
end
|
|
||||||
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
|
|
||||||
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
|
|
||||||
-- .moveDidNotMiss); the multi-hit count follows the last hit
|
|
||||||
if info.crit then battle:sayNext("Critical hit!") end
|
|
||||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
|
||||||
if info.typeMult > 10 then
|
|
||||||
battle:sayNext("It's super\neffective!")
|
|
||||||
elseif info.typeMult < 10 then
|
|
||||||
battle:sayNext("It's not very\neffective...")
|
|
||||||
end
|
|
||||||
if hits > 1 then
|
if hits > 1 then
|
||||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||||
if user.isPlayer then
|
if user.isPlayer then
|
||||||
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
|
battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
|
||||||
else
|
else
|
||||||
battle:sayNext(("Hit %d times!"):format(hits))
|
battle:sayNext(Strings("Hit %d times!", hits))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -242,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
elseif moveInst.struggle then
|
elseif moveInst.struggle then
|
||||||
-- struggle recoils even when its effect id resolves to no record
|
-- struggle recoils even when its effect id resolves to no record
|
||||||
local recoil = math.max(1, math.floor(dmg / 2))
|
local recoil = math.max(1, math.floor(dmg / 2))
|
||||||
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
|
||||||
battle:applyDamage(user, recoil)
|
battle:applyDamage(user, recoil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local MoveEffects = {}
|
local MoveEffects = {}
|
||||||
|
|
||||||
@@ -36,14 +37,14 @@ local STAT_LABEL = {
|
|||||||
local function changeStage(battle, who, stat, delta, fromEnemy)
|
local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||||
if fromEnemy and (who.substituteHP or who.mist) then
|
if fromEnemy and (who.substituteHP or who.mist) then
|
||||||
if who.mist then
|
if who.mist then
|
||||||
return { displayName(who) .. " is\nprotected by MIST!" }
|
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
|
||||||
end
|
end
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
local cur = who.stages[stat] or 0
|
local cur = who.stages[stat] or 0
|
||||||
local new = math.max(-6, math.min(6, cur + delta))
|
local new = math.max(-6, math.min(6, cur + delta))
|
||||||
if new == cur then
|
if new == cur then
|
||||||
return { ("Nothing happened!") }
|
return { Strings("Nothing happened!") }
|
||||||
end
|
end
|
||||||
who.stages[stat] = new
|
who.stages[stat] = new
|
||||||
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
||||||
@@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
|||||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||||
-- two-stage variants scroll "greatly" onto a third line
|
-- two-stage variants scroll "greatly" onto a third line
|
||||||
if delta >= 2 then
|
if delta >= 2 then
|
||||||
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
|
||||||
elseif delta == 1 then
|
elseif delta == 1 then
|
||||||
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
|
||||||
elseif delta == -1 then
|
elseif delta == -1 then
|
||||||
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
|
||||||
end
|
end
|
||||||
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
|
||||||
end
|
end
|
||||||
MoveEffects.changeStage = changeStage
|
MoveEffects.changeStage = changeStage
|
||||||
|
|
||||||
@@ -88,10 +89,10 @@ end
|
|||||||
local function statusMove(status)
|
local function statusMove(status)
|
||||||
return function(battle, user, target, move)
|
return function(battle, user, target, move)
|
||||||
if target.mon.status then
|
if target.mon.status then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
if status == "PSN" and target.substituteHP then
|
if status == "PSN" and target.substituteHP then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
local msgs = inflictStatus(battle, target, status, {
|
local msgs = inflictStatus(battle, target, status, {
|
||||||
toxic = move and move.id == "TOXIC",
|
toxic = move and move.id == "TOXIC",
|
||||||
@@ -99,7 +100,7 @@ local function statusMove(status)
|
|||||||
source = move and move.id,
|
source = move and move.id,
|
||||||
})
|
})
|
||||||
if #msgs == 0 then
|
if #msgs == 0 then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
|
|||||||
-- target (regardless of the burn roll)
|
-- target (regardless of the burn roll)
|
||||||
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
||||||
target.mon.status = nil
|
target.mon.status = nil
|
||||||
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
|
return { Strings("Fire defrosted\n%s!", displayName(target)) }
|
||||||
end
|
end
|
||||||
if battle.rng(0, 255) >= chance then return {} end
|
if battle.rng(0, 255) >= chance then return {} end
|
||||||
return inflictStatus(battle, target, status, {
|
return inflictStatus(battle, target, status, {
|
||||||
@@ -144,10 +145,10 @@ end
|
|||||||
|
|
||||||
local function confuse(battle, target, pierceSub)
|
local function confuse(battle, target, pierceSub)
|
||||||
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
target.confusedTurns = battle.rng(2, 5)
|
target.confusedTurns = battle.rng(2, 5)
|
||||||
return { ("%s\nbecame confused!"):format(displayName(target)) }
|
return { Strings("%s\nbecame confused!", displayName(target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
@@ -181,53 +182,53 @@ MoveEffects.primary = {
|
|||||||
LEECH_SEED_EFFECT = function(battle, user, target)
|
LEECH_SEED_EFFECT = function(battle, user, target)
|
||||||
-- leech_seed.asm has no substitute check: seeding lands through one
|
-- leech_seed.asm has no substitute check: seeding lands through one
|
||||||
if target.leechSeeded then
|
if target.leechSeeded then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
for _, t in ipairs(target.curTypes) do
|
for _, t in ipairs(target.curTypes) do
|
||||||
if t == "GRASS" then return { "But, it failed!" } end
|
if t == "GRASS" then return { Strings("But, it failed!") } end
|
||||||
end
|
end
|
||||||
target.leechSeeded = true
|
target.leechSeeded = true
|
||||||
return { ("%s\nwas seeded!"):format(displayName(target)) }
|
return { Strings("%s\nwas seeded!", displayName(target)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
HEAL_EFFECT = function(battle, user, target, move)
|
HEAL_EFFECT = function(battle, user, target, move)
|
||||||
local mon = user.mon
|
local mon = user.mon
|
||||||
if move.id == "REST" then
|
if move.id == "REST" then
|
||||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||||
mon.hp = mon.stats.hp
|
mon.hp = mon.stats.hp
|
||||||
mon.status = "SLP"
|
mon.status = "SLP"
|
||||||
user.sleepTurns = 2
|
user.sleepTurns = 2
|
||||||
user.toxicCounter = nil
|
user.toxicCounter = nil
|
||||||
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
|
return { Strings("%s\nstarted sleeping!", displayName(user)) }
|
||||||
end
|
end
|
||||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
|
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
|
||||||
return { ("%s\nregained health!"):format(displayName(user)) }
|
return { Strings("%s\nregained health!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
LIGHT_SCREEN_EFFECT = function(battle, user)
|
LIGHT_SCREEN_EFFECT = function(battle, user)
|
||||||
if user.lightScreen then return { "But, it failed!" } end
|
if user.lightScreen then return { Strings("But, it failed!") } end
|
||||||
user.lightScreen = true
|
user.lightScreen = true
|
||||||
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
|
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
REFLECT_EFFECT = function(battle, user)
|
REFLECT_EFFECT = function(battle, user)
|
||||||
if user.reflect then return { "But, it failed!" } end
|
if user.reflect then return { Strings("But, it failed!") } end
|
||||||
user.reflect = true
|
user.reflect = true
|
||||||
return { ("%s\ngained armor!"):format(displayName(user)) }
|
return { Strings("%s\ngained armor!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
MIST_EFFECT = function(battle, user)
|
MIST_EFFECT = function(battle, user)
|
||||||
if user.mist then return { "But, it failed!" } end
|
if user.mist then return { Strings("But, it failed!") } end
|
||||||
user.mist = true
|
user.mist = true
|
||||||
-- _ShroudedInMistText (lowercase "mist")
|
-- _ShroudedInMistText (lowercase "mist")
|
||||||
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
|
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
FOCUS_ENERGY_EFFECT = function(battle, user)
|
FOCUS_ENERGY_EFFECT = function(battle, user)
|
||||||
if user.focusEnergy then return { "But, it failed!" } end
|
if user.focusEnergy then return { Strings("But, it failed!") } end
|
||||||
user.focusEnergy = true
|
user.focusEnergy = true
|
||||||
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
|
return { Strings("%s's\ngetting pumped!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
HAZE_EFFECT = function(battle, user, target)
|
HAZE_EFFECT = function(battle, user, target)
|
||||||
@@ -254,33 +255,33 @@ MoveEffects.primary = {
|
|||||||
target.skipMove = true
|
target.skipMove = true
|
||||||
end
|
end
|
||||||
target.mon.status = nil
|
target.mon.status = nil
|
||||||
return { "All STATUS changes\nare eliminated!" }
|
return { Strings("All STATUS changes\nare eliminated!") }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SUBSTITUTE_EFFECT = function(battle, user)
|
SUBSTITUTE_EFFECT = function(battle, user)
|
||||||
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end
|
if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
|
||||||
local cost = math.floor(user.mon.stats.hp / 4)
|
local cost = math.floor(user.mon.stats.hp / 4)
|
||||||
-- substitute.asm only fails on subtraction underflow (current HP
|
-- substitute.asm only fails on subtraction underflow (current HP
|
||||||
-- strictly below maxHP/4); at equality the substitute is built and
|
-- strictly below maxHP/4); at equality the substitute is built and
|
||||||
-- the user is left standing on exactly 0 HP (it faints only when
|
-- the user is left standing on exactly 0 HP (it faints only when
|
||||||
-- the engine next checks HP, not here)
|
-- the engine next checks HP, not here)
|
||||||
if user.mon.hp < cost then
|
if user.mon.hp < cost then
|
||||||
return { "Too weak to make\na SUBSTITUTE!" }
|
return { Strings("Too weak to make\na SUBSTITUTE!") }
|
||||||
end
|
end
|
||||||
user.mon.hp = user.mon.hp - cost
|
user.mon.hp = user.mon.hp - cost
|
||||||
user.substituteHP = cost + 1
|
user.substituteHP = cost + 1
|
||||||
-- _SubstituteText
|
-- _SubstituteText
|
||||||
return { "It created a\nSUBSTITUTE!" }
|
return { Strings("It created a\nSUBSTITUTE!") }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
CONVERSION_EFFECT = function(battle, user, target)
|
CONVERSION_EFFECT = function(battle, user, target)
|
||||||
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
||||||
if target.invulnerable then
|
if target.invulnerable then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||||
-- _ConvertedTypeText
|
-- _ConvertedTypeText
|
||||||
return { ("Converted type to\n%s's!"):format(displayName(target)) }
|
return { Strings("Converted type to\n%s's!", displayName(target)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
||||||
@@ -311,27 +312,27 @@ MoveEffects.primary = {
|
|||||||
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
||||||
end
|
end
|
||||||
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
||||||
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) }
|
return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
DISABLE_EFFECT = function(battle, user, target)
|
DISABLE_EFFECT = function(battle, user, target)
|
||||||
if target.disabledSlot then return { "But, it failed!" } end
|
if target.disabledSlot then return { Strings("But, it failed!") } end
|
||||||
local usable = {}
|
local usable = {}
|
||||||
for i, mv in ipairs(target.curMoves) do
|
for i, mv in ipairs(target.curMoves) do
|
||||||
if mv.pp > 0 then table.insert(usable, i) end
|
if mv.pp > 0 then table.insert(usable, i) end
|
||||||
end
|
end
|
||||||
if #usable == 0 then return { "But, it failed!" } end
|
if #usable == 0 then return { Strings("But, it failed!") } end
|
||||||
local slot = usable[battle.rng(1, #usable)]
|
local slot = usable[battle.rng(1, #usable)]
|
||||||
target.disabledSlot = slot
|
target.disabledSlot = slot
|
||||||
target.disabledTurns = battle.rng(1, 8)
|
target.disabledTurns = battle.rng(1, 8)
|
||||||
local id = target.curMoves[slot].id
|
local id = target.curMoves[slot].id
|
||||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||||
return { ("%s's\n%s was\ndisabled!"):format(displayName(target),
|
return { Strings("%s's\n%s was\ndisabled!", displayName(target),
|
||||||
battle.data.moves[id].name) }
|
battle.data.moves[id].name) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SPLASH_EFFECT = function()
|
SPLASH_EFFECT = function()
|
||||||
return { "No effect!" }
|
return { Strings("No effect!") }
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +429,9 @@ local function drainHalf(text)
|
|||||||
local mon = ctx.user.mon
|
local mon = ctx.user.mon
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||||
ctx.drain()
|
ctx.drain()
|
||||||
ctx.say(text:format(displayName(ctx.target)))
|
-- `text` arrives as a source string (Strings.source at the call
|
||||||
|
-- site keeps it in the catalog); look it up here, at use time
|
||||||
|
ctx.say(Strings(text, displayName(ctx.target)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -436,7 +439,7 @@ end
|
|||||||
-- flags the miss before the special-damage override)
|
-- flags the miss before the special-damage override)
|
||||||
local function immuneMsg(ctx)
|
local function immuneMsg(ctx)
|
||||||
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
||||||
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
|
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -498,12 +501,12 @@ MoveEffects.full = {
|
|||||||
-- removed): overkill and substitute hits recoil at full strength
|
-- removed): overkill and substitute hits recoil at full strength
|
||||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||||
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
|
ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
|
||||||
ctx.battle:applyDamage(ctx.user, recoil)
|
ctx.battle:applyDamage(ctx.user, recoil)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
DRAIN_HP_EFFECT = {
|
DRAIN_HP_EFFECT = {
|
||||||
afterDamage = drainHalf("Sucked health from\n%s!"),
|
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
|
||||||
},
|
},
|
||||||
DREAM_EATER_EFFECT = {
|
DREAM_EATER_EFFECT = {
|
||||||
-- only works on sleeping targets (checked before damage)
|
-- only works on sleeping targets (checked before damage)
|
||||||
@@ -511,7 +514,7 @@ MoveEffects.full = {
|
|||||||
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
afterDamage = drainHalf("%s's\ndream was eaten!"),
|
afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
|
||||||
},
|
},
|
||||||
|
|
||||||
-- charge moves: first turn just charges; Fly AND Dig go
|
-- charge moves: first turn just charges; Fly AND Dig go
|
||||||
@@ -557,7 +560,7 @@ MoveEffects.full = {
|
|||||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||||
if not user.confusedTurns then
|
if not user.confusedTurns then
|
||||||
user.confusedTurns = ctx.rng(2, 5)
|
user.confusedTurns = ctx.rng(2, 5)
|
||||||
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
|
ctx.say(Strings("%s\nbecame confused!", displayName(user)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -566,7 +569,7 @@ MoveEffects.full = {
|
|||||||
JUMP_KICK_EFFECT = {
|
JUMP_KICK_EFFECT = {
|
||||||
onMiss = function(ctx, reason)
|
onMiss = function(ctx, reason)
|
||||||
if reason ~= "accuracy" then return end
|
if reason ~= "accuracy" then return end
|
||||||
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
|
ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
|
||||||
ctx.damage(ctx.user, 1)
|
ctx.damage(ctx.user, 1)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -581,8 +584,12 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
HYPER_BEAM_EFFECT = {
|
HYPER_BEAM_EFFECT = {
|
||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
-- no recharge when the target faints OR its substitute breaks
|
-- Gen 1: no recharge when the target faints OR its substitute breaks.
|
||||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
|
||||||
|
local ruleset = ctx.battle and ctx.battle.ruleset
|
||||||
|
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
|
||||||
|
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
|
||||||
|
if not skipOnKO or not targetDown then
|
||||||
ctx.user.mustRecharge = true
|
ctx.user.mustRecharge = true
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
@@ -591,7 +598,7 @@ MoveEffects.full = {
|
|||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
local battle = ctx.battle
|
local battle = ctx.battle
|
||||||
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
||||||
ctx.say("Coins scattered\neverywhere!")
|
ctx.say(Strings("Coins scattered\neverywhere!"))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
SWIFT_EFFECT = { neverMiss = true },
|
SWIFT_EFFECT = { neverMiss = true },
|
||||||
@@ -606,7 +613,7 @@ MoveEffects.full = {
|
|||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
user.bideDamage = 0
|
||||||
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
SWITCH_AND_TELEPORT_EFFECT = {
|
SWITCH_AND_TELEPORT_EFFECT = {
|
||||||
@@ -627,27 +634,27 @@ MoveEffects.full = {
|
|||||||
end
|
end
|
||||||
if ok then
|
if ok then
|
||||||
if move.id == "ROAR" then
|
if move.id == "ROAR" then
|
||||||
ctx.say(("%s\nran away scared!"):format(displayName(target)))
|
ctx.say(Strings("%s\nran away scared!", displayName(target)))
|
||||||
elseif move.id == "WHIRLWIND" then
|
elseif move.id == "WHIRLWIND" then
|
||||||
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
|
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
|
||||||
else
|
else
|
||||||
ctx.say(("%s\nran from battle!"):format(displayName(user)))
|
ctx.say(Strings("%s\nran from battle!", displayName(user)))
|
||||||
end
|
end
|
||||||
battle.result = "run"
|
battle.result = "run"
|
||||||
battle.afterQueue = "finish"
|
battle.afterQueue = "finish"
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say(Strings("But, it failed!"))
|
||||||
else
|
else
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
|
||||||
end
|
end
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say(Strings("But, it failed!"))
|
||||||
else
|
else
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -665,7 +672,7 @@ MoveEffects.full = {
|
|||||||
callsMove = function(ctx)
|
callsMove = function(ctx)
|
||||||
local last = ctx.target.lastMove
|
local last = ctx.target.lastMove
|
||||||
if not last then
|
if not last then
|
||||||
ctx.say("The MIRROR MOVE\nfailed!")
|
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
return last
|
return last
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
||||||
-- back to the vanilla records, which is bit-identical behavior.
|
-- back to the vanilla records, which is bit-identical behavior.
|
||||||
|
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Status = {}
|
local Status = {}
|
||||||
|
|
||||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||||
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
||||||
-- Toxic counter (HandlePoisonBurnLeechSeed)
|
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
|
||||||
local function damageOverTime(what)
|
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
||||||
|
-- decline differently once translated, so a shared fragment cannot be the
|
||||||
|
-- translatable unit.
|
||||||
|
local function damageOverTime(template)
|
||||||
return function(battler)
|
return function(battler)
|
||||||
local mon = battler.mon
|
local mon = battler.mon
|
||||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||||
@@ -39,10 +44,15 @@ local function damageOverTime(what)
|
|||||||
battler.toxicCounter = battler.toxicCounter + 1
|
battler.toxicCounter = battler.toxicCounter + 1
|
||||||
end
|
end
|
||||||
mon.hp = math.max(0, mon.hp - dmg)
|
mon.hp = math.max(0, mon.hp - dmg)
|
||||||
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
|
return { Strings(template, name(battler)) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Labels stay plain literals on purpose: this table is built at require
|
||||||
|
-- time, before Strings.load has a catalog, so a Strings() here would
|
||||||
|
-- freeze the English. They are already translatable through the
|
||||||
|
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
||||||
|
--
|
||||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||||
@@ -57,13 +67,13 @@ Status.RECORDS = {
|
|||||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||||
if battler.sleepTurns <= 0 then
|
if battler.sleepTurns <= 0 then
|
||||||
battler.mon.status = nil
|
battler.mon.status = nil
|
||||||
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
|
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
|
||||||
end
|
end
|
||||||
return false, { name(battler) .. "\nis fast asleep!" }
|
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
onInflict = function(battle, target, opts, display)
|
onInflict = function(battle, target, opts, display)
|
||||||
target.sleepTurns = battle.rng(1, 7)
|
target.sleepTurns = battle.rng(1, 7)
|
||||||
return { ("%s\nfell asleep!"):format(display) }
|
return { Strings("%s\nfell asleep!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
FRZ = {
|
FRZ = {
|
||||||
@@ -71,35 +81,35 @@ Status.RECORDS = {
|
|||||||
catchBonus = 25, shakeBonus = 10,
|
catchBonus = 25, shakeBonus = 10,
|
||||||
beforeMovePriority = 30,
|
beforeMovePriority = 30,
|
||||||
beforeMove = function(battler)
|
beforeMove = function(battler)
|
||||||
return false, { name(battler) .. "\nis frozen solid!" }
|
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
return { ("%s\nwas frozen solid!"):format(display) }
|
return { Strings("%s\nwas frozen solid!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PSN = {
|
PSN = {
|
||||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
residual = damageOverTime("poison"),
|
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
|
||||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||||
onInflict = function(_, target, opts, display)
|
onInflict = function(_, target, opts, display)
|
||||||
if opts.toxic then
|
if opts.toxic then
|
||||||
target.toxicCounter = 1
|
target.toxicCounter = 1
|
||||||
-- _BadlyPoisonedText
|
-- _BadlyPoisonedText
|
||||||
return { ("%s's\nbadly poisoned!"):format(display) }
|
return { Strings("%s's\nbadly poisoned!", display) }
|
||||||
end
|
end
|
||||||
return { ("%s\nwas poisoned!"):format(display) }
|
return { Strings("%s\nwas poisoned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
BRN = {
|
BRN = {
|
||||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
statPenalty = { stat = "attack", div = 2 },
|
statPenalty = { stat = "attack", div = 2 },
|
||||||
residual = damageOverTime("the burn"),
|
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
|
||||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
return { ("%s\nwas burned!"):format(display) }
|
return { Strings("%s\nwas burned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PAR = {
|
PAR = {
|
||||||
@@ -110,7 +120,7 @@ Status.RECORDS = {
|
|||||||
beforeMove = function(battler, rng)
|
beforeMove = function(battler, rng)
|
||||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||||
if rng(0, 255) < 63 then
|
if rng(0, 255) < 63 then
|
||||||
return false, { name(battler) .. "'s\nfully paralyzed!" }
|
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||||
end
|
end
|
||||||
return true, {}
|
return true, {}
|
||||||
end,
|
end,
|
||||||
@@ -120,7 +130,7 @@ Status.RECORDS = {
|
|||||||
end,
|
end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||||
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
|
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.flinched then
|
if battler.flinched then
|
||||||
battler.flinched = false
|
battler.flinched = false
|
||||||
return false, { name(battler) .. "\nflinched!" }
|
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||||
end
|
end
|
||||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||||
local handler = record and record.beforeMove
|
local handler = record and record.beforeMove
|
||||||
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.boundTurns and battler.boundTurns > 0 then
|
if battler.boundTurns and battler.boundTurns > 0 then
|
||||||
battler.boundTurns = battler.boundTurns - 1
|
battler.boundTurns = battler.boundTurns - 1
|
||||||
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
|
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
|
||||||
return false, msgs
|
return false, msgs
|
||||||
end
|
end
|
||||||
if battler.disabledTurns then
|
if battler.disabledTurns then
|
||||||
battler.disabledTurns = battler.disabledTurns - 1
|
battler.disabledTurns = battler.disabledTurns - 1
|
||||||
if battler.disabledTurns <= 0 then
|
if battler.disabledTurns <= 0 then
|
||||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||||
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
|
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if battler.confusedTurns then
|
if battler.confusedTurns then
|
||||||
battler.confusedTurns = battler.confusedTurns - 1
|
battler.confusedTurns = battler.confusedTurns - 1
|
||||||
if battler.confusedTurns <= 0 then
|
if battler.confusedTurns <= 0 then
|
||||||
battler.confusedTurns = nil
|
battler.confusedTurns = nil
|
||||||
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
|
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||||
else
|
else
|
||||||
table.insert(msgs, name(battler) .. "\nis confused!")
|
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
|
||||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||||
if rng(0, 255) < 128 then
|
if rng(0, 255) < 128 then
|
||||||
return false, msgs, true -- hurt itself
|
return false, msgs, true -- hurt itself
|
||||||
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
|
|||||||
dmg = math.min(dmg, mon.hp)
|
dmg = math.min(dmg, mon.hp)
|
||||||
mon.hp = mon.hp - dmg
|
mon.hp = mon.hp - dmg
|
||||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||||
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
|
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local StatusRegistry = {}
|
local StatusRegistry = {}
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
|
|||||||
if record and record.onInflict then
|
if record and record.onInflict then
|
||||||
msgs = record.onInflict(battle, target, opts, display)
|
msgs = record.onInflict(battle, target, opts, display)
|
||||||
else
|
else
|
||||||
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
|
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
|
||||||
record and record.label or tostring(status)) }
|
record and record.label or tostring(status)) }
|
||||||
end
|
end
|
||||||
Runtime.emit("battle.status_inflicted", {
|
Runtime.emit("battle.status_inflicted", {
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
-- (super-effective) or adds 1 (not-effective when a better move exists);
|
-- (super-effective) or adds 1 (not-effective when a better move exists);
|
||||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||||
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
|
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
||||||
|
-- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
|
||||||
|
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local TrainerAI = {}
|
local TrainerAI = {}
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ function TrainerAI.useItem(battle, item)
|
|||||||
local enemy = battle.enemy
|
local enemy = battle.enemy
|
||||||
local trainerName = battle.trainer.name
|
local trainerName = battle.trainer.name
|
||||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||||
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) }
|
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
|
||||||
if item == "FULL_HEAL" then
|
if item == "FULL_HEAL" then
|
||||||
enemy.mon.status = nil
|
enemy.mon.status = nil
|
||||||
enemy.toxicCounter = nil
|
enemy.toxicCounter = nil
|
||||||
@@ -111,10 +113,10 @@ function TrainerAI.useItem(battle, item)
|
|||||||
elseif X_STAT[item] then
|
elseif X_STAT[item] then
|
||||||
local stat = X_STAT[item]
|
local stat = X_STAT[item]
|
||||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||||
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper()))
|
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
|
||||||
elseif item == "GUARD_SPEC" then
|
elseif item == "GUARD_SPEC" then
|
||||||
enemy.mist = true
|
enemy.mist = true
|
||||||
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name))
|
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
@@ -212,9 +214,13 @@ end
|
|||||||
|
|
||||||
function TrainerAI.chooseMove(battler, rng, battle)
|
function TrainerAI.chooseMove(battler, rng, battle)
|
||||||
rng = rng or love.math.random
|
rng = rng or love.math.random
|
||||||
|
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
|
||||||
|
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
|
||||||
|
-- depletes enemy PP and falls back to Struggle when none remain.
|
||||||
|
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
|
||||||
local usable = {}
|
local usable = {}
|
||||||
for i, mv in ipairs(battler.curMoves) do
|
for i, mv in ipairs(battler.curMoves) do
|
||||||
if mv.pp > 0 and battler.disabledSlot ~= i then
|
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
|
||||||
table.insert(usable, mv)
|
table.insert(usable, mv)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
|
||||||
|
--
|
||||||
|
-- The battle simulation, timing, animations and rules stay BattleState's;
|
||||||
|
-- this module only replaces the composition and asks the renderer for a
|
||||||
|
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
|
||||||
|
-- border glyphs, species palettes and HP tiles all resolve through the
|
||||||
|
-- engine, so a COLORS mode or an asset mod still owns the look.
|
||||||
|
--
|
||||||
|
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
|
||||||
|
-- upper left with its picture upper right, the player's picture lower left
|
||||||
|
-- with their status lower right, a full-width message window, a split
|
||||||
|
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
|
||||||
|
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
local HudTiles = require("src.render.HudTiles")
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
|
||||||
|
local WideBattle = {
|
||||||
|
WIDTH = 304,
|
||||||
|
HEIGHT = 144,
|
||||||
|
-- everything above this line is battlefield; the 40 rows below it are
|
||||||
|
-- the message / command / move windows
|
||||||
|
FIELD_BOTTOM = 104,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- The forced-mono display modes re-threshold the whole finished frame
|
||||||
|
-- through the shade shader, and picImage hands them raw DMG grays for that
|
||||||
|
-- (#207). The wide layout has to know: it exposes a matching whole-surface
|
||||||
|
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
|
||||||
|
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
|
||||||
|
local function monoMode()
|
||||||
|
local m = PaletteFX.mode
|
||||||
|
return m == "og" or m == "og_inv" or m == "classic"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shownHP(battler)
|
||||||
|
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a name truncated to `pixels` with a trailing '.', measured through the
|
||||||
|
-- font's own advances so a variable-width page still fits
|
||||||
|
local function fitName(text, pixels)
|
||||||
|
local spans = Font.split(text or "")
|
||||||
|
local n = Font.spansFitting(spans, pixels)
|
||||||
|
if n >= #spans then return text or "" end
|
||||||
|
local out = {}
|
||||||
|
for i = 1, math.max(0, n - 1) do
|
||||||
|
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
|
||||||
|
end
|
||||||
|
return table.concat(out) .. "."
|
||||||
|
end
|
||||||
|
|
||||||
|
local function saveScissor()
|
||||||
|
if not love.graphics.getScissor then return nil end
|
||||||
|
local x, y, w, h = love.graphics.getScissor()
|
||||||
|
if x == nil then return false end
|
||||||
|
return { x, y, w, h }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreScissor(saved)
|
||||||
|
if not love.graphics.setScissor then return end
|
||||||
|
if saved and saved ~= false then
|
||||||
|
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
|
||||||
|
else
|
||||||
|
love.graphics.setScissor()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
|
||||||
|
-- The scissor is in canvas space, so it bounds the region itself while the
|
||||||
|
-- translate moves the classic 160x144 coordinates into it.
|
||||||
|
local function inRegion(x, y, w, h, dx, dy, fn)
|
||||||
|
local g = love.graphics
|
||||||
|
local saved = saveScissor()
|
||||||
|
g.setScissor(x, y, w, h)
|
||||||
|
g.push()
|
||||||
|
g.translate(dx, dy)
|
||||||
|
fn()
|
||||||
|
g.pop()
|
||||||
|
restoreScissor(saved)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function levelAt(battle, battler, x, y)
|
||||||
|
if battler.shownStatus then
|
||||||
|
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
|
||||||
|
else
|
||||||
|
HudTiles.tile(0x6E, x, y) -- '<LV>'
|
||||||
|
Font.draw(tostring(battler.mon.level), x + 8, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One side's status box: name and level on the first line, a long HP bar
|
||||||
|
-- under it, and the numeric HP on the player's box only (the foe's exact
|
||||||
|
-- HP is never shown, like the original).
|
||||||
|
local function drawStatusPanel(battle, battler, x, y, player)
|
||||||
|
local tx, ty = math.floor(x / 8), math.floor(y / 8)
|
||||||
|
local tw, th = player and 15 or 16, player and 5 or 4
|
||||||
|
Font.drawBox(tx, ty, tw, th)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
|
||||||
|
local nameWidth = player and 64 or 80
|
||||||
|
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
|
||||||
|
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
|
||||||
|
|
||||||
|
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||||
|
hp = shownHP(battler),
|
||||||
|
stats = battler.mon.stats,
|
||||||
|
}, nil, monoMode(), tw - 5)
|
||||||
|
|
||||||
|
if player then
|
||||||
|
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||||
|
x + tw * 8 - 64, y + 24)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
||||||
|
-- out to the wide screen's own corners
|
||||||
|
local function drawIntroBalls(battle)
|
||||||
|
if not battle.introBalls then return end
|
||||||
|
if battle.enemyParty and
|
||||||
|
(battle.kind == "trainer" or battle.kind == "link") then
|
||||||
|
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
|
||||||
|
end
|
||||||
|
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawHUDs(battle, slide)
|
||||||
|
if battle.enemy and not battle.showEnemyTrainer
|
||||||
|
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
|
||||||
|
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
|
||||||
|
drawStatusPanel(battle, battle.enemy, 0, 0, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
if battle.safari then
|
||||||
|
Font.drawBox(23, 7, 15, 4)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
|
||||||
|
elseif battle.player and not battle.demo and not battle.showPlayerBack
|
||||||
|
and slide == 0 then
|
||||||
|
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||||
|
end
|
||||||
|
drawIntroBalls(battle)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawMessageBox(battle)
|
||||||
|
Font.drawBox(0, 13, 38, 5)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
if battle.scrollPx and battle.scrollPx > 0 then
|
||||||
|
battle.scrollPx = battle.scrollPx - 2
|
||||||
|
if battle.scrollPx <= 0 then battle.scrollPx = nil end
|
||||||
|
end
|
||||||
|
local off = battle.scrollPx or 0
|
||||||
|
local ys = { 112, 128 }
|
||||||
|
for li, line in ipairs(battle.shown or {}) do
|
||||||
|
local y = (ys[li] or 128) + off
|
||||||
|
for i = 1, #line do
|
||||||
|
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
|
||||||
|
Font.drawCode(0xEE, 288, 132)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawCommandMenu(battle)
|
||||||
|
local col = (battle.menuIndex - 1) % 2
|
||||||
|
local row = math.floor((battle.menuIndex - 1) / 2)
|
||||||
|
if battle.safari then
|
||||||
|
Font.drawBox(0, 13, 38, 5)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("BALLx"), 16, 112)
|
||||||
|
Font.draw(Strings("BAIT"), 168, 112)
|
||||||
|
Font.draw(Strings("THROW ROCK"), 16, 128)
|
||||||
|
Font.draw(Strings("RUN"), 168, 128)
|
||||||
|
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the prompt on the left, the 2x2 commands on the right
|
||||||
|
Font.drawBox(0, 13, 20, 5)
|
||||||
|
Font.drawBox(20, 13, 18, 5)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("What will"), 8, 112)
|
||||||
|
local who = battle.player and battle.player.name or ""
|
||||||
|
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
|
||||||
|
Font.draw(Strings("FIGHT"), 176, 112)
|
||||||
|
Font.drawCode(0xE1, 240, 112) -- 'PK'
|
||||||
|
Font.drawCode(0xE2, 248, 112) -- 'MN'
|
||||||
|
Font.draw(Strings("ITEM"), 176, 128)
|
||||||
|
Font.draw(Strings("RUN"), 240, 128)
|
||||||
|
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawMoveDetails(battle, move)
|
||||||
|
Font.drawBox(28, 13, 10, 5)
|
||||||
|
if not move then return end
|
||||||
|
local def = battle.data.moves[move.id]
|
||||||
|
if not def then return end
|
||||||
|
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
|
||||||
|
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawMoveGrid(battle, moves, selected)
|
||||||
|
-- The 8px font needs 28 tiles for two complete twelve-character move
|
||||||
|
-- names plus their cursors; the details panel gets the other ten.
|
||||||
|
Font.drawBox(0, 13, 28, 5)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
for i, move in ipairs(moves or {}) do
|
||||||
|
local col = (i - 1) % 2
|
||||||
|
local row = math.floor((i - 1) / 2)
|
||||||
|
local x, y = col == 0 and 16 or 120, 112 + row * 16
|
||||||
|
local def = battle.data.moves[move.id]
|
||||||
|
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
|
||||||
|
end
|
||||||
|
local col = (selected - 1) % 2
|
||||||
|
local row = math.floor((selected - 1) / 2)
|
||||||
|
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
|
||||||
|
drawMoveDetails(battle, moves and moves[selected])
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawMoveMenu(battle)
|
||||||
|
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
|
||||||
|
if battle.moveSwapIndex then
|
||||||
|
local col = (battle.moveSwapIndex - 1) % 2
|
||||||
|
local row = math.floor((battle.moveSwapIndex - 1) / 2)
|
||||||
|
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawTextArea(battle)
|
||||||
|
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
|
||||||
|
drawMessageBox(battle)
|
||||||
|
elseif battle.phase == "menu" then
|
||||||
|
drawCommandMenu(battle)
|
||||||
|
elseif battle.phase == "moveSelect" then
|
||||||
|
drawMoveMenu(battle)
|
||||||
|
elseif battle.phase == "mimicSelect" then
|
||||||
|
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
|
||||||
|
else
|
||||||
|
Font.drawBox(0, 13, 38, 5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Battle animations are authored in the original 160px coordinate space.
|
||||||
|
-- Shift each complete OAM frame as one rigid group between the new player
|
||||||
|
-- and enemy anchors: drawing the whole animation through both side regions
|
||||||
|
-- would duplicate any tiles overlapping the other side's source range (most
|
||||||
|
-- visibly the send-out POOF reappearing on the far right).
|
||||||
|
function WideBattle.animationOffset(sprites)
|
||||||
|
if not sprites or #sprites == 0 then return 0, 0 end
|
||||||
|
local minX, maxX = math.huge, -math.huge
|
||||||
|
for _, sprite in ipairs(sprites) do
|
||||||
|
minX = math.min(minX, sprite.x - 8)
|
||||||
|
maxX = math.max(maxX, sprite.x)
|
||||||
|
end
|
||||||
|
local center = (minX + maxX) / 2
|
||||||
|
local t = math.max(0, math.min(1, (center - 40) / 80))
|
||||||
|
return math.floor(20 + 116 * t + 0.5),
|
||||||
|
math.floor(8 * (1 - t) + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function currentAnimationSprites(battle)
|
||||||
|
if battle.animPlaying and battle.animPlayer then
|
||||||
|
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
|
||||||
|
return step and step.sprites
|
||||||
|
end
|
||||||
|
if battle.lockedBall and battle.animPlayer then
|
||||||
|
return battle.lockedBall
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawAnimationLayer(battle)
|
||||||
|
local sprites = currentAnimationSprites(battle)
|
||||||
|
if not sprites or #sprites == 0 then return end
|
||||||
|
local dx, dy = WideBattle.animationOffset(sprites)
|
||||||
|
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
|
||||||
|
function() battle:drawAnimLayer(false) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The whole 304x144 composition for one frame.
|
||||||
|
function WideBattle.draw(battle)
|
||||||
|
local g = love.graphics
|
||||||
|
-- The field is the display mode's paper. Under a forced-mono mode the
|
||||||
|
-- whole surface is remapped downstream (WideBattle.zones), so the field
|
||||||
|
-- goes down as DMG white and comes out of that pass as the mode's paper;
|
||||||
|
-- painting the resolved shade there would run it through the remap twice
|
||||||
|
-- and land a shade off the letterbox the renderer fills around it.
|
||||||
|
if monoMode() then
|
||||||
|
g.setColor(1, 1, 1, 1)
|
||||||
|
else
|
||||||
|
g.setColor(PaletteFX.paperShade(battle.data))
|
||||||
|
end
|
||||||
|
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||||
|
-- AskName clears the field the same way the classic layout does
|
||||||
|
if battle.blankForAskName then return end
|
||||||
|
|
||||||
|
local fx = battle.fx
|
||||||
|
local sx = (fx and fx.shakeX) or 0
|
||||||
|
local sy = (fx and fx.shakeY) or 0
|
||||||
|
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||||
|
sx = battle.frame % 4 < 2 and 2 or -2
|
||||||
|
end
|
||||||
|
local slide = (battle.introSlide or 0) * 4
|
||||||
|
|
||||||
|
-- Each side keeps its original sprite pixels and placement math: the two
|
||||||
|
-- 160x144 OAM regions are translated apart and clipped into the wider
|
||||||
|
-- battlefield rather than either monster being scaled. wideRegion tells
|
||||||
|
-- drawBattlerPic its own side window is already the clip.
|
||||||
|
battle.wideRegion = true
|
||||||
|
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||||
|
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
|
||||||
|
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||||
|
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||||
|
battle.wideRegion = nil
|
||||||
|
|
||||||
|
drawHUDs(battle, slide)
|
||||||
|
drawAnimationLayer(battle)
|
||||||
|
drawTextArea(battle)
|
||||||
|
|
||||||
|
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||||
|
g.setColor(1, 1, 1, 0.85)
|
||||||
|
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||||
|
end
|
||||||
|
g.setColor(1, 1, 1, 1)
|
||||||
|
if Runtime.wantsHook("battle.overlay") then
|
||||||
|
Runtime.call("battle.overlay", function() end, battle)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The palette zones for the wide surface. The composition already resolves
|
||||||
|
-- species colors, paper shade and HP-bar colors itself, so the colorized
|
||||||
|
-- modes take the trueColor opt-out (`colors = false`) over the whole
|
||||||
|
-- surface; the forced-mono modes still want their whole-screen remap, and
|
||||||
|
-- get one sized to the wide surface instead of the 160x144 rectangle
|
||||||
|
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
|
||||||
|
function WideBattle.zones()
|
||||||
|
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
|
||||||
|
if monoMode() then
|
||||||
|
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
|
||||||
|
-- the inverted permutation), exactly as it does for ensureZones' zone
|
||||||
|
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
|
||||||
|
end
|
||||||
|
return { { colors = false, x = 0, y = 0, w = w, h = h } }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
|
||||||
|
-- and a direction pointing at an empty slot holds the current one.
|
||||||
|
function WideBattle.moveGridIndex(index, count, direction)
|
||||||
|
if count < 1 then return nil end
|
||||||
|
local row = math.floor((index - 1) / 2)
|
||||||
|
local col = (index - 1) % 2
|
||||||
|
if direction == "left" or direction == "right" then
|
||||||
|
local other = row * 2 + (1 - col) + 1
|
||||||
|
return other <= count and other or index
|
||||||
|
end
|
||||||
|
local otherRow = 1 - row
|
||||||
|
local other = otherRow * 2 + col + 1
|
||||||
|
return other <= count and other or index
|
||||||
|
end
|
||||||
|
|
||||||
|
local DIRECTIONS = { "left", "right", "up", "down" }
|
||||||
|
|
||||||
|
-- the slot a directional press selects, or nil when none was pressed (the
|
||||||
|
-- caller then runs its normal list navigation / A / B / SELECT handling)
|
||||||
|
function WideBattle.navigate(index, count, input)
|
||||||
|
for _, key in ipairs(DIRECTIONS) do
|
||||||
|
if input:wasPressed(key) then
|
||||||
|
return WideBattle.moveGridIndex(index, count, key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return WideBattle
|
||||||
@@ -13,4 +13,10 @@ return {
|
|||||||
randMax = 255,
|
randMax = 255,
|
||||||
-- Focus Energy famously QUARTERS the crit rate instead of x4
|
-- Focus Energy famously QUARTERS the crit rate instead of x4
|
||||||
focusEnergyBug = true,
|
focusEnergyBug = true,
|
||||||
|
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||||
|
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||||
|
enemyUnlimitedPP = true,
|
||||||
|
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
|
||||||
|
-- substitute breaks). Set false to always recharge like Gen 2+.
|
||||||
|
hyperBeamSkipRechargeOnKO = true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ return {
|
|||||||
randMin = 217,
|
randMin = 217,
|
||||||
randMax = 255,
|
randMax = 255,
|
||||||
focusEnergyBug = false,
|
focusEnergyBug = false,
|
||||||
|
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||||
|
enemyUnlimitedPP = false,
|
||||||
|
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
|
||||||
|
hyperBeamSkipRechargeOnKO = false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Threaded playMusic returns an empty QueueableSource and only calls
|
||||||
|
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
||||||
|
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
||||||
|
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
|
||||||
|
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
|
||||||
|
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
|
||||||
|
|
||||||
|
function ChipAudio.awaitingFirstBuffer()
|
||||||
|
if forceAwaitingFirstBuffer then return true end
|
||||||
|
local m = currentMusic
|
||||||
|
if not (m and m.threaded and not m.started and not m.finished) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- a dead worker will never deliver the first buffer
|
||||||
|
if workerReady == false then return false end
|
||||||
|
if worker and worker.getError and worker:getError() then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function ChipAudio.stopMusic()
|
function ChipAudio.stopMusic()
|
||||||
if currentMusic and currentMusic.source then
|
if currentMusic and currentMusic.source then
|
||||||
pcall(currentMusic.source.stop, currentMusic.source)
|
pcall(currentMusic.source.stop, currentMusic.source)
|
||||||
@@ -240,6 +259,7 @@ function ChipAudio.stopMusic()
|
|||||||
end
|
end
|
||||||
pendingBuf = nil
|
pendingBuf = nil
|
||||||
currentMusic = nil
|
currentMusic = nil
|
||||||
|
forceAwaitingFirstBuffer = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the
|
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the
|
||||||
@@ -302,6 +322,20 @@ end
|
|||||||
-- test hooks (headless): synchronous synthesis straight through ChipSynth
|
-- test hooks (headless): synchronous synthesis straight through ChipSynth
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Force the "threaded, first buffer not yet queued" window so Music's
|
||||||
|
-- playOnce / pendingRestore race can be asserted without love.thread.
|
||||||
|
-- Returns a clear() that drops the override (call after the assertion).
|
||||||
|
function ChipAudio._simulateAwaitingFirstBufferForTest()
|
||||||
|
local m = currentMusic
|
||||||
|
if not m or not m.source then return nil end
|
||||||
|
m.threaded = true
|
||||||
|
m.started = false
|
||||||
|
m.finished = false
|
||||||
|
pcall(function() m.source.playing = false end)
|
||||||
|
forceAwaitingFirstBuffer = true
|
||||||
|
return function() forceAwaitingFirstBuffer = nil end
|
||||||
|
end
|
||||||
|
|
||||||
function ChipAudio._renderMusicForTest(data, header, seconds)
|
function ChipAudio._renderMusicForTest(data, header, seconds)
|
||||||
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
|
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
|
||||||
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
|
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
|
||||||
|
|||||||
@@ -102,6 +102,39 @@ function Data:seedDefaults()
|
|||||||
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||||
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
||||||
self:seedCinnabarGymTrainerHeaders()
|
self:seedCinnabarGymTrainerHeaders()
|
||||||
|
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
|
||||||
|
-- writes no header for him -- seed one so he engages on sight and has
|
||||||
|
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
||||||
|
self:seedFightingDojoKarateMaster()
|
||||||
|
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||||
|
require("src.world.SsAnneLayout").apply(self.maps)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
|
||||||
|
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||||
|
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||||
|
-- object index [1] so he behaves like the real leader:
|
||||||
|
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
||||||
|
-- spots the player in his DOWN line and he challenges on sight (#197),
|
||||||
|
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||||
|
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||||
|
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||||
|
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
|
||||||
|
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
|
||||||
|
-- this header also set it, the reward's flag guard would early-return and
|
||||||
|
-- swallow the prize dialogue. trainerDefeated tracks him via
|
||||||
|
-- defeatedTrainers[npc.id] like the leaders.
|
||||||
|
function Data:seedFightingDojoKarateMaster()
|
||||||
|
local headers = self.trainer_headers
|
||||||
|
if not headers then return end
|
||||||
|
headers.FightingDojo = headers.FightingDojo or {}
|
||||||
|
if headers.FightingDojo[1] then return end
|
||||||
|
headers.FightingDojo[1] = {
|
||||||
|
range = 4,
|
||||||
|
battle = "_FightingDojoKarateMasterText",
|
||||||
|
won = "_FightingDojoKarateMasterDefeatedText",
|
||||||
|
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
function Data:seedCinnabarGymTrainerHeaders()
|
function Data:seedCinnabarGymTrainerHeaders()
|
||||||
@@ -173,10 +206,15 @@ function Data:load()
|
|||||||
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
|
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
|
||||||
end
|
end
|
||||||
|
|
||||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
-- Drop every namespace the mod merge created and evict the generated modules
|
||||||
-- mod merge created, then re-require the generated modules so base records
|
-- from package.loaded, so the next load() re-reads them off disk instead of
|
||||||
-- return to their on-disk values even where a mod edited them in place
|
-- handing back the cached tables. Two callers:
|
||||||
function Data:reloadGenerated()
|
-- * reloadGenerated below (dev hot reload)
|
||||||
|
-- * main.lua, when the launcher closes the save editor -- the editor may
|
||||||
|
-- have loaded the OTHER game's cache, and require would otherwise serve
|
||||||
|
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
|
||||||
|
-- clears the matching read-path overlay).
|
||||||
|
function Data:unloadGenerated()
|
||||||
local pristine = self._pristineKeys
|
local pristine = self._pristineKeys
|
||||||
if pristine then
|
if pristine then
|
||||||
for key in pairs(self) do
|
for key in pairs(self) do
|
||||||
@@ -189,6 +227,13 @@ function Data:reloadGenerated()
|
|||||||
for _, name in ipairs(OPTIONAL) do
|
for _, name in ipairs(OPTIONAL) do
|
||||||
package.loaded["data.generated." .. name] = nil
|
package.loaded["data.generated." .. name] = nil
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
||||||
|
-- mod merge created, then re-require the generated modules so base records
|
||||||
|
-- return to their on-disk values even where a mod edited them in place
|
||||||
|
function Data:reloadGenerated()
|
||||||
|
self:unloadGenerated()
|
||||||
self:load()
|
self:load()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -213,6 +258,20 @@ function Data:resolveText(mapLabel, textConst)
|
|||||||
local s = self.text[entry.text]
|
local s = self.text[entry.text]
|
||||||
if s then return s, entry.asm end
|
if s then return s, entry.asm end
|
||||||
end
|
end
|
||||||
|
-- A text_asm entry names its wrapper label but carries no string, because
|
||||||
|
-- the extractor cannot follow asm. A handful of those wrappers are plain
|
||||||
|
-- `text_far <label> / text_end` pairs with no logic at all -- BoulderText
|
||||||
|
-- (home/overworld_text.asm:16), MartSignText, PokeCenterSignText -- and
|
||||||
|
-- for those the extracted _Label string IS the whole behavior, so the
|
||||||
|
-- boulders and signs printed nothing at all (#318). Wrappers that really
|
||||||
|
-- do run logic have no _Label string to find, so they still fall through
|
||||||
|
-- to their hand-ported script in data/scripts/.
|
||||||
|
-- needsAsm comes back false here on purpose: showMapText's warning tells
|
||||||
|
-- the reader to go port a script, and for these there is nothing to port.
|
||||||
|
if entry.label then
|
||||||
|
local s = self.text["_" .. entry.label]
|
||||||
|
if s then return s, false end
|
||||||
|
end
|
||||||
return nil, entry.asm
|
return nil, entry.asm
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
local Json = require("src.link.Json")
|
local Json = require("src.link.Json")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local DiscordPresence = {
|
local DiscordPresence = {
|
||||||
APP_ID = "1529183141267374262",
|
APP_ID = "1529183141267374262",
|
||||||
@@ -38,8 +39,18 @@ local state = {
|
|||||||
unsubs = {},
|
unsubs = {},
|
||||||
pid = nil,
|
pid = nil,
|
||||||
loggedAbsent = false, -- one quiet note when Discord isn't running
|
loggedAbsent = false, -- one quiet note when Discord isn't running
|
||||||
|
game = nil, -- kept for the "friend clicked join" -> push a screen path
|
||||||
|
joinCode = nil, -- the online-match/tournament code to advertise as a join secret, if any
|
||||||
|
joinKind = "match", -- "match" (LinkState online) or "tournament"
|
||||||
|
partySize = 1, -- current headcount for the party.size Discord shows
|
||||||
|
partyMax = 2,
|
||||||
|
subscribedJoin = false, -- SUBSCRIBE ACTIVITY_JOIN sent on this connection?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- MSG_DONTWAIT differs by OS (Darwin 0x80, Linux 0x40); only used for the
|
||||||
|
-- ongoing per-frame inbound poll, never for the one-time connect/handshake
|
||||||
|
local MSG_DONTWAIT_BY_OS = { ["OS X"] = 0x80, Linux = 0x40 }
|
||||||
|
|
||||||
local function disabledByEnv()
|
local function disabledByEnv()
|
||||||
return os.getenv("POKEPORT_NO_DISCORD") == "1"
|
return os.getenv("POKEPORT_NO_DISCORD") == "1"
|
||||||
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
|
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
|
||||||
@@ -378,6 +389,15 @@ local function connect()
|
|||||||
state.dirty = true
|
state.dirty = true
|
||||||
state.lastSentKey = nil
|
state.lastSentKey = nil
|
||||||
state.loggedAbsent = false
|
state.loggedAbsent = false
|
||||||
|
state.subscribedJoin = false
|
||||||
|
-- ask for "friend clicked join" dispatches; a plain outbound write, so
|
||||||
|
-- it's harmless to send even on Windows, where the inbound poll below
|
||||||
|
-- isn't implemented yet (see pollJoinRequest)
|
||||||
|
if sendFrame(OP_FRAME, Json.encode({
|
||||||
|
cmd = "SUBSCRIBE", evt = "ACTIVITY_JOIN", nonce = nonce(),
|
||||||
|
})) then
|
||||||
|
state.subscribedJoin = true
|
||||||
|
end
|
||||||
Logger.info("discord: rich presence connected")
|
Logger.info("discord: rich presence connected")
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
@@ -417,7 +437,7 @@ end
|
|||||||
local function buildActivity()
|
local function buildActivity()
|
||||||
local details, activityState
|
local details, activityState
|
||||||
if state.activity == "battle" then
|
if state.activity == "battle" then
|
||||||
details = state.battleLabel or "In battle"
|
details = state.battleLabel or Strings("In battle")
|
||||||
activityState = state.location and (state.location) or "Kanto"
|
activityState = state.location and (state.location) or "Kanto"
|
||||||
elseif state.activity == "exploring" and state.location then
|
elseif state.activity == "exploring" and state.location then
|
||||||
details = state.location
|
details = state.location
|
||||||
@@ -427,7 +447,7 @@ local function buildActivity()
|
|||||||
activityState = "At the title screen"
|
activityState = "At the title screen"
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
local activity = {
|
||||||
details = details,
|
details = details,
|
||||||
state = activityState,
|
state = activityState,
|
||||||
timestamps = { start = state.startedAt },
|
timestamps = { start = state.startedAt },
|
||||||
@@ -436,10 +456,25 @@ local function buildActivity()
|
|||||||
large_text = "Pokemon Gen1Recomp",
|
large_text = "Pokemon Gen1Recomp",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
-- a party/join-secret pair is what makes Discord show "Ask to Join" on
|
||||||
|
-- the profile card; the secret carries our own room/tournament code plus
|
||||||
|
-- a one-letter kind tag ("m"/"t") so the click handler on the other end
|
||||||
|
-- knows which screen to jump into -- Discord only echoes the string
|
||||||
|
-- back, so the kind has to ride inside it
|
||||||
|
if state.joinCode then
|
||||||
|
local kindTag = state.joinKind == "tournament" and "t" or "m"
|
||||||
|
activity.party = { id = "pokeport-" .. state.joinCode,
|
||||||
|
size = { state.partySize, state.partyMax } }
|
||||||
|
activity.secrets = { join = "join:" .. kindTag .. ":" .. state.joinCode }
|
||||||
|
end
|
||||||
|
return activity
|
||||||
end
|
end
|
||||||
|
|
||||||
local function activityKey(activity)
|
local function activityKey(activity)
|
||||||
return (activity.details or "") .. "|" .. (activity.state or "")
|
local partyKey = activity.party
|
||||||
|
and (activity.party.size[1] .. "/" .. activity.party.size[2]) or ""
|
||||||
|
return (activity.details or "") .. "|" .. (activity.state or "") .. "|"
|
||||||
|
.. (activity.secrets and activity.secrets.join or "") .. "|" .. partyKey
|
||||||
end
|
end
|
||||||
|
|
||||||
local function flush(force)
|
local function flush(force)
|
||||||
@@ -516,20 +551,20 @@ local function subscribe(game)
|
|||||||
|
|
||||||
track("battle.started", function(ev)
|
track("battle.started", function(ev)
|
||||||
local battle = ev and ev.battle
|
local battle = ev and ev.battle
|
||||||
local label = "In battle"
|
local label = Strings("In battle")
|
||||||
if ev and ev.kind == "wild" then
|
if ev and ev.kind == "wild" then
|
||||||
local name = speciesName(game, ev.species)
|
local name = speciesName(game, ev.species)
|
||||||
if name then
|
if name then
|
||||||
label = "Battling wild " .. name
|
label = "Battling wild " .. name
|
||||||
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||||
else
|
else
|
||||||
label = "Wild battle"
|
label = Strings("Wild battle")
|
||||||
end
|
end
|
||||||
elseif ev and ev.kind == "trainer" then
|
elseif ev and ev.kind == "trainer" then
|
||||||
local tname = battle and battle.trainer and battle.trainer.name
|
local tname = battle and battle.trainer and battle.trainer.name
|
||||||
label = tname and ("Battling " .. tname) or "Trainer battle"
|
label = tname and ("Battling " .. tname) or Strings("Trainer battle")
|
||||||
elseif ev and ev.kind == "link" then
|
elseif ev and ev.kind == "link" then
|
||||||
label = "Link battle"
|
label = Strings("Link battle")
|
||||||
end
|
end
|
||||||
local mapId = state.mapId
|
local mapId = state.mapId
|
||||||
or (game.save and game.save.player and game.save.player.map)
|
or (game.save and game.save.player and game.save.player.map)
|
||||||
@@ -560,13 +595,103 @@ local function subscribe(game)
|
|||||||
state.mapId = nil
|
state.mapId = nil
|
||||||
setPresence({
|
setPresence({
|
||||||
activity = "menu",
|
activity = "menu",
|
||||||
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
|
location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
|
||||||
clearBattle = true,
|
clearBattle = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Advertise (or clear, with code=nil) an online-match/tournament code as a
|
||||||
|
-- Discord join secret. LinkState/Tournament call this once they have a
|
||||||
|
-- real code from the relay, and clear it again once paired/started or the
|
||||||
|
-- hosting screen exits (an invite that's already full or gone is worse
|
||||||
|
-- than no invite). kind is "match" (default) or "tournament"; size/max are
|
||||||
|
-- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a
|
||||||
|
-- tournament passes its live roster count and a generous cap instead, and
|
||||||
|
-- should call this again whenever the roster changes, not just once.
|
||||||
|
function DiscordPresence.setJoinCode(code, kind, size, max)
|
||||||
|
pcall(function()
|
||||||
|
state.joinCode = code
|
||||||
|
state.joinKind = kind or "match"
|
||||||
|
state.partySize = size or 1
|
||||||
|
state.partyMax = max or 2
|
||||||
|
state.dirty = true
|
||||||
|
flush(false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A friend clicked "Ask to Join" on our profile and Discord delivered the
|
||||||
|
-- secret back over this same connection. Only act from a safe spot (never
|
||||||
|
-- yank the player out of a battle or an already-running link session) --
|
||||||
|
-- "exploring"/"menu" are the coarse buckets subscribe() above already
|
||||||
|
-- tracks; anything more specific (a shop, a dialogue box) is a rarer miss
|
||||||
|
-- worth accepting rather than adding a second, finer-grained state tracker
|
||||||
|
-- just for this.
|
||||||
|
local function handleJoinRequest(secret)
|
||||||
|
if not secret or secret == "" then return end
|
||||||
|
if state.activity == "battle" then return end
|
||||||
|
local game = state.game
|
||||||
|
if not game or not game.stack then return end
|
||||||
|
local top = game.stack:top()
|
||||||
|
if top and top.stage and top.net then return end -- already in a link session
|
||||||
|
local kindTag, code = secret:match("^(%a):(.+)$")
|
||||||
|
if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match
|
||||||
|
Runtime.emit("discord.join_requested", { code = code, kind = kindTag })
|
||||||
|
if kindTag == "t" then
|
||||||
|
local ok, Tournament = pcall(require, "src.link.Tournament")
|
||||||
|
if ok and Tournament.newJoinOnline then
|
||||||
|
game.stack:push(Tournament.newJoinOnline(game, code))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local ok, LinkState = pcall(require, "src.link.LinkState")
|
||||||
|
if ok and LinkState.newJoinOnline then
|
||||||
|
game.stack:push(LinkState.newJoinOnline(game, code))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
|
||||||
|
-- only for now: reading a Windows named pipe without risking a stall needs
|
||||||
|
-- a HANDLE-level PeekNamedPipe that the plain io.open() connection below
|
||||||
|
-- doesn't give us, so a Windows player's presence/party/join-secret still
|
||||||
|
-- all work (they can still be seen and clicked), the click just isn't
|
||||||
|
-- delivered back to their own game session yet.
|
||||||
|
local function pollJoinRequest()
|
||||||
|
if state.isWindows or not state.ffi or not state.socket or not state.subscribedJoin then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
local ffi = state.ffi
|
||||||
|
local dontwait = MSG_DONTWAIT_BY_OS[love.system.getOS()] or 0
|
||||||
|
local hbuf = ffi.new("char[8]")
|
||||||
|
local n = ffi.C.recv(state.socket, hbuf, 8, dontwait)
|
||||||
|
if not n or tonumber(n) <= 0 then return end -- nothing waiting right now
|
||||||
|
local header = ffi.string(hbuf, 8)
|
||||||
|
local opcode = unpackU32(header:sub(1, 4))
|
||||||
|
local length = unpackU32(header:sub(5, 8))
|
||||||
|
if not opcode or not length or length < 0 or length > 65536 then return end
|
||||||
|
-- the body may lag the header by a packet; the connection's normal
|
||||||
|
-- (short-timeout) blocking read is fine here since it has already
|
||||||
|
-- announced its exact length
|
||||||
|
local body = length > 0 and readExact(length) or ""
|
||||||
|
if body == nil then return end
|
||||||
|
if opcode == 3 then -- PING -> PONG, keeps Discord from closing on us
|
||||||
|
sendFrame(4, body)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if opcode ~= 1 then return end
|
||||||
|
local decoded = Json.decode(body)
|
||||||
|
if decoded and decoded.evt == "ACTIVITY_JOIN" then
|
||||||
|
local secret = decoded.data and decoded.data.secret
|
||||||
|
if type(secret) == "string" and secret ~= "" then
|
||||||
|
if secret:sub(1, 5) == "join:" then secret = secret:sub(6) end
|
||||||
|
handleJoinRequest(secret)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function DiscordPresence.init(game)
|
function DiscordPresence.init(game)
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
DiscordPresence.shutdown()
|
DiscordPresence.shutdown()
|
||||||
@@ -579,6 +704,8 @@ function DiscordPresence.init(game)
|
|||||||
state.location = "Title screen"
|
state.location = "Title screen"
|
||||||
state.mapId = nil
|
state.mapId = nil
|
||||||
state.battleLabel = nil
|
state.battleLabel = nil
|
||||||
|
state.joinCode = nil
|
||||||
|
state.game = game
|
||||||
state.dirty = true
|
state.dirty = true
|
||||||
state.nextReconnectAt = 0
|
state.nextReconnectAt = 0
|
||||||
state.loggedAbsent = false
|
state.loggedAbsent = false
|
||||||
@@ -621,6 +748,7 @@ function DiscordPresence.update(_dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
pollJoinRequest()
|
||||||
if state.dirty then flush(false) end
|
if state.dirty then flush(false) end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -649,10 +777,13 @@ function DiscordPresence.shutdown()
|
|||||||
state.lastSentKey = nil
|
state.lastSentKey = nil
|
||||||
state.connected = false
|
state.connected = false
|
||||||
state.socket = nil
|
state.socket = nil
|
||||||
|
state.subscribedJoin = false
|
||||||
|
state.joinCode = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- test / debug helpers
|
-- test / debug helpers
|
||||||
DiscordPresence._state = state
|
DiscordPresence._state = state
|
||||||
DiscordPresence.locationName = locationName
|
DiscordPresence.locationName = locationName
|
||||||
|
DiscordPresence.handleJoinRequest = handleJoinRequest
|
||||||
|
|
||||||
return DiscordPresence
|
return DiscordPresence
|
||||||
|
|||||||
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Drop any pending catch-up steps. A hitch inside one logic step (map
|
||||||
|
-- seam setMap / song start) makes the next real-time dt huge; without this
|
||||||
|
-- the while-loop above would advance many walk frames before the next
|
||||||
|
-- draw, which looks like a slide with no leg animation (issue #93).
|
||||||
|
function FixedStep:discardCatchup()
|
||||||
|
self.accum = 0
|
||||||
|
end
|
||||||
|
|
||||||
return FixedStep
|
return FixedStep
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Render frame-rate cap. With a driver control panel forcing
|
||||||
|
-- vsync off, the 160x144 game is trivially cheap and love.run will present
|
||||||
|
-- thousands of frames a second; over hours that cooks the graphics driver
|
||||||
|
-- until a restart, and it wastes power whenever the window is left open in
|
||||||
|
-- the background. A hard cap bounds the present rate. Render-only: game
|
||||||
|
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
|
||||||
|
-- changes nothing about timing, audio, or determinism.
|
||||||
|
--
|
||||||
|
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
|
||||||
|
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
|
||||||
|
-- frame for its sleep budget. The module never touches love.timer itself,
|
||||||
|
-- so it stays safe under the headless test stub.
|
||||||
|
|
||||||
|
local FrameCap = {}
|
||||||
|
|
||||||
|
-- Selectable steps: the normal framerate stops between the floor and the
|
||||||
|
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
|
||||||
|
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
|
||||||
|
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
|
||||||
|
FrameCap.MIN = 30
|
||||||
|
FrameCap.MAX = 160
|
||||||
|
FrameCap.DEFAULT = 60
|
||||||
|
|
||||||
|
-- The live cap the run loop paces to. Defaults so the launcher and the
|
||||||
|
-- save editor are paced before any save applies its stored option.
|
||||||
|
FrameCap.current = FrameCap.DEFAULT
|
||||||
|
|
||||||
|
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
|
||||||
|
-- an old save with no fpsCap key), so a bad number degrades to something
|
||||||
|
-- sane; nil / non-numbers fall back to the default. A value below MIN or
|
||||||
|
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
|
||||||
|
function FrameCap.normalize(value)
|
||||||
|
value = tonumber(value)
|
||||||
|
if not value then return FrameCap.DEFAULT end
|
||||||
|
local best, bestDiff = FrameCap.DEFAULT, math.huge
|
||||||
|
for _, step in ipairs(FrameCap.STEPS) do
|
||||||
|
local diff = math.abs(step - value)
|
||||||
|
if diff < bestDiff then best, bestDiff = step, diff end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- plain numeric text for the options row (e.g. "60")
|
||||||
|
function FrameCap.label(value)
|
||||||
|
return tostring(FrameCap.normalize(value))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- cycle to the next/previous step, wrapping (the options row idiom)
|
||||||
|
function FrameCap.cycle(value, dir)
|
||||||
|
local steps = FrameCap.STEPS
|
||||||
|
local snapped = FrameCap.normalize(value)
|
||||||
|
local cur = 1
|
||||||
|
for i, step in ipairs(steps) do
|
||||||
|
if step == snapped then cur = i break end
|
||||||
|
end
|
||||||
|
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
|
||||||
|
return steps[nextIdx]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Store the chosen cap as the live value the run loop paces to. Never
|
||||||
|
-- touches love.timer, so it is safe headless -- the loop just reads the
|
||||||
|
-- number back. Returns the normalized value it stored.
|
||||||
|
function FrameCap.apply(value)
|
||||||
|
FrameCap.current = FrameCap.normalize(value)
|
||||||
|
return FrameCap.current
|
||||||
|
end
|
||||||
|
|
||||||
|
function FrameCap.applyOptions(opts)
|
||||||
|
FrameCap.apply(opts and opts.fpsCap)
|
||||||
|
end
|
||||||
|
|
||||||
|
return FrameCap
|
||||||
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local Renderer = require("src.render.Renderer")
|
local Renderer = require("src.render.Renderer")
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
local StateStack = require("src.core.StateStack")
|
local StateStack = require("src.core.StateStack")
|
||||||
local TouchInput = require("src.core.TouchInput")
|
local TouchControls = require("src.core.TouchControls")
|
||||||
local ModLoader = require("src.mods.Loader")
|
local ModLoader = require("src.mods.Loader")
|
||||||
local ModRuntime = require("src.mods.Runtime")
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
@@ -37,12 +37,15 @@ function Game:load()
|
|||||||
self.mods = ModLoader.new()
|
self.mods = ModLoader.new()
|
||||||
self.mods:load(Data)
|
self.mods:load(Data)
|
||||||
self.modStatus = self.mods:status()
|
self.modStatus = self.mods:status()
|
||||||
|
-- render pipelines dispatch off the merged dataset; point them at the
|
||||||
|
-- one the mods just merged into before anything can draw a frame
|
||||||
|
require("src.render.Pipelines").install(Data)
|
||||||
|
|
||||||
self.input = Input
|
self.input = Input
|
||||||
Input:init()
|
Input:init()
|
||||||
|
|
||||||
self.touchInput = TouchInput
|
self.touchControls = TouchControls
|
||||||
TouchInput:init()
|
TouchControls:init()
|
||||||
|
|
||||||
self.renderer = Renderer
|
self.renderer = Renderer
|
||||||
Renderer:init()
|
Renderer:init()
|
||||||
@@ -50,6 +53,9 @@ function Game:load()
|
|||||||
require("src.render.Font").load(Data)
|
require("src.render.Font").load(Data)
|
||||||
-- menu cursor/border/geometry constants; field.theme restyles them
|
-- menu cursor/border/geometry constants; field.theme restyles them
|
||||||
require("src.ui.Theme").load(Data)
|
require("src.ui.Theme").load(Data)
|
||||||
|
-- the engine's own text, after the merge so a translation mod's catalog
|
||||||
|
-- is already in Data.strings; empty on a mod-free boot and skipped
|
||||||
|
require("src.core.Strings").load(Data)
|
||||||
|
|
||||||
self.stack = StateStack
|
self.stack = StateStack
|
||||||
StateStack:init()
|
StateStack:init()
|
||||||
@@ -93,6 +99,20 @@ function Game:load()
|
|||||||
end
|
end
|
||||||
|
|
||||||
Logger.info("game loaded")
|
Logger.info("game loaded")
|
||||||
|
-- Scaling bug reports (#87, #208) are unanswerable without these three
|
||||||
|
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
|
||||||
|
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
|
||||||
|
-- looks stretched" into something reproducible.
|
||||||
|
if love.graphics and love.graphics.getDimensions then
|
||||||
|
local ww, wh = love.graphics.getDimensions()
|
||||||
|
local pw, ph = ww, wh
|
||||||
|
if love.graphics.getPixelDimensions then
|
||||||
|
pw, ph = love.graphics.getPixelDimensions()
|
||||||
|
end
|
||||||
|
Logger.info(string.format(
|
||||||
|
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
|
||||||
|
ww, wh, pw, ph, Renderer:fitScale()))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the merged field.boot: spawn, names, money and the naming presets a
|
-- the merged field.boot: spawn, names, money and the naming presets a
|
||||||
@@ -171,15 +191,21 @@ end
|
|||||||
-- or screenshot run does not depend on whatever the player last chose.
|
-- or screenshot run does not depend on whatever the player last chose.
|
||||||
function Game:logicSpeed()
|
function Game:logicSpeed()
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
|
-- Link play is always 1X on both machines, and this wins over every other
|
||||||
|
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
||||||
|
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
||||||
|
-- than the opponent it is racing, and drove its own animation/message
|
||||||
|
-- queue at a different rate than the peer it is locked to. Nothing about
|
||||||
|
-- a match should depend on what either player set this to.
|
||||||
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||||
local opts = self.save and self.save.options
|
local opts = self.save and self.save.options
|
||||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:update(dt)
|
function Game:update(dt)
|
||||||
-- Touch timers / prior-frame auto-releases before the fixed step so
|
|
||||||
-- deferred A and edge pulses land in Input's press queue for this step.
|
|
||||||
TouchInput:update(dt)
|
|
||||||
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||||
-- Give the accumulator room for one full frame at the current speed,
|
-- Give the accumulator room for one full frame at the current speed,
|
||||||
-- or the anti-spiral clamp quietly caps every level above ~15X.
|
-- or the anti-spiral clamp quietly caps every level above ~15X.
|
||||||
@@ -198,6 +224,9 @@ function Game:update(dt)
|
|||||||
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
||||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||||
require("src.render.Tilt").update(dt)
|
require("src.render.Tilt").update(dt)
|
||||||
|
-- mod render pipelines tween on the same real-frame clock, for the same
|
||||||
|
-- reason: they are presentational, so fast-forward must not speed them up
|
||||||
|
require("src.render.Pipelines").update(dt)
|
||||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||||
-- Steady-state memory backstop: advance the incremental collector one
|
-- Steady-state memory backstop: advance the incremental collector one
|
||||||
-- small step every rendered frame. The heavy GPU objects are now freed
|
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||||
@@ -218,6 +247,16 @@ function Game:draw()
|
|||||||
-- white clear
|
-- white clear
|
||||||
local base = self.stack:visibleBase()
|
local base = self.stack:visibleBase()
|
||||||
local worldBelow = self.stack.states[base] == self.overworld
|
local worldBelow = self.stack.states[base] == self.overworld
|
||||||
|
-- The UI surface is resolved once, before any state draws: the top state
|
||||||
|
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
||||||
|
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
||||||
|
-- pushed over a wide battle brings the screen straight back to 160x144.
|
||||||
|
local top = self.stack:top()
|
||||||
|
if top and top.uiSize then
|
||||||
|
Renderer:setUISize(top:uiSize())
|
||||||
|
else
|
||||||
|
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||||
|
end
|
||||||
Renderer:beginFrame(worldBelow)
|
Renderer:beginFrame(worldBelow)
|
||||||
self.stack:draw()
|
self.stack:draw()
|
||||||
-- SGB colorization: the topmost state that knows its palette owns the
|
-- SGB colorization: the topmost state that knows its palette owns the
|
||||||
@@ -240,13 +279,19 @@ function Game:draw()
|
|||||||
worldZones = self.overworld:sgbWorldZones()
|
worldZones = self.overworld:sgbWorldZones()
|
||||||
end
|
end
|
||||||
Renderer:endFrame(zones, worldZones)
|
Renderer:endFrame(zones, worldZones)
|
||||||
|
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||||
|
TouchControls:draw()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
|
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
|
||||||
function Game:zoomStep(delta)
|
function Game:zoomStep(delta)
|
||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
|
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
|
||||||
Zoom.step(delta, Renderer:fitScale())
|
local offset = Zoom.step(delta, Renderer:fitScale())
|
||||||
|
if self.save and self.save.options then
|
||||||
|
self.save.options.zoom = offset
|
||||||
|
self:writeOptions()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:wheelmoved(_, dy)
|
function Game:wheelmoved(_, dy)
|
||||||
@@ -320,13 +365,34 @@ function Game:keypressed(key)
|
|||||||
self:writeOptions()
|
self:writeOptions()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
|
elseif key == "4" then
|
||||||
|
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
if Zoom.gateOK(self.stack:top(), self.overworld) then
|
||||||
|
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
|
||||||
|
self:writeOptions()
|
||||||
|
end
|
||||||
|
return
|
||||||
elseif key == "5" then
|
elseif key == "5" then
|
||||||
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
||||||
|
-- desktop. Mobile refuses the present shader (issue #136).
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
|
if not GBCFX.isSupported() then return end
|
||||||
self.save.options.gbcfx = GBCFX.cycle()
|
self.save.options.gbcfx = GBCFX.cycle()
|
||||||
self:writeOptions()
|
self:writeOptions()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- Mod render pipelines claim their hotkeys last, so one can never shadow
|
||||||
|
-- an engine display key however a mod declares it (12 §rendering
|
||||||
|
-- pipelines). syncOptions writes the whole ladder back, including the
|
||||||
|
-- tilt exclusion a world pipeline forces.
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
|
||||||
|
Pipelines.syncOptions(self.save.options)
|
||||||
|
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
|
||||||
|
self:writeOptions()
|
||||||
|
return
|
||||||
|
end
|
||||||
Input:keypressed(key)
|
Input:keypressed(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -334,9 +400,7 @@ end
|
|||||||
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
|
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
|
||||||
-- the newly selected mod state.
|
-- the newly selected mod state.
|
||||||
function Game:restartWithMods()
|
function Game:restartWithMods()
|
||||||
if love.event and love.event.quit then
|
require("src.core.HostShell").restart()
|
||||||
love.event.quit("restart")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:keyreleased(key)
|
function Game:keyreleased(key)
|
||||||
@@ -344,6 +408,9 @@ function Game:keyreleased(key)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:gamepadpressed(joystick, button)
|
function Game:gamepadpressed(joystick, button)
|
||||||
|
-- a controller is being used: the touch overlay steps aside until the
|
||||||
|
-- next screen touch (mobile only; a no-op elsewhere)
|
||||||
|
TouchControls:noteGamepad()
|
||||||
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
||||||
local top = self.stack and self.stack:top()
|
local top = self.stack and self.stack:top()
|
||||||
if top and top.onGamepadPressed then
|
if top and top.onGamepadPressed then
|
||||||
@@ -358,6 +425,8 @@ function Game:gamepadreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:gamepadaxis(joystick, axis, value)
|
function Game:gamepadaxis(joystick, axis, value)
|
||||||
|
-- past-deadzone only, so resting-stick drift can't hide the overlay
|
||||||
|
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -367,12 +436,12 @@ end
|
|||||||
-- state is worse than asking the player to re-press.
|
-- state is worse than asking the player to re-press.
|
||||||
function Game:focus(f)
|
function Game:focus(f)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
TouchInput:reset()
|
TouchControls:reset()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:visible(v)
|
function Game:visible(v)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
TouchInput:reset()
|
TouchControls:reset()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A disconnected/dropped controller can't send the button-up for whatever
|
-- A disconnected/dropped controller can't send the button-up for whatever
|
||||||
@@ -380,19 +449,19 @@ end
|
|||||||
-- flags it owned.
|
-- flags it owned.
|
||||||
function Game:joystickremoved(joystick)
|
function Game:joystickremoved(joystick)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
TouchInput:reset()
|
TouchControls:joystickremoved()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:touchpressed(id, x, y)
|
function Game:touchpressed(id, x, y)
|
||||||
TouchInput:touchpressed(id, x, y)
|
TouchControls:touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:touchmoved(id, x, y)
|
function Game:touchmoved(id, x, y)
|
||||||
TouchInput:touchmoved(id, x, y)
|
TouchControls:touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:touchreleased(id, x, y)
|
function Game:touchreleased(id, x, y)
|
||||||
TouchInput:touchreleased(id, x, y)
|
TouchControls:touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Point the loader's mod.save backing at this save's modData so per-mod
|
-- Point the loader's mod.save backing at this save's modData so per-mod
|
||||||
@@ -443,9 +512,20 @@ function Game:applyOptions(opts)
|
|||||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||||
require("src.render.PaletteFX").applyOptions(opts)
|
require("src.render.PaletteFX").applyOptions(opts)
|
||||||
require("src.render.Tilt").applyOptions(opts)
|
require("src.render.Tilt").applyOptions(opts)
|
||||||
require("src.render.GBCFX").applyOptions(opts)
|
-- after Tilt, so a persisted world pipeline can switch the tilt level it
|
||||||
|
-- just restored back off (the two are mutually exclusive)
|
||||||
|
require("src.render.Pipelines").applyOptions(opts)
|
||||||
|
require("src.render.Zoom").applyOptions(opts)
|
||||||
|
require("src.render.TileRenderer").applyOptions(opts)
|
||||||
|
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||||
|
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||||
require("src.core.VideoMode").applyOptions(opts)
|
require("src.core.VideoMode").applyOptions(opts)
|
||||||
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
Input:applyBindings(opts.bindings)
|
Input:applyBindings(opts.bindings)
|
||||||
|
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||||
|
if gbcCleared then self:writeOptions() end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:restoreSave(loaded, recovered)
|
function Game:restoreSave(loaded, recovered)
|
||||||
@@ -457,6 +537,13 @@ function Game:restoreSave(loaded, recovered)
|
|||||||
-- SaveData.load and skip on the format guard
|
-- SaveData.load and skip on the format guard
|
||||||
local activeMods = self.modStatus and self.modStatus.loaded
|
local activeMods = self.modStatus and self.modStatus.loaded
|
||||||
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
||||||
|
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
|
||||||
|
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
|
||||||
|
if SaveData.needsPostGameRescue(loaded) then
|
||||||
|
SaveData.applyPostGameHome(loaded, self:bootConfig())
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
|
||||||
|
end
|
||||||
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
||||||
local report = SaveData.validate(loaded, self.data)
|
local report = SaveData.validate(loaded, self.data)
|
||||||
report.recovered = recovered
|
report.recovered = recovered
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Helpers for calling host tools (curl, zenity/kdialog, ...).
|
||||||
|
|
||||||
|
local HostShell = {}
|
||||||
|
|
||||||
|
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
|
||||||
|
-- spawn tries to link against the libraries we're shipping instead of the
|
||||||
|
-- system ones. We want to unset the var so that any system tools can find
|
||||||
|
-- their proper libraries. Only needed when running in an AppImage.
|
||||||
|
function HostShell.envPrefix()
|
||||||
|
if os.getenv("APPIMAGE") then
|
||||||
|
return "env -u LD_LIBRARY_PATH "
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||||
|
function HostShell.popen(command, mode)
|
||||||
|
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||||
|
if not ok or not pipe then return nil end
|
||||||
|
return pipe
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
|
||||||
|
-- boot in-process, which calls love.filesystem.init a second time -- and inside
|
||||||
|
-- an AppImage physfs is already initialized, so that second init throws
|
||||||
|
-- ("Failed to initialize filesystem: already initialized") and the relaunch
|
||||||
|
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
|
||||||
|
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
|
||||||
|
-- On every other platform the in-process restart works, so keep it.
|
||||||
|
function HostShell.restart()
|
||||||
|
if not (love and love.event and love.event.quit) then return end
|
||||||
|
local appimage = os.getenv("APPIMAGE")
|
||||||
|
if not appimage then
|
||||||
|
love.event.quit("restart")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- We have to restart the process with this cursed execv call to prevent the
|
||||||
|
-- PID from changing, which might cause SteamOS and other Linux launchers to
|
||||||
|
-- think the app has crashed.
|
||||||
|
local ffi = require("ffi")
|
||||||
|
pcall(ffi.cdef, [[
|
||||||
|
int execv(const char *path, char *const argv[]);
|
||||||
|
int unsetenv(const char *name);
|
||||||
|
]])
|
||||||
|
ffi.C.unsetenv("LD_LIBRARY_PATH")
|
||||||
|
local argv = ffi.new("const char *[2]", appimage, nil)
|
||||||
|
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
|
||||||
|
end
|
||||||
|
|
||||||
|
return HostShell
|
||||||
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
|
|||||||
z = "a", ["return"] = "a", space = "a",
|
z = "a", ["return"] = "a", space = "a",
|
||||||
x = "b", backspace = "b",
|
x = "b", backspace = "b",
|
||||||
["kpenter"] = "start", escape = "start",
|
["kpenter"] = "start", escape = "start",
|
||||||
|
-- Select: fight-menu move reorder + bag item reorder. Tab is the
|
||||||
|
-- discoverable default (shown in CONTROLS); both shifts stay as
|
||||||
|
-- aliases so Right-Shift muscle memory from older builds still works.
|
||||||
|
tab = "select",
|
||||||
rshift = "select",
|
rshift = "select",
|
||||||
|
lshift = "select",
|
||||||
}
|
}
|
||||||
|
|
||||||
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
|
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
|
||||||
@@ -139,7 +144,7 @@ function Input:step()
|
|||||||
elseif next(sources) ~= nil then
|
elseif next(sources) ~= nil then
|
||||||
self.state[btn] = true
|
self.state[btn] = true
|
||||||
end
|
end
|
||||||
-- sources == {}: real press fully released before this step — keep up
|
-- sources == {}: real press fully released before this step -- keep up
|
||||||
end
|
end
|
||||||
for btn, sources in pairs(self.sources) do
|
for btn, sources in pairs(self.sources) do
|
||||||
if next(sources) == nil then
|
if next(sources) == nil then
|
||||||
@@ -149,6 +154,17 @@ function Input:step()
|
|||||||
self.pressQueue = {}
|
self.pressQueue = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The on-screen touch overlay (src/core/TouchControls.lua) presses GB
|
||||||
|
-- buttons directly by name -- not through a keyboard alias -- so a player
|
||||||
|
-- rebind can never detach or shadow the overlay.
|
||||||
|
function Input:overlayPressed(btn)
|
||||||
|
press(self, btn, "touch:" .. btn)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:overlayReleased(btn)
|
||||||
|
release(self, btn, "touch:" .. btn)
|
||||||
|
end
|
||||||
|
|
||||||
function Input:gamepadpressed(joystick, button)
|
function Input:gamepadpressed(joystick, button)
|
||||||
local btn = self.padBindings[button]
|
local btn = self.padBindings[button]
|
||||||
if btn then
|
if btn then
|
||||||
|
|||||||
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
|||||||
local filterLevel = 0
|
local filterLevel = 0
|
||||||
|
|
||||||
local function applyVolume(src)
|
local function applyVolume(src)
|
||||||
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
|
if not src then return end
|
||||||
|
local vol = VOLUME * volumeScale
|
||||||
|
if Runtime.wantsHook("music.volume") then
|
||||||
|
local ctx = {
|
||||||
|
song = state.current,
|
||||||
|
mapSong = state.mapSong,
|
||||||
|
onBike = state.onBike,
|
||||||
|
surfing = state.surfing,
|
||||||
|
fading = state.fade ~= nil,
|
||||||
|
optionScale = volumeScale,
|
||||||
|
}
|
||||||
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
|
if ok and Game then
|
||||||
|
local ow = Game.overworld
|
||||||
|
if ow and ow.player then
|
||||||
|
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
|
||||||
|
ctx.mapId = ow.map and ow.map.id
|
||||||
|
ctx.tod = ow.tod
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
|
||||||
|
vol = tonumber(vol) or (VOLUME * volumeScale)
|
||||||
|
if vol < 0 then vol = 0 end
|
||||||
|
end
|
||||||
|
pcall(src.setVolume, src, vol)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
||||||
@@ -254,6 +278,7 @@ function Music.stop()
|
|||||||
require("src.core.ChipAudio").stopMusic()
|
require("src.core.ChipAudio").stopMusic()
|
||||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||||
state.chip = false
|
state.chip = false
|
||||||
|
state.pendingRestore = nil
|
||||||
if previous and Runtime.wants("music.stopped") then
|
if previous and Runtime.wants("music.stopped") then
|
||||||
Runtime.emit("music.stopped", { song = previous })
|
Runtime.emit("music.stopped", { song = previous })
|
||||||
end
|
end
|
||||||
@@ -347,18 +372,25 @@ end
|
|||||||
function Music.playOnce(data, song)
|
function Music.playOnce(data, song)
|
||||||
if not songDef(data, song) then return false end
|
if not songDef(data, song) then return false end
|
||||||
Music.play(data, song, false, { reason = "once" })
|
Music.play(data, song, false, { reason = "once" })
|
||||||
|
-- play() can no-op (hook silence, failed def); only arm restore when
|
||||||
|
-- the jingle actually became current
|
||||||
|
if state.current ~= song then return false end
|
||||||
state.pendingRestore = true
|
state.pendingRestore = true
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
|
local function chipAwaitingFirstBuffer()
|
||||||
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
|
return state.chip
|
||||||
|
and require("src.core.ChipAudio").awaitingFirstBuffer()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
|
||||||
|
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
|
||||||
|
-- pendingRestore stays set from playOnce until Music.update restores the
|
||||||
|
-- map theme, covering the threaded chip "empty QueueableSource" window
|
||||||
|
-- where Source:isPlaying is briefly false before the first buffer lands.
|
||||||
function Music.oneShotPlaying()
|
function Music.oneShotPlaying()
|
||||||
if not state.pendingRestore then return false end
|
return state.pendingRestore == true
|
||||||
local src = state.source
|
|
||||||
if not src then return false end
|
|
||||||
local ok, playing = pcall(src.isPlaying, src)
|
|
||||||
return ok and playing or false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Music.restoreMap(data)
|
function Music.restoreMap(data)
|
||||||
@@ -400,6 +432,12 @@ end
|
|||||||
-- restores the map theme after a one-shot jingle
|
-- restores the map theme after a one-shot jingle
|
||||||
function Music.update(data)
|
function Music.update(data)
|
||||||
if state.chip then require("src.core.ChipAudio").update() end
|
if state.chip then require("src.core.ChipAudio").update() end
|
||||||
|
-- distance / indoor muffling mods re-apply volume every frame while
|
||||||
|
-- subscribed; otherwise applyVolume only runs on song/option changes
|
||||||
|
if Runtime.wantsHook("music.volume") and not state.fade then
|
||||||
|
applyVolume(state.source)
|
||||||
|
applyVolume(state.loopSource)
|
||||||
|
end
|
||||||
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
||||||
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
||||||
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
||||||
@@ -441,8 +479,10 @@ function Music.update(data)
|
|||||||
state.source = loopSrc
|
state.source = loopSrc
|
||||||
pcall(loopSrc.play, loopSrc)
|
pcall(loopSrc.play, loopSrc)
|
||||||
end
|
end
|
||||||
|
-- do not treat "threaded source still waiting on its first buffer" as
|
||||||
|
-- ended, or playOnce jingles get restored over before they can sound
|
||||||
if state.pendingRestore and sourceStopped(state.source)
|
if state.pendingRestore and sourceStopped(state.source)
|
||||||
and not state.loopSource then
|
and not state.loopSource and not chipAwaitingFirstBuffer() then
|
||||||
Music.restoreMap(data)
|
Music.restoreMap(data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ local SaveSerializer = require("src.core.SaveSerializer")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Semver = require("src.mods.Semver")
|
local Semver = require("src.mods.Semver")
|
||||||
local Boxes = require("src.pokemon.Boxes")
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local Stats = require("src.pokemon.Stats")
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
|
local Badges = require("src.inventory.Badges")
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
@@ -31,10 +33,11 @@ local OPTIONS_FILENAME = "options.lua"
|
|||||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||||
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
||||||
-- load promotes either when the main file is missing or fails to parse.
|
-- load promotes either when the main file is missing or fails to parse.
|
||||||
local function saveNames(version)
|
-- Forward-declared here so saveFilename (just below) and every save/load
|
||||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
-- caller share the one upvalue; the body is filled in under "save slots"
|
||||||
return main, main .. ".bak", main .. ".tmp"
|
-- once the options IO it depends on exists, because it now resolves the
|
||||||
end
|
-- ACTIVE slot for a version rather than the fixed flat name.
|
||||||
|
local saveNames
|
||||||
|
|
||||||
-- The main save filename for a version -- used by the title screen's
|
-- The main save filename for a version -- used by the title screen's
|
||||||
-- CONTINUE gate so it looks for the right game's save.
|
-- CONTINUE gate so it looks for the right game's save.
|
||||||
@@ -91,6 +94,18 @@ local function makePortableFs(dir)
|
|||||||
os.remove(full(name))
|
os.remove(full(name))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
|
createDirectory = function(name)
|
||||||
|
-- portable mode writes real files through io.*, which will not
|
||||||
|
-- create missing parent directories; mkdir the tree so a slot path
|
||||||
|
-- like "saves/red" exists before a write lands inside it
|
||||||
|
local osPath = full(name):gsub("/", SEP)
|
||||||
|
if SEP == "\\" then
|
||||||
|
os.execute('mkdir "' .. osPath .. '" 2>nul')
|
||||||
|
else
|
||||||
|
os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null')
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -118,17 +133,26 @@ local function detectPortable()
|
|||||||
-- Recover the folder containing the .app so a packaged app finds its
|
-- Recover the folder containing the .app so a packaged app finds its
|
||||||
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
|
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
|
||||||
-- and the plain source-base directory (next to the .exe/AppImage) is used.
|
-- and the plain source-base directory (next to the .exe/AppImage) is used.
|
||||||
local function appContainer(path)
|
local function parentDir(path)
|
||||||
local appPath = path and path:match("^(.*%.app)/Contents/")
|
return path and path:match("^(.*)/[^/]+$") or nil
|
||||||
return appPath and appPath:match("^(.*)/[^/]+$") or nil
|
|
||||||
end
|
end
|
||||||
-- Order: the .app's containing folder (packaged macOS), then the
|
local function appContainer(path)
|
||||||
-- source-base directory (next to a packaged .exe/AppImage), then the
|
return parentDir(path and path:match("^(.*%.app)/Contents/"))
|
||||||
-- source itself (a `love <gamedir>` run drops portable.txt in the game
|
end
|
||||||
-- folder). First one holding the marker wins. Built by appending so a
|
-- The Linux AppImage build works similarly to macOs, but the runtime mounts
|
||||||
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
|
-- the squashfs at a temp path, and that becomes the base directory.
|
||||||
|
-- The runtime exports the real .AppImage path in $APPIMAGE, so we get the
|
||||||
|
-- base dir from there.
|
||||||
|
local function appImageContainer()
|
||||||
|
return parentDir(os.getenv("APPIMAGE"))
|
||||||
|
end
|
||||||
|
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
|
||||||
|
-- then the source-base directory (next to a packaged .exe), then the source
|
||||||
|
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
|
||||||
|
-- First one holding the marker wins. Built by appending so a nil (e.g. no
|
||||||
|
-- .app in the path) never truncates the ipairs scan.
|
||||||
local candidates = {}
|
local candidates = {}
|
||||||
local appDir = appContainer(src) or appContainer(sbd)
|
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
|
||||||
if appDir then candidates[#candidates + 1] = appDir end
|
if appDir then candidates[#candidates + 1] = appDir end
|
||||||
if sbd then candidates[#candidates + 1] = sbd end
|
if sbd then candidates[#candidates + 1] = sbd end
|
||||||
if src then candidates[#candidates + 1] = src end
|
if src then candidates[#candidates + 1] = src end
|
||||||
@@ -179,6 +203,9 @@ function SaveData.defaultOptions()
|
|||||||
textSpeed = 3,
|
textSpeed = 3,
|
||||||
animations = true,
|
animations = true,
|
||||||
battleStyle = "shift",
|
battleStyle = "shift",
|
||||||
|
-- battle screen composition: og (the 160x144 original) | wide
|
||||||
|
-- (304x144, src/battle/WideBattle.lua)
|
||||||
|
battleLayout = "og",
|
||||||
ruleset = "gen1_faithful",
|
ruleset = "gen1_faithful",
|
||||||
-- 0-7 like the GB's NR50 master volume
|
-- 0-7 like the GB's NR50 master volume
|
||||||
musicVol = 7,
|
musicVol = 7,
|
||||||
@@ -186,12 +213,23 @@ function SaveData.defaultOptions()
|
|||||||
musicFilter = 0,
|
musicFilter = 0,
|
||||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||||
speed = 1,
|
speed = 1,
|
||||||
-- port display options (OptionsMenu / hotkeys 2/3/5)
|
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
|
||||||
colors = "gbc",
|
colors = "gbc",
|
||||||
tilt = 0,
|
tilt = 0,
|
||||||
gbcfx = 0,
|
gbcfx = 0,
|
||||||
|
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
|
||||||
|
zoom = 0,
|
||||||
|
-- OVERWORLD beyond-edge fill: trees | water | black
|
||||||
|
voidFill = "trees",
|
||||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
|
fpsCap = 60,
|
||||||
|
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||||
|
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||||
|
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
||||||
|
-- the player left it in.
|
||||||
|
pipelines = {},
|
||||||
-- Native mod enablement is an installation option, not save-slot data.
|
-- Native mod enablement is an installation option, not save-slot data.
|
||||||
-- Missing entries mean enabled so newly installed mods work by default.
|
-- Missing entries mean enabled so newly installed mods work by default.
|
||||||
mods = {},
|
mods = {},
|
||||||
@@ -277,6 +315,355 @@ function SaveData.loadOptions(fs)
|
|||||||
return SaveData.mergeOptions(data)
|
return SaveData.mergeOptions(data)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- save slots
|
||||||
|
|
||||||
|
-- A version's playthroughs live in numbered slots under saves/<version>/;
|
||||||
|
-- the active slot is where in-game SAVE and CONTINUE land. The registry
|
||||||
|
-- (the ordered slot list plus which one is active) persists in options.lua
|
||||||
|
-- under options.saveSlots[version]; the active slot is also cached
|
||||||
|
-- process-wide (like GameVersion.current) so the hot saveNames path does
|
||||||
|
-- not re-read options every call. A false cache entry means "no slot in
|
||||||
|
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
|
||||||
|
-- keeps a brand-new install and every pre-slots caller working unchanged.
|
||||||
|
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||||
|
local slotsChecked = {} -- version -> true once resolved this process
|
||||||
|
|
||||||
|
local function slotDir(version) return "saves/" .. version end
|
||||||
|
|
||||||
|
local function slotNames(version, id)
|
||||||
|
local main = slotDir(version) .. "/" .. id .. ".lua"
|
||||||
|
return main, main .. ".bak", main .. ".tmp"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the pre-slots flat names a version always used (save.lua for Red,
|
||||||
|
-- save_blue.lua for Blue); still the destination before any slot exists
|
||||||
|
local function legacyNames(version)
|
||||||
|
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||||
|
return main, main .. ".bak", main .. ".tmp"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Slot resolution is only meaningful for versions GameVersion actually knows
|
||||||
|
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
|
||||||
|
-- that has no info entry and therefore no saveSuffix; resolving its legacy
|
||||||
|
-- names would index a nil info table and crash. Treat any unknown version as
|
||||||
|
-- having no slots so the slot APIs degrade to empty/no-op instead.
|
||||||
|
local function knownVersion(version)
|
||||||
|
return GameVersion.info(version) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create the parent directory of a slot path when the fs supports it.
|
||||||
|
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
|
||||||
|
-- stub keys files by full path and exposes no such method, so this is a
|
||||||
|
-- no-op there.
|
||||||
|
local function ensureParentDir(fs, name)
|
||||||
|
local dir = name:match("^(.*)/[^/]+$")
|
||||||
|
if dir and fs.createDirectory then fs.createDirectory(dir) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Decode a slot's save using the same recovery order load() uses -- main,
|
||||||
|
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
|
||||||
|
-- summarizes. nil when nothing readable is present.
|
||||||
|
local function decodeSlot(fs, version, id)
|
||||||
|
local main, bak, tmp = slotNames(version, id)
|
||||||
|
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
|
||||||
|
if data then return data end
|
||||||
|
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
|
||||||
|
if data then return data end
|
||||||
|
data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "")
|
||||||
|
return data or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One-time legacy consolidation: a pre-slots install has a flat save file
|
||||||
|
-- (+ .bak) and no saves/<version>/ registry. Copy both into slot1, verify
|
||||||
|
-- the copy reads back, then remove the originals and register slot1 as the
|
||||||
|
-- active slot. Returns the new slot id, or nil when there is nothing to
|
||||||
|
-- migrate or the copy could not be verified (originals left in place so no
|
||||||
|
-- data is ever lost to a failed move).
|
||||||
|
local function tryMigrateLegacy(version, fs)
|
||||||
|
local lmain, lbak, ltmp = legacyNames(version)
|
||||||
|
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
|
||||||
|
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
|
||||||
|
if not (mainBody or bakBody) then return nil end
|
||||||
|
local id = "slot1"
|
||||||
|
local dmain, dbak = slotNames(version, id)
|
||||||
|
ensureParentDir(fs, dmain)
|
||||||
|
if mainBody then fs.write(dmain, mainBody) end
|
||||||
|
if bakBody then fs.write(dbak, bakBody) end
|
||||||
|
-- refuse to delete the originals unless the new slot is loadable (from
|
||||||
|
-- the main copy or, failing that, the backup)
|
||||||
|
if not decodeSlot(fs, version, id) then return nil end
|
||||||
|
remove(fs, lmain)
|
||||||
|
remove(fs, lbak)
|
||||||
|
remove(fs, ltmp)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
|
opts.saveSlots[version] = { list = { id }, active = id }
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||||
|
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||||
|
-- slot1; otherwise false, meaning the flat legacy path.
|
||||||
|
local function ensureVersionSlots(version, fs)
|
||||||
|
if slotsChecked[version] then return end
|
||||||
|
slotsChecked[version] = true
|
||||||
|
if not knownVersion(version) then
|
||||||
|
activeSlotCache[version] = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||||
|
if reg and type(reg.list) == "table" and #reg.list > 0 then
|
||||||
|
activeSlotCache[version] = reg.active or reg.list[1]
|
||||||
|
return
|
||||||
|
end
|
||||||
|
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||||
|
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||||
|
function saveNames(version)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
ensureVersionSlots(version, fs)
|
||||||
|
local slot = activeSlotCache[version]
|
||||||
|
if slot then return slotNames(version, slot) end
|
||||||
|
return legacyNames(version)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure extraction of the launcher's per-slot summary from a decoded save,
|
||||||
|
-- factored out so it is unit-testable with no filesystem: the player name
|
||||||
|
-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same
|
||||||
|
-- fields the title screen's ContinueInfo derives. Badges resolve against
|
||||||
|
-- the vanilla gym list (launcher has no loaded Data), which is what the
|
||||||
|
-- flat launcher meta line needs.
|
||||||
|
function SaveData.slotSummary(save)
|
||||||
|
if type(save) ~= "table" then return nil, nil end
|
||||||
|
local name = save.player and save.player.name or nil
|
||||||
|
local dexCount = 0
|
||||||
|
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
||||||
|
dexCount = dexCount + 1
|
||||||
|
end
|
||||||
|
local t = math.floor(save.playTime or 0)
|
||||||
|
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
|
||||||
|
math.floor(t / 60) % 60)
|
||||||
|
return name, {
|
||||||
|
badges = Badges.count(nil, save),
|
||||||
|
timeText = timeText,
|
||||||
|
dexCount = dexCount,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The absolute on-disk path of a slot's save file, for the one caller that
|
||||||
|
-- cannot go through love.filesystem: the save editor reads and writes with
|
||||||
|
-- raw io.* so it can also open a file the player dragged in from anywhere.
|
||||||
|
-- Resolves against the same root persistFs would write to -- the portable
|
||||||
|
-- game folder when portable mode is on, otherwise LOVE's save directory --
|
||||||
|
-- so Edit on a launcher save row lands on the file the game actually plays.
|
||||||
|
-- nil when neither root is available (headless tests with an injected fs).
|
||||||
|
function SaveData.slotDiskPath(version, slotId)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) or not slotId then return nil end
|
||||||
|
local base = SaveData.portableBaseDir()
|
||||||
|
or (love and love.filesystem and love.filesystem.getSaveDirectory
|
||||||
|
and love.filesystem.getSaveDirectory())
|
||||||
|
if not base then return nil end
|
||||||
|
local sep = package.config:sub(1, 1)
|
||||||
|
local rel = select(1, slotNames(version, slotId))
|
||||||
|
return base .. sep .. rel:gsub("/", sep)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Slots visible to the launcher: every registered slot for a version, each
|
||||||
|
-- with whether it holds a save and the cheap summary above. A fresh
|
||||||
|
-- install with nothing registered returns an empty array; a legacy install
|
||||||
|
-- is migrated to slot1 first.
|
||||||
|
function SaveData.listSlots(version)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return {} end
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
ensureVersionSlots(version, fs)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||||
|
local list = (reg and reg.list) or {}
|
||||||
|
local out = {}
|
||||||
|
for _, id in ipairs(list) do
|
||||||
|
local save = decodeSlot(fs, version, id)
|
||||||
|
local name, meta = SaveData.slotSummary(save)
|
||||||
|
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||||
|
label = reg.names and reg.names[id] or nil }
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||||
|
-- you can see that in the launcher"). The label lives in the options
|
||||||
|
-- registry next to list/active, never in the save file itself, so renaming
|
||||||
|
-- needs no save rewrite and an empty slot can be labeled too. The label is
|
||||||
|
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
|
||||||
|
-- false + an error string when the id is not registered.
|
||||||
|
function SaveData.renameSlot(version, slotId, name)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return false, "unknown version" end
|
||||||
|
if type(slotId) ~= "string" or slotId == "" then
|
||||||
|
return false, "missing slot id"
|
||||||
|
end
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
|
local reg = opts.saveSlots[version]
|
||||||
|
if not reg or not reg.list then return false, "slot not registered" end
|
||||||
|
local found = false
|
||||||
|
for _, id in ipairs(reg.list) do
|
||||||
|
if id == slotId then found = true break end
|
||||||
|
end
|
||||||
|
if not found then return false, "slot not registered" end
|
||||||
|
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
|
||||||
|
if label == "" then label = nil end
|
||||||
|
reg.names = reg.names or {}
|
||||||
|
reg.names[slotId] = label
|
||||||
|
if next(reg.names) == nil then reg.names = nil end
|
||||||
|
opts.saveSlots[version] = reg
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Point the active slot at slotId (registering it if new) and persist the
|
||||||
|
-- choice to options.lua; also update the process-global cache so the very
|
||||||
|
-- next save/load lands in the chosen slot.
|
||||||
|
function SaveData.setActiveSlot(version, slotId)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return nil end
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
|
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||||
|
local found = false
|
||||||
|
for _, id in ipairs(reg.list) do
|
||||||
|
if id == slotId then found = true break end
|
||||||
|
end
|
||||||
|
if not found then reg.list[#reg.list + 1] = slotId end
|
||||||
|
reg.active = slotId
|
||||||
|
opts.saveSlots[version] = reg
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
slotsChecked[version] = true
|
||||||
|
activeSlotCache[version] = slotId
|
||||||
|
return slotId
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Register a new empty slot for the version and return its id. Does NOT
|
||||||
|
-- write a save file and does NOT change the active slot: an empty slot
|
||||||
|
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
|
||||||
|
-- allocated one past the highest existing number so a reused id can never
|
||||||
|
-- collide with a lingering file.
|
||||||
|
function SaveData.createSlot(version)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return nil end
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
ensureVersionSlots(version, fs)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
|
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||||
|
local maxN = 0
|
||||||
|
for _, id in ipairs(reg.list) do
|
||||||
|
local n = tonumber(tostring(id):match("^slot(%d+)$"))
|
||||||
|
if n and n > maxN then maxN = n end
|
||||||
|
end
|
||||||
|
local id = "slot" .. (maxN + 1)
|
||||||
|
reg.list[#reg.list + 1] = id
|
||||||
|
opts.saveSlots[version] = reg
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The active slot id in use for a version (resolved once per process like
|
||||||
|
-- saveNames does), or nil when none is registered and the flat legacy path is
|
||||||
|
-- in use. Public so the launcher's save Import/Export glue can name an export
|
||||||
|
-- after the slot it came from without reaching into the private cache.
|
||||||
|
function SaveData.activeSlot(version)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return nil end
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
ensureVersionSlots(version, fs)
|
||||||
|
return activeSlotCache[version] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
|
||||||
|
-- the same fs seam every other save/load call uses (so portable mode keeps
|
||||||
|
-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save
|
||||||
|
-- uses for the flat path. Used by the launcher's save-import glue, which has
|
||||||
|
-- already registered the slot via createSlot but written no bytes yet; unlike
|
||||||
|
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
|
||||||
|
-- options. Returns true, or false + an error string on a failed write.
|
||||||
|
function SaveData.writeSlot(version, slotId, saveTable)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return false, "unknown version" end
|
||||||
|
if type(slotId) ~= "string" then return false, "missing slot id" end
|
||||||
|
if type(saveTable) ~= "table" then return false, "missing save table" end
|
||||||
|
local main, bak, tmp = slotNames(version, slotId)
|
||||||
|
local encoded = SaveSerializer.encode(saveTable)
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
ensureParentDir(fs, main)
|
||||||
|
if fs.getInfo(main) then
|
||||||
|
local prev = fs.read(main)
|
||||||
|
if prev then fs.write(bak, prev) end
|
||||||
|
end
|
||||||
|
local ok, err = fs.write(tmp, encoded)
|
||||||
|
if not ok then return false, err end
|
||||||
|
remove(fs, main)
|
||||||
|
ok, err = fs.write(main, encoded)
|
||||||
|
if not ok then return false, err end
|
||||||
|
remove(fs, tmp)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
|
||||||
|
-- options registry, and if it was active point active at another remaining
|
||||||
|
-- slot (or clear active when the list is empty). Returns true, or false +
|
||||||
|
-- an error string when the id is unknown / not registered.
|
||||||
|
function SaveData.deleteSlot(version, slotId)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then return false, "unknown version" end
|
||||||
|
if type(slotId) ~= "string" or slotId == "" then
|
||||||
|
return false, "missing slot id"
|
||||||
|
end
|
||||||
|
local fs = persistFs(nil)
|
||||||
|
ensureVersionSlots(version, fs)
|
||||||
|
local opts = SaveData.loadOptions(fs)
|
||||||
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
|
local reg = opts.saveSlots[version]
|
||||||
|
if not reg or not reg.list then return false, "slot not registered" end
|
||||||
|
local found, idx = false, nil
|
||||||
|
for i, id in ipairs(reg.list) do
|
||||||
|
if id == slotId then found = true; idx = i; break end
|
||||||
|
end
|
||||||
|
if not found then return false, "slot not registered" end
|
||||||
|
|
||||||
|
local main, bak, tmp = slotNames(version, slotId)
|
||||||
|
remove(fs, main)
|
||||||
|
remove(fs, bak)
|
||||||
|
remove(fs, tmp)
|
||||||
|
|
||||||
|
table.remove(reg.list, idx)
|
||||||
|
if reg.names then reg.names[slotId] = nil end
|
||||||
|
if reg.active == slotId then
|
||||||
|
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||||
|
end
|
||||||
|
opts.saveSlots[version] = reg
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
slotsChecked[version] = true
|
||||||
|
activeSlotCache[version] = reg.active or false
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test seam: drop the process-global slot cache so a suite can exercise
|
||||||
|
-- migration/resolution against a freshly injected filesystem. Unused by
|
||||||
|
-- the game, which resolves each version exactly once per boot.
|
||||||
|
function SaveData.resetSlotState()
|
||||||
|
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
|
||||||
|
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- meta
|
-- ------- meta
|
||||||
|
|
||||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||||
@@ -476,6 +863,25 @@ SaveData.addCoreMigration(2, function(save)
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
|
||||||
|
-- floor after defeat (defeatedTrainers only). The #50 script now hides
|
||||||
|
-- him via objectToggles, but saves that already beat him never got the
|
||||||
|
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
|
||||||
|
-- save reconciles once, then is skipped after re-stamp.
|
||||||
|
SaveData.addCoreMigration(3, function(save)
|
||||||
|
local defeated = save.defeatedTrainers
|
||||||
|
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
|
||||||
|
save.objectToggles = save.objectToggles or {}
|
||||||
|
local mapToggles = save.objectToggles.GAME_CORNER
|
||||||
|
if not mapToggles then
|
||||||
|
mapToggles = {}
|
||||||
|
save.objectToggles.GAME_CORNER = mapToggles
|
||||||
|
end
|
||||||
|
if mapToggles.GAMECORNER_ROCKET ~= false then
|
||||||
|
mapToggles.GAMECORNER_ROCKET = false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
-- ------- write
|
-- ------- write
|
||||||
|
|
||||||
-- Game progress only; options are written separately via saveOptions.
|
-- Game progress only; options are written separately via saveOptions.
|
||||||
@@ -500,6 +906,9 @@ function SaveData.save(data, mods)
|
|||||||
end
|
end
|
||||||
local encoded = SaveSerializer.encode(gameOnly)
|
local encoded = SaveSerializer.encode(gameOnly)
|
||||||
local fs = persistFs(nil)
|
local fs = persistFs(nil)
|
||||||
|
-- the active slot may live in saves/<version>/, which must exist before
|
||||||
|
-- the .tmp/.bak/main writes land (a no-op for the flat legacy path)
|
||||||
|
ensureParentDir(fs, FILENAME)
|
||||||
if fs.getInfo(FILENAME) then
|
if fs.getInfo(FILENAME) then
|
||||||
local prev = fs.read(FILENAME)
|
local prev = fs.read(FILENAME)
|
||||||
if prev then fs.write(BACKUP_FILENAME, prev) end
|
if prev then fs.write(BACKUP_FILENAME, prev) end
|
||||||
@@ -629,6 +1038,17 @@ local function scrubKnownMon(mon, data)
|
|||||||
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
|
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
|
||||||
end
|
end
|
||||||
mon.level = clamp(mon.level, 1, 100, 1)
|
mon.level = clamp(mon.level, 1, 100, 1)
|
||||||
|
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
|
||||||
|
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
|
||||||
|
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
|
||||||
|
-- mon.stats: the status screen opened in the box (#233) and the party list
|
||||||
|
-- after withdrawing one (#304). The original derives them on demand
|
||||||
|
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||||
|
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||||
|
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||||
|
-- so the derived stats use a sane level. A save that already has stats is
|
||||||
|
-- untouched.
|
||||||
|
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||||
local moves = mon.moves
|
local moves = mon.moves
|
||||||
if type(moves) ~= "table" then return end
|
if type(moves) ~= "table" then return end
|
||||||
local hadMoves = #moves > 0
|
local hadMoves = #moves > 0
|
||||||
@@ -811,6 +1231,43 @@ function SaveData.defaultHeal(boot)
|
|||||||
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
||||||
|
-- after jp Init; this port relocates CONTINUE instead, and retargets
|
||||||
|
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
|
||||||
|
-- house does not dump the player back at Indigo Plateau.
|
||||||
|
--
|
||||||
|
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
|
||||||
|
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
|
||||||
|
-- so resuming puts the player outside the front door. Spawning them back
|
||||||
|
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
|
||||||
|
-- spawns are unaffected -- defaultHeal returns boot's own start map for
|
||||||
|
-- those, which is what the bedroom branch resolved to anyway.
|
||||||
|
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||||
|
function SaveData.applyPostGameHome(save, boot)
|
||||||
|
boot = type(boot) == "table" and boot or {}
|
||||||
|
local heal = SaveData.defaultHeal(boot)
|
||||||
|
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||||
|
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||||
|
save.player = save.player or {}
|
||||||
|
save.player.map = heal.map
|
||||||
|
save.player.x = heal.x
|
||||||
|
save.player.y = heal.y
|
||||||
|
save.player.facing = boot.startFacing or "down"
|
||||||
|
save.postGameHomeOk = true
|
||||||
|
return heal
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
|
||||||
|
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
|
||||||
|
function SaveData.needsPostGameRescue(save)
|
||||||
|
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if save.postGameHomeOk then return false end
|
||||||
|
local hof = save.hallOfFame
|
||||||
|
return type(hof) == "table" and #hof > 0
|
||||||
|
end
|
||||||
|
|
||||||
function SaveData.newGame(boot)
|
function SaveData.newGame(boot)
|
||||||
boot = type(boot) == "table" and boot or {}
|
boot = type(boot) == "table" and boot or {}
|
||||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
@@ -834,6 +1291,10 @@ function SaveData.newGame(boot)
|
|||||||
},
|
},
|
||||||
flags = {},
|
flags = {},
|
||||||
inventory = {},
|
inventory = {},
|
||||||
|
-- Vanilla Gen1 seeds one Potion in the player's item PC
|
||||||
|
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
|
||||||
|
-- already have -- this only applies to New Game.
|
||||||
|
pcItems = { POTION = 1 },
|
||||||
party = {},
|
party = {},
|
||||||
box = {},
|
box = {},
|
||||||
money = boot.startMoney or 3000,
|
money = boot.startMoney or 3000,
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
-- The engine's own player-facing text, made overridable.
|
||||||
|
--
|
||||||
|
-- Extracted dialogue already had a home: `Data.text`, keyed by pokered's
|
||||||
|
-- labels, which a mod reaches through `mod.content.text`. The strings the
|
||||||
|
-- engine *authors* had none. Battle messages, item results, menu labels
|
||||||
|
-- and the link-play screens were literals in Lua, so a translator could
|
||||||
|
-- reach two thirds of the game and no more (#186, #245).
|
||||||
|
--
|
||||||
|
-- Those literals stay where they are, wrapped in `S(...)`, and the English
|
||||||
|
-- source doubles as the catalog key:
|
||||||
|
--
|
||||||
|
-- self:say(S("But, it failed!"))
|
||||||
|
-- self:say(S("Wild %s\nappeared!", self.enemy.name))
|
||||||
|
--
|
||||||
|
-- Keying on the source rather than on an invented id is deliberate. It
|
||||||
|
-- keeps the English readable at the point it is used (the alternative
|
||||||
|
-- scatters a thousand `battle.it_failed` ids that have to be looked up to
|
||||||
|
-- review a diff), it needs no id registry to stay in sync, and an entry a
|
||||||
|
-- translation has not covered yet falls through to English instead of
|
||||||
|
-- rendering a raw id at the player. The cost is that editing an English
|
||||||
|
-- string orphans its translations; `tools/modkit.py translation --refresh`
|
||||||
|
-- reports those as changed keys rather than silently dropping them.
|
||||||
|
--
|
||||||
|
-- Same-source-different-meaning is the one case source keys cannot hold on
|
||||||
|
-- their own ("OFF" as a filter setting vs "OFF" as a toggle, which some
|
||||||
|
-- languages render differently). Those sites pass a context:
|
||||||
|
--
|
||||||
|
-- S("OFF", "options.musicFilter") -- key: "options.musicFilter|OFF"
|
||||||
|
--
|
||||||
|
-- A mod supplies the catalog through the `strings` registry:
|
||||||
|
--
|
||||||
|
-- mod.content.strings:override("But, it failed!", "Echec !")
|
||||||
|
--
|
||||||
|
-- With no mod loaded the catalog is empty and `S` is an identity function
|
||||||
|
-- guarded by one boolean, so a vanilla boot draws byte-identical text.
|
||||||
|
|
||||||
|
local Strings = {}
|
||||||
|
|
||||||
|
local catalog = nil -- Data.strings once a mod has put something in it
|
||||||
|
local missing = {} -- format-arity complaints, reported once each
|
||||||
|
|
||||||
|
-- Called from Game after the mod merge, and again on dev-mode hot reload.
|
||||||
|
-- Holding the table (not a copy) means a mod that registers late still
|
||||||
|
-- takes effect without a second load.
|
||||||
|
function Strings.load(data)
|
||||||
|
local t = data and data.strings
|
||||||
|
catalog = nil
|
||||||
|
if type(t) ~= "table" then return end
|
||||||
|
for _ in pairs(t) do
|
||||||
|
catalog = t
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Strings.active()
|
||||||
|
return catalog ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The lookup itself. `context` is optional and only disambiguates sources
|
||||||
|
-- that collide; the plain key is tried after it, so a translation that does
|
||||||
|
-- not care about the distinction can supply one entry for both.
|
||||||
|
function Strings.lookup(source, context)
|
||||||
|
if not catalog then return source end
|
||||||
|
if context then
|
||||||
|
local hit = catalog[context .. "|" .. source]
|
||||||
|
if type(hit) == "string" then return hit end
|
||||||
|
end
|
||||||
|
local hit = catalog[source]
|
||||||
|
if type(hit) == "string" then return hit end
|
||||||
|
return source
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Count `%`-directives so a translation that drops or adds one is caught
|
||||||
|
-- here rather than as a mid-battle `string.format` error.
|
||||||
|
local function specifiers(s)
|
||||||
|
local n = 0
|
||||||
|
for spec in s:gmatch("%%(.)") do
|
||||||
|
if spec ~= "%" then n = n + 1 end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
-- S(source) -> translated source
|
||||||
|
-- S(source, ...) -> translated source, string.format'ed
|
||||||
|
-- S(source, context) -> context-disambiguated lookup, no formatting
|
||||||
|
--
|
||||||
|
-- The two-argument forms are told apart by whether the source carries any
|
||||||
|
-- format directives: a source with no `%s` cannot be formatting, so a lone
|
||||||
|
-- string second argument is a context.
|
||||||
|
function Strings.get(source, ...)
|
||||||
|
local argc = select("#", ...)
|
||||||
|
if argc == 0 then return Strings.lookup(source) end
|
||||||
|
|
||||||
|
local wants = specifiers(source)
|
||||||
|
if wants == 0 and argc == 1 and type((...)) == "string" then
|
||||||
|
return Strings.lookup(source, (...))
|
||||||
|
end
|
||||||
|
|
||||||
|
local text = Strings.lookup(source)
|
||||||
|
-- A translation with the wrong arity would raise inside string.format,
|
||||||
|
-- which in a battle means a crash the player cannot escape. Fall back to
|
||||||
|
-- the English source, which is known to match, and say so once.
|
||||||
|
if specifiers(text) ~= wants then
|
||||||
|
if not missing[source] then
|
||||||
|
missing[source] = true
|
||||||
|
require("src.core.Logger").warn(
|
||||||
|
"strings: translation of %q has %d format directives, source has %d"
|
||||||
|
.. " -- using the source", source, specifiers(text), wants)
|
||||||
|
end
|
||||||
|
text = source
|
||||||
|
end
|
||||||
|
local ok, out = pcall(string.format, text, ...)
|
||||||
|
if not ok then return source end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A marker, not a lookup: returns its argument untouched.
|
||||||
|
--
|
||||||
|
-- Some templates are declared in a module-level table and formatted much
|
||||||
|
-- later (BattleState's charge-move lines, for one). Translating at the
|
||||||
|
-- declaration would freeze the English, because those tables are built at
|
||||||
|
-- require time and Strings.load has no catalog yet; the use site therefore
|
||||||
|
-- calls Strings(template, ...) and looks the source up then. That works at
|
||||||
|
-- runtime but leaves the literal invisible to the catalog generator, which
|
||||||
|
-- only sees what is spelled out at a call site. Wrapping the declaration in
|
||||||
|
-- Strings.source puts it back in the harvest while changing nothing at all
|
||||||
|
-- about when the lookup happens.
|
||||||
|
function Strings.source(text)
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
setmetatable(Strings, { __call = function(_, ...) return Strings.get(...) end })
|
||||||
|
|
||||||
|
return Strings
|
||||||